web: fix note editor properties

This commit is contained in:
Abdullah Atta
2023-11-15 15:11:51 +05:00
parent 7e8e981145
commit 2168609577
36 changed files with 1516 additions and 1094 deletions

View File

@@ -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",
@@ -40764,6 +40765,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",
@@ -46406,17 +46432,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",

View File

@@ -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",

View File

@@ -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;
}

View File

@@ -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<string, Icon> = {
"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<HTMLTableRowElement>;
};
export function Attachment({
attachment,
item,
isSelected,
onSelected,
compact
compact,
rowRef,
style
}: AttachmentProps) {
const [status, setStatus] = useState<AttachmentProgressStatus>();
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 (
<Box
as="tr"
sx={{ height: 30, ":hover": { bg: "hover" } }}
onContextMenu={(e) => {
e.preventDefault();
Menu.openMenu(AttachmentMenuItems(attachment, status));
Menu.openMenu(AttachmentMenuItems(item, status));
}}
onClick={onSelected}
style={style}
ref={rowRef}
>
{!compact && (
<td>
@@ -161,7 +166,7 @@ export function Attachment({
) : (
<Uploading size={16} color="accent" />
)
) : attachment.failed ? (
) : processing?.failed || item.failed ? (
<AttachmentError
color={"icon-error"}
size={16}
@@ -171,7 +176,7 @@ export function Attachment({
: attachment.failed
}
/>
) : attachment.working ? (
) : processing?.working ? (
<Loading size={16} />
) : (
<FileIcon size={16} />
@@ -185,14 +190,12 @@ export function Attachment({
textOverflow: "ellipsis"
}}
>
{attachment.metadata.filename}
{item.filename}
</Text>
</Flex>
</td>
<Text as="td" variant="body">
{attachment.isDeleting ? (
<Loading sx={{ flexShrink: 0 }} size={16} title={"Deleting.."} />
) : attachment.dateUploaded ? (
{item.dateUploaded ? (
<DoubleCheckmark
sx={{ flexShrink: 0 }}
color={"accent"}
@@ -217,13 +220,13 @@ export function Attachment({
{formatBytes(status.loaded, 1)}/{formatBytes(status.total, 1)}
</>
) : (
formatBytes(attachment.length, compact ? 1 : 2)
formatBytes(item.size, compact ? 1 : 2)
)}
</Text>
{!compact && (
<Text as="td" variant="body">
{attachment.dateUploaded
? getFormattedDate(attachment.dateUploaded, "date")
{item.dateUploaded
? getFormattedDate(item.dateUploaded, "date")
: "-"}
</Text>
)}
@@ -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])
}
];
};

View File

@@ -56,7 +56,8 @@ function CachedRouter() {
sx={{
display: key === RouteResult.key ? "flex" : "none",
flexDirection: "column",
flex: 1
flex: 1,
overflow: "hidden"
}}
>
<Component key={key} {...RouteResult.props} />

View File

@@ -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;

View File

@@ -18,6 +18,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
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;
};

View File

@@ -17,15 +17,9 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { 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 (
<ScrollContainer
{...props}
forwardedRef={(sRef) => {
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<VirtuosoHandle>(null);
const listContainerRef = useRef(null);
const listRef = useRef<Virtualizer<Element, Element>>();
useEffect(() => {
return () => {
@@ -125,7 +104,7 @@ function ListContainer(props: ListContainerProps) {
});
return (
<Flex variant="columnFill">
<Flex variant="columnFill" sx={{ overflow: "hidden" }}>
{!props.items.ids.length && props.placeholder ? (
<>
{header}
@@ -139,29 +118,25 @@ function ListContainer(props: ListContainerProps) {
</>
) : (
<>
<Flex
ref={listContainerRef}
variant="columnFill"
<FlexScrollContainer
style={{ display: "flex", flexDirection: "column", flex: 1 }}
data-test-id={`${group}-list`}
>
<Virtuoso
ref={listRef}
data={items.ids}
computeItemKey={(index) => items.getKey(index)}
defaultItemHeight={DEFAULT_ITEM_HEIGHT}
totalCount={items.ids.length}
{header ? header : <Announcements />}
<VirtualizedList
virtualizerRef={listRef}
estimatedSize={DEFAULT_ITEM_HEIGHT}
getItemKey={(index) => 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 : <Announcements />)
}}
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) {
);
}}
/>
</Flex>
</FlexScrollContainer>
</>
)}
{button && (
@@ -250,29 +224,6 @@ function ListContainer(props: ListContainerProps) {
}
export default ListContainer;
function VirtuosoItem({
item: _item,
context,
...props
}: ItemProps<string | GroupHeaderType> & {
context?: {
onMouseDown: (e: MouseEvent, itemIndex: number) => void;
onFocus: (itemIndex: number) => void;
};
}) {
return (
<div
{...props}
onFocus={() => context?.onFocus(props["data-item-index"])}
onMouseDown={(e) =>
context?.onMouseDown(e.nativeEvent, props["data-item-index"])
}
>
{props.children}
</div>
);
}
/**
* 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<Element, Element>,
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();
}

View File

@@ -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<TItem = Item> = {
type ListItemWrapperProps = {
group?: GroupingKey;
items: VirtualizedGrouping<TItem>;
items: VirtualizedGrouping<Item>;
id: string;
context?: Context;
compact?: boolean;
simplified?: boolean;
};
export function ListItemWrapper(props: ListItemWrapperProps) {
const { id, items, group, compact, context } = props;
const [item, setItem] = useState<Item>();
const tags = useRef<TagsWithDateEdited>();
const notebooks = useRef<NotebooksWithDateEdited>();
const reminder = useRef<ReminderItem>();
const color = useRef<Color>();
const totalNotes = useRef<number>(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 <div style={{ height: DEFAULT_ITEM_HEIGHT, width: "100%" }} />;
const { type } = item;
switch (type) {
const { data, item } = resolvedItem;
switch (item.type) {
case "note": {
return (
<Note
compact={compact}
item={item}
tags={tags.current}
color={color.current}
notebooks={notebooks.current}
reminder={reminder.current}
date={getDate(item, group)}
context={context}
{...(isNoteResolvedData(data) ? data : {})}
/>
);
}
case "notebook":
if (context?.type === "notebook")
return (
<SubNotebook
item={item}
totalNotes={totalNotes.current}
notebookId={context.id}
/>
);
return (
<Notebook
item={item}
totalNotes={totalNotes.current}
totalNotes={typeof data === "number" ? data : 0}
date={getDate(item, group)}
simplified={simplified}
/>
);
case "trash":
return <TrashItem item={item} date={getDate(item, type)} />;
return <TrashItem item={item} date={getDate(item, group)} />;
case "reminder":
return <Reminder item={item} />;
return <Reminder item={item} simplified={simplified} />;
case "tag":
return <Tag item={item} totalNotes={totalNotes.current} />;
return (
<Tag item={item} totalNotes={typeof data === "number" ? data : 0} />
);
case "attachment":
return <Attachment item={item} compact={compact} />;
default:
return null;
}
}
function withDateEdited<
T extends { dateEdited: number } | { dateModified: number }
>(items: T[]): WithDateEdited<T> {
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<string, Item>) {
const { type } = items[ids[0]];
if (type === "note") return resolveNotes(ids);
else if (type === "notebook") {
const data: Record<string, number> = {};
for (const id of ids) data[id] = await db.notebooks.totalNotes(id);
return data;
} else if (type === "tag") {
const data: Record<string, number> = {};
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<string>;
colors: Set<string>;
tags: Set<string>;
reminders: Set<string>;
} = {
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<string, NoteResolvedData> = {};
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
);
}

View File

@@ -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 <http://www.gnu.org/licenses/>.
*/
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<Item>;
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<ResolvedItemProps, "children">) {
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<T> {
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<string, Item>) {
const { type } = items[ids[0]];
if (type === "note") return resolveNotes(ids);
else if (type === "notebook") {
const data: Record<string, number> = {};
for (const id of ids) data[id] = await db.notebooks.totalNotes(id);
return data;
} else if (type === "tag") {
const data: Record<string, number> = {};
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<string>;
colors: Set<string>;
tags: Set<string>;
reminders: Set<string>;
} = {
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<string, NoteResolvedData> = {};
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
);
}

View File

@@ -183,7 +183,7 @@ function Note(props: NoteProps) {
icon={Notebook}
/>
))}
{reminder && isReminderActive(reminder) && (
{reminder && isReminderActive(reminder) ? (
<IconTag
icon={Reminder}
text={getFormattedReminderTime(reminder, true)}
@@ -197,7 +197,7 @@ function Note(props: NoteProps) {
: {}
}
/>
)}
) : null}
</Flex>
}
footer={

View File

@@ -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 <http://www.gnu.org/licenses/>.
*/
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 (
<AnimatedFlex
animate={{
x: 0
}}
transition={{
duration: 0.1,
bounceDamping: 1,
bounceStiffness: 1,
ease: "easeOut"
}}
initial={{ x: 600 }}
sx={{
display: "flex",
position: "absolute",
right: 0,
zIndex: 3,
height: "100%",
width: "300px",
borderLeft: "1px solid",
borderLeftColor: "border"
}}
>
<ScopedThemeProvider
scope="editorSidebar"
sx={{
flex: 1,
display: "flex",
bg: "background",
overflowY: "hidden",
overflowX: "hidden",
flexDirection: "column"
}}
>
<ScrollContainer>
<Card
title="Properties"
button={
<ArrowLeft
data-test-id="properties-close"
onClick={() => toggleProperties(false)}
size={18}
sx={{ mr: 1, cursor: "pointer" }}
/>
}
>
{!isPreviewMode && (
<>
{tools.map((tool) => (
<Toggle
{...tool}
key={tool.key}
toggleKey={tool.property}
onToggle={(state) => changeState(tool.key, state)}
testId={`properties-${tool.key}`}
/>
))}
</>
)}
{metadataItems.map((item) => (
<Flex
key={item.key}
py={2}
px={2}
sx={{
borderBottom: "1px solid var(--separator)",
alignItems: "center",
justifyContent: "space-between"
}}
>
<Text variant="subBody" sx={{ fontSize: "body" }}>
{item.label}
</Text>
<Text
className="selectable"
variant="subBody"
sx={{ fontSize: "body" }}
>
{item.value(session[item.key])}
</Text>
</Flex>
))}
{!isPreviewMode && (
<>
<Flex
py={2}
px={2}
sx={{
cursor: "pointer",
justifyContent: "center"
}}
>
{COLORS.map((label) => (
<Flex
key={label}
onClick={() => noteStore.get().setColor(sessionId, label)}
sx={{
cursor: "pointer",
position: "relative",
alignItems: "center",
justifyContent: "space-between"
}}
data-test-id={`properties-${label}`}
>
<Circle
size={35}
color={label.toLowerCase()}
data-test-id={`toggle-state-${
label.toLowerCase() === color?.toLowerCase()
? "on"
: "off"
}`}
/>
{label.toLowerCase() === color?.toLowerCase() && (
<Checkmark
color="white"
size={18}
sx={{ position: "absolute", left: "8px" }}
/>
)}
</Flex>
))}
</Flex>
</>
)}
</Card>
{allNotebooks?.length > 0 && (
<Card title="Notebooks">
{allNotebooks.map((notebook) => (
<Notebook
key={notebook.id}
item={notebook}
date={notebook.dateCreated}
totalNotes={0} // getTotalNotes(notebook)}
simplified
/>
))}
</Card>
)}
{reminders.length > 0 && (
<Card title="Reminders">
{reminders.map((reminder) => {
return (
<Reminder key={reminder.id} item={reminder} simplified />
);
})}
</Card>
)}
{attachments.length > 0 && (
<Card
title="Attachments"
subtitle={`${attachments.length} attachments | ${formatBytes(
getTotalSize(attachments)
)} occupied`}
>
<table style={{ borderSpacing: 0 }}>
<tbody>
{attachments.map((attachment, i) => (
<Attachment
key={attachment.id}
compact
attachment={attachment}
/>
))}
</tbody>
</table>
</Card>
)}
<Card
title="Previous Sessions"
subtitle={"Your session history is local only."}
>
{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 (
<Flex
key={session.id}
data-test-id={`session-item`}
py={1}
px={2}
sx={{
cursor: "pointer",
bg: isSelected ? "background-selected" : "transparent",
":hover": {
bg: isSelected ? "hover-selected" : "hover"
},
alignItems: "center",
justifyContent: "space-between"
}}
title="Click to preview"
onClick={async () => {
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
});
}
}}
>
<Text variant={"body"} data-test-id="title">
{label}
</Text>
<Flex
sx={{
fontSize: "subBody",
color: "paragraph-secondary"
}}
>
{session.locked && <Lock size={14} data-test-id="locked" />}
<TimeAgo
live
datetime={session.dateModified}
locale={"en_short"}
/>
</Flex>
</Flex>
);
})}
</Card>
</ScrollContainer>
</ScopedThemeProvider>
</AnimatedFlex>
);
}
export default React.memo(Properties);
function Card({ title, subtitle, button, children }) {
return (
<Flex
sx={{
borderRadius: "default",
flexDirection: "column"
}}
>
<Flex mx={2} mt={2} sx={{ alignItems: "center" }}>
{button}
<Text variant="subtitle">{title}</Text>
</Flex>
{subtitle && (
<Text variant="subBody" mb={1} mx={2}>
{subtitle}
</Text>
)}
{children}
</Flex>
);
}

View File

@@ -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 <http://www.gnu.org/licenses/>.
*/
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<T extends keyof EditorSession = keyof EditorSession> = {
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 (
<AnimatedFlex
animate={{
x: 0
}}
transition={{
duration: 0.1,
bounceDamping: 1,
bounceStiffness: 1,
ease: "easeOut"
}}
initial={{ x: 600 }}
sx={{
display: "flex",
position: "absolute",
right: 0,
zIndex: 3,
height: "100%",
width: "300px",
borderLeft: "1px solid",
borderLeftColor: "border"
}}
>
<ScopedThemeProvider
scope="editorSidebar"
sx={{
flex: 1,
display: "flex",
bg: "background",
overflowY: "hidden",
overflowX: "hidden",
flexDirection: "column"
}}
>
<ScrollContainer>
<Section
title="Properties"
button={
<ArrowLeft
data-test-id="properties-close"
onClick={() => toggleProperties(false)}
size={18}
sx={{ mr: 1, cursor: "pointer" }}
/>
}
>
{!isPreviewMode && (
<>
{tools.map((tool) => (
<Toggle
{...tool}
key={tool.key}
toggleKey={tool.property}
onToggle={() => changeToggleState(tool.key)}
testId={`properties-${tool.key}`}
/>
))}
</>
)}
{metadataItems.map((item) => (
<Flex
key={item.key}
py={2}
px={2}
sx={{
borderBottom: "1px solid var(--separator)",
alignItems: "center",
justifyContent: "space-between"
}}
>
<Text variant="subBody" sx={{ fontSize: "body" }}>
{item.label}
</Text>
<Text
className="selectable"
variant="subBody"
sx={{ fontSize: "body" }}
>
{item.value(session[item.key])}
</Text>
</Flex>
))}
{!isPreviewMode && <Colors noteId={sessionId} />}
</Section>
<Notebooks noteId={sessionId} />
<Reminders noteId={sessionId} />
<Attachments noteId={sessionId} />
<SessionHistory
noteId={sessionId}
dateCreated={dateCreated || 0}
isPreviewMode={isPreviewMode}
onOpenPreviewSession={onOpenPreviewSession}
/>
</ScrollContainer>
</ScopedThemeProvider>
</AnimatedFlex>
);
}
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 (
<Flex
py={2}
px={2}
sx={{
cursor: "pointer",
justifyContent: "center"
}}
>
{COLORS.map((label) => (
<Flex
key={label.key}
onClick={() => noteStore.get().setColor(label.key, noteId)}
sx={{
cursor: "pointer",
position: "relative",
alignItems: "center",
justifyContent: "space-between"
}}
data-test-id={`properties-${label}`}
>
<Circle
size={35}
color={DefaultColors[label.key]}
data-test-id={`toggle-state-${
result.status === "fulfilled" &&
label.key === result.value?.colorCode
? "on"
: "off"
}`}
/>
{result.status === "fulfilled" &&
label.key === result.value?.colorCode && (
<Checkmark
color="white"
size={18}
sx={{ position: "absolute", left: "8px" }}
/>
)}
</Flex>
))}
</Flex>
);
}
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 (
<Section title="Notebooks">
<VirtualizedList
mode="fixed"
estimatedSize={50}
getItemKey={(index) => result.value.getKey(index)}
items={result.value.ids}
renderItem={(id) => (
<ListItemWrapper id={id as string} items={result.value} simplified />
)}
/>
</Section>
);
}
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 (
<Section title="Reminders">
<VirtualizedList
mode="fixed"
estimatedSize={54}
getItemKey={(index) => result.value.getKey(index)}
items={result.value.ids}
renderItem={(id) => (
<ListItemWrapper id={id as string} items={result.value} simplified />
)}
/>
</Section>
);
}
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 (
<Section title="Attachments">
{result.value.ids.map((id, index) => (
<ListItemWrapper
key={result.value.getKey(index)}
id={id as string}
items={result.value}
compact
/>
))}
</Section>
);
}
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 (
<Section
title="Previous Sessions"
subtitle={"Your session history is local only."}
>
<VirtualizedList
mode="fixed"
estimatedSize={28}
getItemKey={(index) => result.value.getKey(index)}
items={result.value.ids}
renderItem={(id) => (
<ResolvedItem id={id as string} items={result.value}>
{({ item }) =>
item.type === "session" ? (
<SessionItem
noteId={noteId}
session={item}
dateCreated={dateCreated}
isPreviewMode={isPreviewMode}
onOpenPreviewSession={onOpenPreviewSession}
/>
) : null
}
</ResolvedItem>
)}
/>
</Section>
);
}
type SectionProps = { title: string; subtitle?: string; button?: JSX.Element };
function Section({
title,
subtitle,
button,
children
}: PropsWithChildren<SectionProps>) {
return (
<Flex
sx={{
borderRadius: "default",
flexDirection: "column"
}}
>
<Flex mx={2} mt={2} sx={{ alignItems: "center" }}>
{button}
<Text variant="subtitle">{title}</Text>
</Flex>
{subtitle && (
<Text variant="subBody" mb={1} mx={2}>
{subtitle}
</Text>
)}
{children}
</Flex>
);
}
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;
}
}

View File

@@ -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",

View File

@@ -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 <http://www.gnu.org/licenses/>.
*/
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 (
<Flex
key={session.id}
data-test-id={`session-item`}
py={1}
px={2}
sx={{
cursor: "pointer",
bg: isSelected ? "background-selected" : "transparent",
":hover": {
bg: isSelected ? "hover-selected" : "hover"
},
alignItems: "center",
justifyContent: "space-between"
}}
title="Click to preview"
onClick={async () => {
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
});
}
}}
>
<Text variant={"body"} data-test-id="title">
{label}
</Text>
<Flex
sx={{
fontSize: "subBody",
color: "paragraph-secondary"
}}
>
{session.locked && <Lock size={14} data-test-id="locked" />}
<TimeAgo live datetime={session.dateModified} locale={"en_short"} />
</Flex>
</Flex>
);
}

View File

@@ -66,7 +66,6 @@ export function ThemePreview(props: ThemePreviewProps) {
theme.previewColors.background
].map((color) => (
<Circle
key={color}
color={color}
size={18}
sx={{

View File

@@ -0,0 +1,121 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Virtualizer, useVirtualizer } from "@tanstack/react-virtual";
import { Box } from "@theme-ui/components";
import React, { useEffect, useRef } from "react";
type VirtualizedGridProps<T> = {
virtualizerRef?: React.MutableRefObject<
Virtualizer<Element, Element> | 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<T>(props: VirtualizedGridProps<T>) {
const {
items,
getItemKey,
scrollElement,
scrollMargin,
renderItem: Item,
estimatedSize,
mode,
virtualizerRef,
columns,
onEndReached
} = props;
const containerRef = useRef<HTMLDivElement>(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 (
<Box ref={containerRef} className="Grid">
<Box
sx={{
height: virtualizer.getTotalSize(),
width: "100%",
position: "relative"
}}
>
{virtualItems.map((row) => (
<Box
key={row.key}
data-index={row.index}
ref={mode === "dynamic" ? virtualizer.measureElement : null}
style={{
position: "absolute",
top: 0,
left: `${(100 / columns) * row.lane}%`,
width: `${100 / columns}%`,
height: mode === "dynamic" ? "unset" : `${row.size}px`,
transform: `translateY(${
row.start - virtualizer.options.scrollMargin
}px)`
}}
>
<Item key={row.key} item={items[row.index]} index={row.index} />
</Box>
))}
</Box>
</Box>
);
}

View File

@@ -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 <http://www.gnu.org/licenses/>.
*/
import { Virtualizer, useVirtualizer } from "@tanstack/react-virtual";
import { Box, BoxProps } from "@theme-ui/components";
import React, { useRef } from "react";
type VirtualizedListProps<T> = {
virtualizerRef?: React.MutableRefObject<
Virtualizer<Element, Element> | 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<T>(props: VirtualizedListProps<T>) {
const {
items,
getItemKey,
scrollElement,
scrollMargin,
renderItem: Item,
estimatedSize,
mode,
virtualizerRef,
itemWrapperProps,
...containerProps
} = props;
const containerRef = useRef<HTMLDivElement>(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 (
<Box {...containerProps} ref={containerRef} className="List">
<Box
sx={{
height: virtualizer.getTotalSize(),
width: "100%",
position: "relative"
}}
>
{virtualItems.map((row) => (
<Box
{...itemWrapperProps?.(items[row.index], row.index)}
key={row.key}
data-index={row.index}
ref={mode === "dynamic" ? virtualizer.measureElement : null}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: mode === "dynamic" ? "unset" : `${row.size}px`,
transform: `translateY(${
row.start - virtualizer.options.scrollMargin
}px)`
}}
>
<Item key={row.key} item={items[row.index]} index={row.index} />
</Box>
))}
</Box>
</Box>
);
}

View File

@@ -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 <http://www.gnu.org/licenses/>.
*/
import { Virtualizer, useVirtualizer } from "@tanstack/react-virtual";
import { Box } from "@theme-ui/components";
import React, { useRef } from "react";
export type VirtualizedTableRowProps<T, C> = {
item: T;
index: number;
style: React.CSSProperties;
rowRef?: React.Ref<HTMLTableRowElement>;
context?: C;
};
type VirtualizedTableProps<T, C> = {
virtualizerRef?: React.MutableRefObject<
Virtualizer<Element, Element> | undefined
>;
mode?: "fixed" | "dynamic";
items: T[];
estimatedSize: number;
getItemKey: (index: number) => string;
scrollElement?: Element | null;
context?: C;
renderRow: (props: VirtualizedTableRowProps<T, C>) => JSX.Element | null;
scrollMargin?: number;
header: React.ReactNode;
style?: React.CSSProperties;
};
export function VirtualizedTable<T, C>(props: VirtualizedTableProps<T, C>) {
const {
items,
getItemKey,
scrollElement,
scrollMargin,
renderRow: Row,
estimatedSize,
mode,
virtualizerRef,
header,
style,
context
} = props;
const containerRef = useRef<HTMLDivElement>(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 (
<Box
ref={containerRef}
sx={{
height: virtualizer.getTotalSize()
}}
>
<table style={style}>
<thead>{header}</thead>
<tbody>
{virtualItems.map((row, index) => (
<Row
key={row.key}
item={items[row.index]}
index={row.index}
rowRef={mode === "dynamic" ? virtualizer.measureElement : null}
context={context}
style={{
height: mode === "dynamic" ? "unset" : `${row.size}px`,
transform: `translateY(${
row.start -
index * row.size -
virtualizer.options.scrollMargin
}px)`
}}
/>
))}
</tbody>
</table>
</Box>
);
}

View File

@@ -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 =

View File

@@ -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 (
<Dialog
isOpen={true}
@@ -157,16 +155,14 @@ function AttachmentsDialog({ onClose }: AttachmentsDialogProps) {
>
<Flex
sx={{
height: "80vw"
height: "80vw",
overflow: "hidden"
}}
>
<Sidebar
totalSize={totalSize}
onDownloadAll={() => 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"
}}
>
<Flex sx={{ justifyContent: "space-between" }}>
<Flex sx={{ gap: 1 }}>
{TOOLBAR_ACTIONS.map((tool) => (
<Button
variant="secondary"
key={tool.title}
title={tool.title}
onClick={() =>
tool.onClick({
selected
// : attachments.filter(
// (a) => selected.indexOf(a.id) > -1
// )
})
}
disabled={!selected.length}
sx={{ bg: "transparent", p: 1 }}
>
<tool.icon size={18} />
</Button>
))}
</Flex>
{/* <Button
<FlexScrollContainer>
<Flex sx={{ justifyContent: "space-between" }}>
<Flex sx={{ gap: 1 }}>
{TOOLBAR_ACTIONS.map((tool) => (
<Button
variant="secondary"
key={tool.title}
title={tool.title}
onClick={() =>
tool.onClick({
selected
// : attachments.filter(
// (a) => selected.indexOf(a.id) > -1
// )
})
}
disabled={!selected.length}
sx={{ bg: "transparent", p: 1 }}
>
<tool.icon size={18} />
</Button>
))}
</Flex>
{/* <Button
variant="tool"
sx={{ p: 1, display: "flex" }}
onClick={async () => {
@@ -232,114 +222,113 @@ function AttachmentsDialog({ onClose }: AttachmentsDialogProps) {
Upload
</Text>
</Button> */}
</Flex>
{attachments && (
<TableVirtuoso
components={{
Scroller: CustomScrollbarsVirtualList,
TableRow
}}
style={{ height: "100%" }}
computeItemKey={(index) => 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={() => (
<Box
as="tr"
sx={{
height: 40,
th: { borderBottom: "1px solid var(--separator)" },
bg: "background"
}}
>
<Text
as="th"
variant="body"
</Flex>
{attachments && (
<VirtualizedTable
style={{ tableLayout: "fixed", borderCollapse: "collapse" }}
header={
<Box
as="tr"
sx={{
width: 24,
textAlign: "left",
fontWeight: "normal",
mb: 2
height: 40,
th: { borderBottom: "1px solid var(--separator)" },
bg: "background"
}}
>
<Label>
<Checkbox
sx={{ width: 18, height: 18 }}
onChange={(e) => {
setSelected(
e.currentTarget.checked ? attachments.ungrouped : []
);
}}
/>
</Label>
</Text>
{COLUMNS.map((column) =>
!column.title ? (
<th key={column.id} />
) : (
<Box
as="th"
key={column.id}
sx={{
width: column.width,
cursor: "pointer",
px: 1,
mb: 2,
":hover": { bg: "hover" }
}}
onClick={() => {
setSortBy((sortBy) => ({
direction:
sortBy.id === column.id &&
sortBy.direction === "asc"
? "desc"
: "asc",
id: column.id
}));
}}
>
<Flex
<Text
as="th"
variant="body"
sx={{
width: 24,
textAlign: "left",
fontWeight: "normal",
mb: 2
}}
>
<Label>
<Checkbox
sx={{ width: 18, height: 18 }}
onChange={(e) => {
setSelected(
e.currentTarget.checked
? attachments.ungrouped
: []
);
}}
/>
</Label>
</Text>
{COLUMNS.map((column) =>
!column.title ? (
<th key={column.id} />
) : (
<Box
as="th"
key={column.id}
sx={{
alignItems: "center",
justifyContent: "space-between"
width: column.width,
cursor: "pointer",
px: 1,
mb: 2,
":hover": { bg: "hover" }
}}
onClick={() => {
setSortBy((sortBy) => ({
direction:
sortBy.id === column.id &&
sortBy.direction === "asc"
? "desc"
: "asc",
id: column.id
}));
}}
>
<Text
variant="body"
sx={{ textAlign: "left", fontWeight: "normal" }}
<Flex
sx={{
alignItems: "center",
justifyContent: "space-between"
}}
>
{column.title}
</Text>
{sortBy.id === column.id ? (
sortBy.direction === "asc" ? (
<ChevronUp size={16} />
) : (
<ChevronDown size={16} />
)
) : null}
</Flex>
</Box>
)
)}
</Box>
)}
itemContent={() => <></>}
/>
)}
<Text
variant="body"
sx={{ textAlign: "left", fontWeight: "normal" }}
>
{column.title}
</Text>
{sortBy.id === column.id ? (
sortBy.direction === "asc" ? (
<ChevronUp size={16} />
) : (
<ChevronDown size={16} />
)
) : null}
</Flex>
</Box>
)
)}
</Box>
}
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}
/>
)}
</FlexScrollContainer>
</Flex>
</Flex>
</Dialog>
@@ -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<AttachmentType>;
}
>
) {
if (!props.context) return null;
return (
<ResolvedItem id={props.item} items={props.context.attachments}>
{({ item }) =>
item.type === "attachment" ? (
<Attachment
rowRef={props.rowRef}
style={props.style}
item={item}
isSelected={props.context?.isSelected(props.item)}
onSelected={() => props.context?.select(props.item)}
/>
) : null
}
</ResolvedItem>
);
}
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<Route, number>;
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 (
<ScopedThemeProvider scope="navigationMenu" injectCssVars={false}>
@@ -410,6 +427,8 @@ const Sidebar = memo(
>
<Flex sx={{ flexDirection: "column" }}>
<Input
id="search"
name="search"
placeholder="Search"
sx={{ m: 2, mb: 0, width: "auto", bg: "background", py: "7px" }}
onChange={(e) => {
@@ -435,7 +454,11 @@ const Sidebar = memo(
<Flex sx={{ pl: 2, m: 2, mt: 1, justifyContent: "space-between" }}>
<Flex sx={{ flexDirection: "column" }}>
<Text variant="body">{pluralize(counts.all, "file")}</Text>
<Text variant="subBody">{formatBytes(totalSize)}</Text>
{result.status === "fulfilled" && (
<Text variant="subBody">
{formatBytes(result.value || 0)}
</Text>
)}
</Flex>
<Button
variant="secondary"
@@ -478,7 +501,6 @@ const Sidebar = memo(
);
},
(prev, next) =>
prev.totalSize === next.totalSize &&
prev.counts.all === next.counts.all &&
prev.counts.documents === next.counts.documents &&
prev.counts.images === next.counts.images &&
@@ -487,36 +509,6 @@ const Sidebar = memo(
prev.counts.orphaned === next.counts.orphaned
);
function TableRow(
props: ItemProps<string> & {
context?: {
isSelected: (id: string) => boolean;
select: (id: string) => void;
attachments: VirtualizedGrouping<AttachmentType>;
};
}
) {
const { context, item, ...restProps } = props;
const result = usePromise(
() => context?.attachments.item(item),
[item, context]
);
if (result.status !== "fulfilled" || !result.value)
return <div {...restProps} />;
return (
<Attachment
{...restProps}
key={item}
attachment={result.value}
isSelected={context?.isSelected(item)}
onSelected={() => {
context?.select(item);
}}
/>
);
}
async function getCounts(): Promise<Record<Route, number>> {
return {
all: await db.attachments.all.count(),

View File

@@ -37,10 +37,10 @@ import {
import { ThemeMetadata } from "@notesnook/themes-server";
import { showThemeDetails } from "../../../common/dialog-controller";
import { ThemePreview } from "../../../components/theme-preview";
import { VirtuosoGrid } from "react-virtuoso";
import { Loader } from "../../../components/loader";
import { showToast } from "../../../utils/toast";
import { showFilePicker, readFile } from "../../../utils/file-picker";
import { VirtualizedGrid } from "../../../components/virtualized-grid";
const ThemesClient = ThemesTRPC.createClient({
links: [
@@ -107,6 +107,18 @@ function ThemesList() {
}
);
const items = [
{
...darkTheme,
previewColors: getPreviewColors(darkTheme)
},
{
...lightTheme,
previewColors: getPreviewColors(lightTheme)
},
...(themes.data?.pages.flatMap((a) => a.themes) || [])
];
const setTheme = useCallback(
async (theme: ThemeMetadata) => {
if (isThemeCurrentlyApplied(theme.id)) return;
@@ -198,38 +210,22 @@ function ThemesList() {
<Box
sx={{
".virtuoso-grid-list": {
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: 2
},
mt: 2
}}
>
{themes.isInitialLoading ? (
<Loader title={"Loading themes..."} />
) : (
<VirtuosoGrid
customScrollParent={
document.getElementById("settings-scrollbar") || undefined
}
data={[
{
...darkTheme,
previewColors: getPreviewColors(darkTheme)
},
{
...lightTheme,
previewColors: getPreviewColors(lightTheme)
},
...(themes.data?.pages.flatMap((a) => a.themes) || [])
]}
endReached={() =>
<VirtualizedGrid
columns={2}
items={items}
getItemKey={(index) => items[index].id}
estimatedSize={285}
mode="dynamic"
onEndReached={() =>
themes.hasNextPage ? themes.fetchNextPage() : null
}
context={{ darkTheme, lightTheme, setTheme }}
computeItemKey={(_index, item) => item.id}
itemContent={(_index, theme) => (
renderItem={({ item: theme }) => (
<ThemeItem
key={theme.id}
theme={theme}

View File

@@ -242,6 +242,8 @@ function SettingsSideBar(props: SettingsSideBarProps) {
}}
>
<Input
id="search"
name="search"
placeholder="Search"
data-test-id="settings-search"
sx={{

View File

@@ -61,7 +61,7 @@ export const ProfileSettings: SettingsGroup[] = [
key: "manage-attachments",
title: "Attachments",
description: "Manage all your attachments in one place.",
isHidden: () => !useUserStore.getState().isLoggedIn,
// isHidden: () => !useUserStore.getState().isLoggedIn,
components: [
{
type: "button",

View File

@@ -46,8 +46,8 @@ enum SESSION_STATES {
opening = "opening"
}
type EditorSession = {
sessionType: "default" | "locked";
export type EditorSession = {
sessionType: "default" | "locked" | "preview";
content?: NoteContent<false>;
isDeleted: boolean;
attachmentsLength: number;

View File

@@ -46,14 +46,11 @@ export class Compressor implements ICompressor {
return await desktop.compress.gunzip.query(data);
await this.init();
const bytes = new Memory(Buffer.from(data, "base64"));
return gunzip(Buffer.from(data, "base64"))
.copyAndDispose()
.toString("utf-8");
// return new Promise<string>((resolve, reject) => {
// gunzip(Buffer.from(data, "base64"), (err, data) =>
// err ? reject(err) : resolve(Buffer.from(data.buffer).toString("utf-8"))
// );
// });
const res = gunzip(bytes);
const text = Buffer.from(res.bytes).toString("utf-8");
res.free();
return text;
}
}

View File

@@ -22,7 +22,7 @@ import Database from ".";
import { CHECK_IDS, EV, EVENTS, checkIsUserPremium } from "../common";
import { tinyToTiptap } from "../migrations";
import { isCipher } from "../database/crypto";
import { EncryptedContentItem, Note } from "../types";
import { Note } from "../types";
import {
isEncryptedContent,
isUnencryptedContent
@@ -110,6 +110,7 @@ export default class Vault {
try {
const content = await this.decryptContent(
encryptedContent,
note.id,
oldPassword
);
contentItems.push({
@@ -239,18 +240,18 @@ export default class Vault {
}
async decryptContent(
encryptedContent: EncryptedContentItem,
encryptedContent: NoteContent<true>,
noteId: string,
password?: string
) {
if (!password) password = await this.getVaultPassword();
if (
encryptedContent.noteId &&
typeof encryptedContent.data !== "object" &&
!isCipher(encryptedContent.data)
) {
await this.db.notes.add({
id: encryptedContent.noteId,
id: noteId,
locked: false
});
return { data: encryptedContent.data, type: encryptedContent.type };
@@ -330,7 +331,11 @@ export default class Vault {
const encryptedContent = await this.db.content.get(note.contentId);
if (!encryptedContent || !isEncryptedContent(encryptedContent)) return;
const content = await this.decryptContent(encryptedContent, password);
const content = await this.decryptContent(
encryptedContent,
note.id,
password
);
if (perm) {
await this.db.notes.add({

View File

@@ -29,6 +29,7 @@ import { Attachment } from "../types";
import Database from "../api";
import { FilteredSelector, SQLCollection } from "../database/sql-collection";
import { isFalse } from "../database";
import { sql } from "kysely";
export class Attachments implements ICollection {
name = "attachments";
@@ -470,6 +471,13 @@ export class Attachments implements ICollection {
);
}
async totalSize(selector: FilteredSelector<Attachment> = this.all) {
const result = await selector.filter
.select((eb) => eb.fn.sum<number>(sql.raw(`size + 17`)).as("totalSize"))
.executeTakeFirst();
return result?.totalSize;
}
private async encryptKey(key: SerializedKey) {
const encryptionKey = await this._getEncryptionKey();
const encryptedKey = await this.db

View File

@@ -19,7 +19,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import Database from "../api";
import { isCipher } from "../database/crypto";
import { SQLCollection } from "../database/sql-collection";
import { FilteredSelector, SQLCollection } from "../database/sql-collection";
import { HistorySession, isDeleted } from "../types";
import { makeSessionContentId } from "../utils/id";
import { ICollection } from "./collection";
@@ -39,25 +39,31 @@ export class NoteHistory implements ICollection {
await this.sessionContent.init();
}
async get(noteId: string, order: "asc" | "desc" = "desc") {
if (!noteId) return [];
// async get(noteId: string, order: "asc" | "desc" = "desc") {
// if (!noteId) return [];
// const indices = this.collection.indexer.indices;
// const sessionIds = indices.filter((id) => id.startsWith(noteId));
// if (sessionIds.length === 0) return [];
// const history = await this.getSessions(sessionIds);
// // const indices = this.collection.indexer.indices;
// // const sessionIds = indices.filter((id) => id.startsWith(noteId));
// // if (sessionIds.length === 0) return [];
// // const history = await this.getSessions(sessionIds);
// return history.sort(function (a, b) {
// return b.dateModified - a.dateModified;
// });
const history = await this.db
.sql()
.selectFrom("notehistory")
.where("noteId", "==", noteId)
.orderBy(`dateModified ${order}`)
.selectAll()
.execute();
return history as HistorySession[];
// // return history.sort(function (a, b) {
// // return b.dateModified - a.dateModified;
// // });
// const history = await this.db
// .sql()
// .selectFrom("notehistory")
// .where("noteId", "==", noteId)
// .orderBy(`dateModified ${order}`)
// .selectAll()
// .execute();
// return history as HistorySession[];
// }
get(noteId: string) {
return new FilteredSelector<HistorySession>(
"notehistory",
this.db.sql().selectFrom("notehistory").where("noteId", "==", noteId)
);
}
async add(

View File

@@ -70,7 +70,9 @@ export class SessionContent implements ICollection {
});
}
async get(sessionContentId: string) {
async get(
sessionContentId: string
): Promise<NoteContent<boolean> | undefined> {
const session = await this.collection.get(sessionContentId);
if (!session || isDeleted(session)) return;

View File

@@ -38,7 +38,7 @@ const DEFAULT_GROUP_OPTIONS = (key: GroupingKey) =>
sortBy:
key === "trash"
? "dateDeleted"
: key === "tags"
: key === "tags" || key === "reminders"
? "dateCreated"
: key === "reminders"
? "dueDate"

View File

@@ -345,31 +345,37 @@ export default class Backup {
if ("sessionContentId" in item && item.type !== "session")
(item as any).type = "notehistory";
await migrateItem(
item,
version,
CURRENT_DATABASE_VERSION,
item.type,
this.db,
"backup"
);
if (
(await migrateItem(
item,
version,
CURRENT_DATABASE_VERSION,
item.type,
this.db,
"backup"
)) === "skip"
)
continue;
// since items in trash can have their own set of migrations,
// we have to run the migration again to account for that.
if (item.type === "trash" && item.itemType)
await migrateItem(
item as unknown as Note | Notebook,
version,
CURRENT_DATABASE_VERSION,
item.itemType,
this.db,
"backup"
);
if (
(await migrateItem(
item as unknown as Note | Notebook,
version,
CURRENT_DATABASE_VERSION,
item.itemType,
this.db,
"backup"
)) === "skip"
)
continue;
const itemType =
// colors are naively of type "tag" instead of "color" so we have to fix that.
item.type === "tag" && COLORS.includes(item.title.toLowerCase())
? "color"
: "itemType" in item && item.itemType
: item.type === "trash" && "itemType" in item && item.itemType
? item.itemType
: item.type;

View File

@@ -159,7 +159,7 @@ export class NNMigrationProvider implements MigrationProvider {
.addColumn("salt", "text")
.addColumn("size", "integer")
.addColumn("alg", "text")
.addColumn("encryptionKey", "text")
.addColumn("key", "text")
.addColumn("chunkSize", "integer")
.addColumn("hash", "text", (c) => c.unique())
.addColumn("hashType", "text")

View File

@@ -19,3 +19,4 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
export * from "./types";
export { VirtualizedGrouping } from "./utils/virtualized-grouping";
export { DefaultColors } from "./collections/colors";

View File

@@ -61,7 +61,7 @@ type Migration = {
item: MigrationItemMap[P],
db: Database,
migrationType: MigrationType
) => boolean | Promise<boolean> | void;
) => "skip" | boolean | Promise<boolean | "skip"> | void;
};
collection?: (collection: IndexedCollection) => Promise<void> | void;
};
@@ -196,12 +196,22 @@ const migrations: Migration[] = [
.items()
.find((t) => item.title === t.title && t.id !== oldTagId))
)
return false;
return "skip";
const colorCode = ColorToHexCode[item.title];
if (colorCode) {
const newColor = await db.colors.all.find((eb) =>
eb.or([eb("title", "in", [alias, item.title])])
);
if (newColor) return "skip";
(item as unknown as Color).type = "color";
(item as unknown as Color).colorCode = colorCode;
} else {
const newTag = await db.tags.all.find((eb) =>
eb.or([eb("title", "in", [alias, item.title])])
);
if (newTag) return "skip";
}
item.title = alias || item.title;
@@ -305,6 +315,7 @@ const migrations: Migration[] = [
await db.relations.add(item, { id: subNotebookId, type: "notebook" });
}
delete item.topics;
delete item.totalNotes;
return true;
},
shortcut: (item) => {
@@ -409,7 +420,9 @@ export async function migrateItem<TItemType extends MigrationItemType>(
const itemMigrator = migration.items[type];
if (!itemMigrator) continue;
if (await itemMigrator(item, database, migrationType)) {
const result = await itemMigrator(item, database, migrationType);
if (result === "skip") return "skip";
if (result) {
if (item.type && item.type !== type) type = item.type as TItemType;
count++;
}

View File

@@ -25,6 +25,7 @@ export type SortOptions = {
| "dateCreated"
| "dateDeleted"
| "dateEdited"
| "dateModified"
| "title"
| "filename"
| "size"
@@ -197,6 +198,10 @@ export interface Notebook extends BaseItem<"notebook"> {
* @deprecated only kept here for migration purposes.
*/
topics?: Topic[];
/**
* @deprecated only kept here for migration purposes.
*/
totalNotes?: number;
}
/**