diff --git a/apps/web/src/common/dialog-controller.tsx b/apps/web/src/common/dialog-controller.tsx index 5d171ced9..a9e003068 100644 --- a/apps/web/src/common/dialog-controller.tsx +++ b/apps/web/src/common/dialog-controller.tsx @@ -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) => ( + 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 23e42b493..724a56ae0 100644 --- a/apps/web/src/components/editor/index.tsx +++ b/apps/web/src/components/editor/index.tsx @@ -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) { diff --git a/apps/web/src/components/editor/tiptap.tsx b/apps/web/src/components/editor/tiptap.tsx index c9880f7e3..2c4f9de43 100644 --- a/apps/web/src/components/editor/tiptap.tsx +++ b/apps/web/src/components/editor/tiptap.tsx @@ -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; 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 diff --git a/apps/web/src/components/icons/index.tsx b/apps/web/src/components/icons/index.tsx index 646332899..16d7d97fc 100644 --- a/apps/web/src/components/icons/index.tsx +++ b/apps/web/src/components/icons/index.tsx @@ -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); diff --git a/apps/web/src/components/note/index.tsx b/apps/web/src/components/note/index.tsx index 64b3c249c..d23213a2c 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, @@ -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": `${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 83e22590a..5102707c4 100644 --- a/apps/web/src/dialogs/index.ts +++ b/apps/web/src/dialogs/index.ts @@ -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 }; 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 2cc9f6f1d..70be83692 100644 --- a/apps/web/src/stores/editor-store.ts +++ b/apps/web/src/stores/editor-store.ts @@ -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 { 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 { }); }; - 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 { 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 { 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 { 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, "download"); @@ -322,7 +331,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 @@ -343,7 +353,7 @@ class EditorStore extends BaseStore { 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 { 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 { 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 { 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 { 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 }); +}