web: add full support for adding internal links

This commit is contained in:
Abdullah Atta
2024-01-22 17:27:44 +05:00
parent 2696ef7ca4
commit 888160fa76
9 changed files with 355 additions and 52 deletions

View File

@@ -37,6 +37,7 @@ import { ThemeMetadata } from "@notesnook/themes-server";
import { Color, Reminder, Tag } from "@notesnook/core";
import { AuthenticatorType } from "@notesnook/core/dist/api/user-manager";
import { createRoot } from "react-dom/client";
import { LinkAttributes } from "@notesnook/editor/dist/extensions/link";
type DialogTypes = typeof Dialogs;
type DialogIds = keyof DialogTypes;
@@ -99,6 +100,19 @@ export function showAddNotebookDialog(parentId?: string) {
));
}
export function showNoteLinkingDialog(attr?: LinkAttributes) {
return showDialog<"NoteLinkingDialog", LinkAttributes | undefined>(
"NoteLinkingDialog",
(Dialog, perform) => (
<Dialog
attributes={attr}
onDone={(link) => perform(link)}
onClose={() => perform(undefined)}
/>
)
);
}
export async function showEditNotebookDialog(notebookId: string) {
const notebook = await db.notebooks.notebook(notebookId);
if (!notebook) return;

View File

@@ -59,6 +59,8 @@ import { EditorActionBar } from "./action-bar";
import { UnlockView } from "../unlock";
import DiffViewer from "../diff-viewer";
import TableOfContents from "./table-of-contents";
import { showNoteLinkingDialog } from "../../common/dialog-controller";
import { scrollIntoViewById } from "@notesnook/editor";
const PDFPreview = React.lazy(() => import("../pdf-preview"));
@@ -428,6 +430,7 @@ export function Editor(props: EditorProps) {
isMobile: false
};
const [isLoading, setIsLoading] = useState(true);
useScrollToBlock(id);
useEffect(() => {
const event = AppEventManager.subscribe(
@@ -528,13 +531,18 @@ export function Editor(props: EditorProps) {
const mime = type === "file" ? "*/*" : "image/*";
insertAttachment(mime).then((file) => {
if (!file) return;
// editor.current?.attachFile(file);
// editor.attachFile(file);
});
}}
onAttachFile={async (file) => {
const result = await attachFile(file);
if (!result) return;
// editor.current?.attachFile(result);
// editor.attachFile(result);
}}
onInsertInternalLink={async (attributes) => {
const link = await showNoteLinkingDialog(attributes);
console.log(link);
return link;
}}
>
{headless ? null : (
@@ -806,6 +814,16 @@ function useDragOverlay() {
return [dropElementRef, overlayRef] as const;
}
function useScrollToBlock(id: string) {
const blockId = useEditorStore(
(store) => store.getSession(id)?.activeBlockId
);
useEffect(() => {
if (!blockId) return;
scrollIntoViewById(blockId);
}, [blockId]);
}
function isFile(e: DragEvent) {
return (
e.dataTransfer &&
@@ -815,6 +833,9 @@ function isFile(e: DragEvent) {
}
function restoreScrollPosition(id: string) {
const session = useEditorStore.getState().getActiveSession();
if (session?.activeBlockId) return scrollIntoViewById(session.activeBlockId);
const scrollContainer = document.getElementById(`${id}_editorScroll`);
const scrollPosition = Config.get(`${id}:scroll-position`, 0);
if (scrollContainer) {

View File

@@ -58,10 +58,13 @@ import { showBuyDialog } from "../../common/dialog-controller";
import { useStore as useSettingsStore } from "../../stores/setting-store";
import { debounce } from "@notesnook/common";
import { ScopedThemeProvider } from "../theme-provider";
import { writeText } from "clipboard-polyfill";
import { useStore as useThemeStore } from "../../stores/theme-store";
import { toBlobURL } from "@notesnook/editor/dist/utils/downloader";
import { getChangedNodes } from "@notesnook/editor/dist/utils/prosemirror";
import { LinkAttributes } from "@notesnook/editor/dist/extensions/link";
import { writeToClipboard } from "../../utils/clipboard";
import { useEditorStore } from "../../stores/editor-store";
import { parseInternalLink } from "@notesnook/core";
export type OnChangeHandler = (content: () => string) => void;
type TipTapProps = {
@@ -74,6 +77,9 @@ type TipTapProps = {
onInsertAttachment?: (type: AttachmentType) => void;
onDownloadAttachment?: (attachment: Attachment) => void;
onPreviewAttachment?: (attachment: Attachment) => void;
onInsertInternalLink?: (
attributes?: LinkAttributes
) => Promise<LinkAttributes | undefined>;
onAttachFile?: (file: File) => void;
onFocus?: () => void;
content?: () => string | undefined;
@@ -108,6 +114,7 @@ function TipTap(props: TipTapProps) {
onInsertAttachment,
onDownloadAttachment,
onPreviewAttachment,
onInsertInternalLink,
onAttachFile,
onContentChange,
onFocus = () => {},
@@ -217,9 +224,14 @@ function TipTap(props: TipTapProps) {
const preventSave = transaction?.getMeta("preventSave") as boolean;
if (preventSave || !editor.isEditable || !onChange) return;
onChange(() =>
getHTMLFromFragment(editor.state.doc.content, editor.schema)
);
onChange(() => {
const html = getHTMLFromFragment(
editor.state.doc.content,
editor.schema
);
console.log(html);
return html;
});
},
onDestroy: () => {
useEditorManager.getState().setEditor(id);
@@ -230,8 +242,8 @@ function TipTap(props: TipTapProps) {
canUndo: editor.can().undo()
});
},
copyToClipboard(text) {
writeText(text);
copyToClipboard(text, html) {
writeToClipboard({ "text/plain": text, "text/html": html });
},
onSelectionUpdate: debounce(({ editor, transaction }) => {
const isEmptySelection = transaction.selection.empty;
@@ -260,21 +272,17 @@ function TipTap(props: TipTapProps) {
};
});
}, 500),
onOpenAttachmentPicker: (_editor, type) => {
onInsertAttachment?.(type);
return true;
},
onDownloadAttachment: (_editor, attachment) => {
onDownloadAttachment?.(attachment);
return true;
},
onPreviewAttachment(_editor, attachment) {
onPreviewAttachment?.(attachment);
return true;
},
onOpenLink: (url) => {
window.open(url, "_blank");
return true;
openAttachmentPicker: onInsertAttachment,
downloadAttachment: onDownloadAttachment,
previewAttachment: onPreviewAttachment,
createInternalLink: onInsertInternalLink,
openLink: (url) => {
const link = parseInternalLink(url);
if (link && link.type === "note") {
useEditorStore.getState().openSession(link.id, {
activeBlockId: link.params?.blockId || undefined
});
} else window.open(url, "_blank");
}
};
}, [readonly, nonce, doubleSpacedLines, dateFormat, timeFormat]);
@@ -412,25 +420,25 @@ function toIEditor(editor: Editor): IEditor {
return {
focus: ({ position, scrollIntoView } = {}) => {
if (typeof position === "object")
editor.current?.chain().focus().setTextSelection(position).run();
editor.chain().focus().setTextSelection(position).run();
else
editor.current?.commands.focus(position, {
editor.commands.focus(position, {
scrollIntoView
});
},
undo: () => editor.current?.commands.undo(),
redo: () => editor.current?.commands.redo(),
undo: () => editor.commands.undo(),
redo: () => editor.commands.redo(),
getMediaHashes: () => {
if (!editor.current) return [];
if (!editor) return [];
const hashes: string[] = [];
editor.current.state.doc.descendants((n) => {
editor.state.doc.descendants((n) => {
if (typeof n.attrs.hash === "string") hashes.push(n.attrs.hash);
});
return hashes;
},
updateContent: (content) => {
const { from, to } = editor.state.selection;
editor.current
editor
?.chain()
.command(({ tr }) => {
tr.setMeta("preventSave", true);
@@ -445,21 +453,21 @@ function toIEditor(editor: Editor): IEditor {
},
attachFile: (file: Attachment) => {
if (file.dataurl) {
editor.current?.commands.insertImage({
editor.commands.insertImage({
...file,
bloburl: toBlobURL(file.dataurl, file.hash)
});
} else editor.current?.commands.insertAttachment(file);
} else editor.commands.insertAttachment(file);
},
loadWebClip: (hash, src) =>
editor.current?.commands.updateWebClip({ hash }, { src }),
editor.commands.updateWebClip({ hash }, { src }),
loadImage: (hash, dataurl) =>
editor.current?.commands.updateImage(
editor.commands.updateImage(
{ hash },
{ hash, bloburl: toBlobURL(dataurl, hash), preventUpdate: true }
),
sendAttachmentProgress: (hash, type, progress) =>
editor.current?.commands.setAttachmentProgress({
editor.commands.setAttachmentProgress({
hash,
type,
progress

View File

@@ -209,7 +209,8 @@ import {
mdiDesktopClassic,
mdiBellBadgeOutline,
mdiDotsHorizontal,
mdiFormatListBulleted
mdiFormatListBulleted,
mdiLink
} from "@mdi/js";
import { useTheme } from "@emotion/react";
import { Theme } from "@notesnook/theme";
@@ -405,6 +406,7 @@ export const Copy = createIcon(mdiContentCopy);
export const Refresh = createIcon(mdiRefresh);
export const Clock = createIcon(mdiClockTimeFiveOutline);
export const Duplicate = createIcon(mdiContentDuplicate);
export const InternalLink = createIcon(mdiLink);
export const Select = createIcon(mdiCheckboxMultipleMarkedCircleOutline);
export const NotebookEdit = createIcon(mdiBookEditOutline);
export const DeleteForver = createIcon(mdiDeleteForeverOutline);

View File

@@ -42,6 +42,7 @@ import {
Publish,
Export,
Duplicate,
InternalLink,
Sync,
Trash,
Circle,
@@ -59,7 +60,7 @@ import {
showAddTagsDialog,
showMoveNoteDialog
} from "../../common/dialog-controller";
import { store, useStore } from "../../stores/note-store";
import { store } from "../../stores/note-store";
import { store as userstore } from "../../stores/user-store";
import { useEditorStore } from "../../stores/editor-store";
import { store as tagStore } from "../../stores/tag-store";
@@ -83,7 +84,8 @@ import {
Note,
Notebook as NotebookItem,
Tag,
DefaultColors
DefaultColors,
createInternalLink
} from "@notesnook/core";
import { MenuItem } from "@notesnook/ui";
import {
@@ -93,6 +95,7 @@ import {
} from "../list-container/types";
import { SchemeColors } from "@notesnook/theme";
import Vault from "../../common/vault";
import { writeToClipboard } from "../../utils/clipboard";
type NoteProps = {
tags?: TagsWithDateEdited;
@@ -499,6 +502,20 @@ const menuItems: (
]
}
},
{
type: "button",
key: "copy-link",
title: "Copy internal link",
icon: InternalLink.path,
onClick: () => {
const link = createInternalLink("note", note.id);
writeToClipboard({
"text/plain": link,
"text/html": `<a href="${link}">${note.title}</a>`,
"text/markdown": `[${note.title}](${link})`
});
}
},
{
type: "button",
key: "duplicate",

View File

@@ -52,6 +52,7 @@ const MigrationDialog = React.lazy(() => import("./migration-dialog"));
const EmailChangeDialog = React.lazy(() => import("./email-change-dialog"));
const AddTagsDialog = React.lazy(() => import("./add-tags-dialog"));
const ThemeDetailsDialog = React.lazy(() => import("./theme-details-dialog"));
const NoteLinkingDialog = React.lazy(() => import("./note-linking-dialog"));
export const Dialogs = {
AddNotebookDialog,
@@ -79,5 +80,6 @@ export const Dialogs = {
EmailChangeDialog,
AddTagsDialog,
SettingsDialog,
ThemeDetailsDialog
ThemeDetailsDialog,
NoteLinkingDialog
};

View File

@@ -0,0 +1,181 @@
/*
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 { Perform } from "../common/dialog-controller";
import Field from "../components/field";
import Dialog from "../components/dialog";
import { useState } from "react";
import { db } from "../common/db";
import {
ContentBlock,
Note as NoteType,
VirtualizedGrouping,
createInternalLink
} from "@notesnook/core";
import { VirtualizedList } from "../components/virtualized-list";
import { ResolvedItem } from "../components/list-container/resolved-item";
import { Button, Flex, Text } from "@theme-ui/components";
import { ScrollContainer } from "@notesnook/ui";
import { LinkAttributes } from "@notesnook/editor/dist/extensions/link";
export type NoteLinkingDialogProps = {
attributes?: LinkAttributes;
onClose: Perform;
onDone: Perform<LinkAttributes>;
};
export default function NoteLinkingDialog(props: NoteLinkingDialogProps) {
const { attributes } = props;
const [notes, setNotes] = useState<VirtualizedGrouping<NoteType>>();
const [selectedNote, setSelectedNote] = useState<NoteType>();
const [blocks, setBlocks] = useState<ContentBlock[]>([]);
return (
<Dialog
isOpen={true}
title={attributes ? "Edit internal link" : "Link to note"}
width={500}
onClose={() => props.onClose(false)}
onOpen={async () => {
setNotes(
await db.notes.all.sorted(db.settings.getGroupOptions("home"))
);
}}
positiveButton={{
text: "Save",
disabled: !selectedNote,
onClick: () =>
selectedNote
? props.onDone({
title: selectedNote.title,
href: createInternalLink("note", selectedNote.id)
})
: null
}}
negativeButton={{ text: "Cancel", onClick: () => props.onClose(false) }}
noScroll
>
<Flex variant="columnFill" sx={{ mx: 3, overflow: "hidden" }}>
{selectedNote ? (
<>
<Field
autoFocus
placeholder="Type # to only search headings"
sx={{ mx: 0 }}
onChange={async (e) =>
setNotes(await db.lookup.notes(e.target.value).sorted())
}
/>
<Button variant="accentSecondary" sx={{ mt: 1, textAlign: "left" }}>
Selected note: {selectedNote.title}
</Button>
<ScrollContainer>
<VirtualizedList
items={blocks}
estimatedSize={34}
mode="dynamic"
itemGap={5}
getItemKey={(i) => blocks[i].id}
mt={1}
renderItem={({ item }) => (
<Button
variant="menuitem"
sx={{
p: 1,
width: "100%",
textAlign: "left",
display: "flex",
justifyContent: "space-between",
alignItems: "center"
}}
onClick={() => {
props.onDone({
title: selectedNote.title,
href: createInternalLink("note", selectedNote.id, {
blockId: item.id
})
});
}}
>
<Text
variant="body"
sx={{ fontFamily: "monospace", whiteSpace: "pre-wrap" }}
>
{item.content}
</Text>
<Text
variant="subBody"
sx={{
bg: "background-secondary",
p: "small",
px: 1,
borderRadius: "default",
alignSelf: "flex-start"
}}
>
{item.type.toUpperCase()}
</Text>
</Button>
)}
/>
</ScrollContainer>
</>
) : (
<>
<Field
autoFocus
placeholder="Search for a note to link to..."
sx={{ mx: 0 }}
onChange={async (e) =>
setNotes(await db.lookup.notes(e.target.value).sorted())
}
/>
{notes && (
<ScrollContainer>
<VirtualizedList
items={notes.placeholders}
estimatedSize={28}
itemGap={5}
getItemKey={notes.key}
mt={1}
renderItem={({ index }) => (
<ResolvedItem items={notes} index={index} type="note">
{({ item: note }) => (
<Button
variant="menuitem"
sx={{ p: 1, width: "100%", textAlign: "left" }}
onClick={async () => {
setSelectedNote(note);
setBlocks(await db.notes.getBlocks(note.id));
}}
>
<Text variant="body">{note.title}</Text>
</Button>
)}
</ResolvedItem>
)}
/>
</ScrollContainer>
)}
</>
)}
</Flex>
</Dialog>
);
}

View File

@@ -58,6 +58,11 @@ export type BaseEditorSession = {
pinned?: boolean;
preview?: boolean;
title?: string;
/**
* The id of block to scroll to after opening the session successfully.
*/
activeBlockId?: string;
};
export type LockedEditorSession = BaseEditorSession & {
@@ -196,7 +201,6 @@ class EditorStore extends BaseStore<EditorStore> {
const session = getSession(activeSessionId);
if (!session) return;
console.log("OPENING", session);
if (session.type === "diff") openDiffSession(session.note.id, session.id);
else if (session.type === "new") activateSession(session.id);
else openSession(activeSessionId);
@@ -240,7 +244,7 @@ class EditorStore extends BaseStore<EditorStore> {
});
};
activateSession = (id?: string) => {
activateSession = (id?: string, activeBlockId?: string) => {
const session = this.get().sessions.find((s) => s.id === id);
if (!session) id = undefined;
@@ -262,6 +266,11 @@ class EditorStore extends BaseStore<EditorStore> {
if (history.includes(id)) history.splice(history.indexOf(id), 1);
history.push(id);
}
if (activeBlockId && session)
this.updateSession(session.id, [session.type], {
activeBlockId: activeBlockId
});
};
openDiffSession = async (noteId: string, sessionId: string) => {
@@ -275,7 +284,7 @@ class EditorStore extends BaseStore<EditorStore> {
if (!oldContent || !currentContent) return;
const label = getFormattedHistorySessionDate(session);
useEditorStore.getState().addSession({
this.addSession({
type: "diff",
id: session.id,
note,
@@ -298,14 +307,14 @@ class EditorStore extends BaseStore<EditorStore> {
openSession = async (
noteOrId: string | Note | BaseTrashItem<Note>,
force = false
options: { force?: boolean; activeBlockId?: string } = {}
): Promise<void> => {
const { getSession } = this.get();
const noteId = typeof noteOrId === "string" ? noteOrId : noteOrId.id;
const session = getSession(noteId);
if (session && !force && !session.needsHydration) {
return this.activateSession(noteId);
if (session && !options.force && !session.needsHydration) {
return this.activateSession(noteId, options.activeBlockId);
}
if (session && session.id) await db.fs().cancel(session.id, "download");
@@ -322,7 +331,8 @@ class EditorStore extends BaseStore<EditorStore> {
type: "locked",
id: note.id,
note,
preview: isPreview
preview: isPreview,
activeBlockId: options.activeBlockId
});
} else if (note.conflicted) {
const content = note.contentId
@@ -343,7 +353,7 @@ class EditorStore extends BaseStore<EditorStore> {
dateResolved: Date.now()
});
}
return this.openSession(note, true);
return this.openSession(note, { ...options, force: true });
}
this.addSession({
@@ -351,7 +361,8 @@ class EditorStore extends BaseStore<EditorStore> {
content: content,
id: note.id,
note,
preview: isPreview
preview: isPreview,
activeBlockId: options.activeBlockId
});
} else {
const content = note.contentId
@@ -361,7 +372,7 @@ class EditorStore extends BaseStore<EditorStore> {
if (content?.locked) {
note.locked = true;
await db.notes.add({ id: note.id, locked: true });
return this.openSession(note, true);
return this.openSession(note, { ...options, force: true });
}
if (note.type === "trash") {
@@ -369,14 +380,16 @@ class EditorStore extends BaseStore<EditorStore> {
type: "deleted",
note,
id: note.id,
content
content,
activeBlockId: options.activeBlockId
});
} else if (note.readonly) {
this.addSession({
type: "readonly",
note,
id: note.id,
content
content,
activeBlockId: options.activeBlockId
});
} else {
const attachmentsLength = await db.attachments
@@ -391,7 +404,8 @@ class EditorStore extends BaseStore<EditorStore> {
sessionId: `${Date.now()}`,
attachmentsLength,
content,
preview: isPreview
preview: isPreview,
activeBlockId: options.activeBlockId
});
}
}

View File

@@ -0,0 +1,44 @@
/*
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/>.
*/
type Formats = {
"text/html"?: string;
"text/markdown"?: string;
"text/plain": string;
};
const COPYABLE_FORMATS = ["text/html", "text/plain"] as const;
export async function writeToClipboard(formats: Formats) {
if ("ClipboardItem" in window) {
const items: Record<string, Blob> = Object.fromEntries(
COPYABLE_FORMATS.map((f) => {
const content = formats[f];
if (!content) return [];
return [f as string, textToBlob(content, f)] as const;
})
);
return navigator.clipboard.write([new ClipboardItem(items)]);
} else
return navigator.clipboard.writeText(
formats["text/markdown"] || formats["text/plain"]
);
}
function textToBlob(text: string, type: string) {
return new Blob([new TextEncoder().encode(text)], { type });
}