diff --git a/apps/web/src/common/dialog-controller.tsx b/apps/web/src/common/dialog-controller.tsx index 81d88d450..afc3bf270 100644 --- a/apps/web/src/common/dialog-controller.tsx +++ b/apps/web/src/common/dialog-controller.tsx @@ -42,6 +42,7 @@ import { } from "@notesnook/core"; import { createRoot } from "react-dom/client"; import { PasswordDialogProps } from "../dialogs/password-dialog"; +import { LinkAttributes } from "@notesnook/editor/dist/extensions/link"; type DialogTypes = typeof Dialogs; type DialogIds = keyof DialogTypes; @@ -113,6 +114,19 @@ export function showAddNotebookDialog(parentId?: string) { )); } +export function showNoteLinkingDialog(attr?: LinkAttributes) { + return showDialog<"NoteLinkingDialog", LinkAttributes | undefined>( + "NoteLinkingDialog", + (Dialog, perform) => ( + perform(link)} + onClose={() => perform(undefined)} + /> + ) + ); +} + export async function showEditNotebookDialog(notebookId: string) { const notebook = await db.notebooks.notebook(notebookId); if (!notebook) return; diff --git a/apps/web/src/components/editor/index.tsx b/apps/web/src/components/editor/index.tsx index 6642802e2..6c8ada7a6 100644 --- a/apps/web/src/components/editor/index.tsx +++ b/apps/web/src/components/editor/index.tsx @@ -63,6 +63,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")); @@ -408,6 +410,7 @@ export function Editor(props: EditorProps) { isMobile: false }; const [isLoading, setIsLoading] = useState(true); + useScrollToBlock(id); useEffect(() => { const event = AppEventManager.subscribe( @@ -503,6 +506,10 @@ export function Editor(props: EditorProps) { if (!result) return; result.forEach((attachment) => editor?.attachFile(attachment)); }} + onInsertInternalLink={async (attributes) => { + const link = await showNoteLinkingDialog(attributes); + return link; + }} > {headless ? null : ( <> @@ -805,6 +812,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 && @@ -814,6 +831,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) { diff --git a/apps/web/src/components/editor/tiptap.tsx b/apps/web/src/components/editor/tiptap.tsx index 8a92d14cb..0bc92302a 100644 --- a/apps/web/src/components/editor/tiptap.tsx +++ b/apps/web/src/components/editor/tiptap.tsx @@ -59,10 +59,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, @@ -80,6 +83,10 @@ type TipTapProps = { onPreviewAttachment?: (attachment: Attachment) => void; onGetAttachmentData?: (attachment: Attachment) => Promise; onAttachFiles?: (files: File[]) => void; + onInsertInternalLink?: ( + attributes?: LinkAttributes + ) => Promise; + onAttachFile?: (file: File) => void; onFocus?: () => void; content?: () => string | undefined; readonly?: boolean; @@ -115,6 +122,7 @@ function TipTap(props: TipTapProps) { onPreviewAttachment, onGetAttachmentData, onAttachFiles, + onInsertInternalLink, onContentChange, onFocus = () => {}, content, @@ -239,8 +247,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; @@ -269,23 +277,19 @@ 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; - }, - getAttachmentData: onGetAttachmentData + openAttachmentPicker: onInsertAttachment, + downloadAttachment: onDownloadAttachment, + previewAttachment: onPreviewAttachment, + createInternalLink: onInsertInternalLink, + getAttachmentData: onGetAttachmentData, + 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, @@ -429,17 +433,17 @@ 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(), updateContent: (content) => { const { from, to } = editor.state.selection; - editor.current + editor ?.chain() .command(({ tr }) => { tr.setMeta("preventSave", true); @@ -454,10 +458,10 @@ function toIEditor(editor: Editor): IEditor { }, attachFile: (file: Attachment) => file.type === "image" - ? editor.current?.commands.insertImage(file) - : editor.current?.commands.insertAttachment(file), + ? editor.commands.insertImage(file) + : editor.commands.insertAttachment(file), sendAttachmentProgress: (hash, progress) => - editor.current?.commands.updateAttachment( + editor.commands.updateAttachment( { progress }, diff --git a/apps/web/src/components/icons/index.tsx b/apps/web/src/components/icons/index.tsx index 5e437e3b4..2b997eeaf 100644 --- a/apps/web/src/components/icons/index.tsx +++ b/apps/web/src/components/icons/index.tsx @@ -210,7 +210,8 @@ import { mdiBellBadgeOutline, mdiDotsHorizontal, mdiCalendarBlank, - 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); diff --git a/apps/web/src/components/note/index.tsx b/apps/web/src/components/note/index.tsx index b4f8a36e3..8d11296f8 100644 --- a/apps/web/src/components/note/index.tsx +++ b/apps/web/src/components/note/index.tsx @@ -42,6 +42,7 @@ import { Publish, Export, Duplicate, + InternalLink, Sync, Trash, Circle, @@ -60,7 +61,7 @@ import { showCreateColorDialog, 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"; @@ -82,11 +83,19 @@ import { getFormattedReminderTime, pluralize } from "@notesnook/common"; -import { Color, Note, Notebook as NotebookItem, Tag } from "@notesnook/core"; +import { + Color, + Note, + Notebook as NotebookItem, + Tag, + createInternalLink +} from "@notesnook/core"; import { MenuItem } from "@notesnook/ui"; import { Context } from "../list-container/types"; import { SchemeColors } from "@notesnook/theme"; import FileSaver from "file-saver"; +import Vault from "../../common/vault"; +import { writeToClipboard } from "../../utils/clipboard"; type NoteProps = NoteResolvedData & { item: Note; @@ -497,6 +506,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": `${note.title}`, + "text/markdown": `[${note.title}](${link})` + }); + } + }, { type: "button", key: "duplicate", diff --git a/apps/web/src/dialogs/index.ts b/apps/web/src/dialogs/index.ts index ebe7ae70a..1a0472f3e 100644 --- a/apps/web/src/dialogs/index.ts +++ b/apps/web/src/dialogs/index.ts @@ -60,6 +60,7 @@ const EditProfilePictureDialog = React.lazy( () => import("./edit-profile-picture-dialog") ); const ImagePickerDialog = React.lazy(() => import("./image-picker-dialog")); +const NoteLinkingDialog = React.lazy(() => import("./note-linking-dialog")); export const Dialogs = { AddNotebookDialog, @@ -91,5 +92,6 @@ export const Dialogs = { BackupPasswordDialog, CreateColorDialog, EditProfilePictureDialog, - ImagePickerDialog + ImagePickerDialog, + NoteLinkingDialog }; diff --git a/apps/web/src/dialogs/note-linking-dialog.tsx b/apps/web/src/dialogs/note-linking-dialog.tsx new file mode 100644 index 000000000..bae4c69bd --- /dev/null +++ b/apps/web/src/dialogs/note-linking-dialog.tsx @@ -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 . +*/ + +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; +}; + +export default function NoteLinkingDialog(props: NoteLinkingDialogProps) { + const { attributes } = props; + const [notes, setNotes] = useState>(); + const [selectedNote, setSelectedNote] = useState(); + const [blocks, setBlocks] = useState([]); + + return ( + 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 + > + + {selectedNote ? ( + <> + + setNotes(await db.lookup.notes(e.target.value).sorted()) + } + /> + + + blocks[i].id} + mt={1} + renderItem={({ item }) => ( + + )} + /> + + + ) : ( + <> + + setNotes(await db.lookup.notes(e.target.value).sorted()) + } + /> + {notes && ( + + ( + + {({ item: note }) => ( + + )} + + )} + /> + + )} + + )} + + + ); +} diff --git a/apps/web/src/stores/editor-store.ts b/apps/web/src/stores/editor-store.ts index a25b7c97b..4d7e66294 100644 --- a/apps/web/src/stores/editor-store.ts +++ b/apps/web/src/stores/editor-store.ts @@ -59,6 +59,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 & { @@ -199,7 +204,6 @@ class EditorStore extends BaseStore { 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); @@ -243,7 +247,7 @@ class EditorStore extends BaseStore { }); }; - activateSession = (id?: string) => { + activateSession = (id?: string, activeBlockId?: string) => { const session = this.get().sessions.find((s) => s.id === id); if (!session) id = undefined; @@ -265,6 +269,11 @@ class EditorStore extends BaseStore { 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) => { @@ -301,14 +310,14 @@ class EditorStore extends BaseStore { openSession = async ( noteOrId: string | Note | BaseTrashItem, - force = false + options: { force?: boolean; activeBlockId?: string } = {} ): Promise => { 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); @@ -325,7 +334,8 @@ class EditorStore extends BaseStore { type: "locked", id: note.id, note, - preview: isPreview + preview: isPreview, + activeBlockId: options.activeBlockId }); } else if (note.conflicted) { const content = note.contentId @@ -346,7 +356,7 @@ class EditorStore extends BaseStore { dateResolved: Date.now() }); } - return this.openSession(note, true); + return this.openSession(note, { ...options, force: true }); } this.addSession({ @@ -354,7 +364,8 @@ class EditorStore extends BaseStore { content: content, id: note.id, note, - preview: isPreview + preview: isPreview, + activeBlockId: options.activeBlockId }); } else { const content = note.contentId @@ -364,7 +375,7 @@ class EditorStore extends BaseStore { 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") { @@ -372,14 +383,16 @@ class EditorStore extends BaseStore { 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 @@ -394,7 +407,8 @@ class EditorStore extends BaseStore { sessionId: `${Date.now()}`, attachmentsLength, content, - preview: isPreview + preview: isPreview, + activeBlockId: options.activeBlockId }); } } diff --git a/apps/web/src/utils/clipboard.ts b/apps/web/src/utils/clipboard.ts new file mode 100644 index 000000000..d369ce11e --- /dev/null +++ b/apps/web/src/utils/clipboard.ts @@ -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 . +*/ + +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 = 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 }); +}