From d75ab14d65e6bb9b222a4e4fff3949a7650f9afb Mon Sep 17 00:00:00 2001 From: Komediruzecki Date: Mon, 16 Nov 2020 22:07:13 +0100 Subject: [PATCH] Add initial open note-link functionality (#313) Add copy note link menu option (NoteItem.tsx) Update db API for creating a note link (createstore, FSNoteDb, PouchNoteDb) Add codemirror hyperlink addon Add codemirror hyperlink addon css style (CodeEditor.tsx) Initialize hyperlink addon (CodeMirror.ts) Add event handling of editor ctrl+click link with ipc (App.tsx) Add regexes for note link shortId (MarkdownPreviewer.tsx) Add handling of rendering and clicking of note link in md preview --- src/components/App.tsx | 16 ++ src/components/atoms/CodeEditor.tsx | 10 ++ src/components/atoms/MarkdownPreviewer.tsx | 39 ++++- src/components/molecules/NoteItem.tsx | 26 +++ src/lib/CodeMirror.ts | 5 + src/lib/addons/hyperlink.js | 183 +++++++++++++++++++++ src/lib/db/FSNoteDb.ts | 6 + src/lib/db/PouchNoteDb.ts | 5 + src/lib/db/createStore.ts | 22 +++ src/lib/platform.ts | 2 +- tsconfig.json | 2 +- 11 files changed, 312 insertions(+), 4 deletions(-) create mode 100644 src/lib/addons/hyperlink.js diff --git a/src/components/App.tsx b/src/components/App.tsx index d93c3f4dd3..8a577ba710 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -20,6 +20,7 @@ import styled from '../lib/styled' import { useEffectOnce } from 'react-use' import AppNavigator from './organisms/AppNavigator' import { useRouter } from '../lib/router' +import { usePathnameWithoutNoteId } from '../lib/routeParams' import { values } from '../lib/db/utils' import { localLiteStorage } from 'ltstrg' import { defaultStorageCreatedKey } from '../lib/localStorageKeys' @@ -57,6 +58,7 @@ import { featureBoostHubIntro, } from '../lib/checkedFeatures' import BoostHubIntroModal from '../components/organisms/BoostHubIntroModal' +import { IpcRendererEvent } from 'electron/renderer' const LoadingText = styled.div` margin: 30px; @@ -97,6 +99,7 @@ const App = () => { const { replace, push } = useRouter() const [initialized, setInitialized] = useState(false) const { addSideNavOpenedItem, setGeneralStatus } = useGeneralStatus() + const currentPathnameWithoutNoteId = usePathnameWithoutNoteId() const { togglePreferencesModal, preferences, @@ -212,6 +215,19 @@ const App = () => { run() }) + useEffect(() => { + const noteLinkNavigateEventHandler = ( + _: IpcRendererEvent, + noteId: string + ) => { + replace(currentPathnameWithoutNoteId + `/note:${noteId}`) + } + addIpcListener('note:navigate', noteLinkNavigateEventHandler) + return () => { + removeIpcListener('note:navigate', noteLinkNavigateEventHandler) + } + }, []) + useEffect(() => { addIpcListener('preferences', togglePreferencesModal) return () => { diff --git a/src/components/atoms/CodeEditor.tsx b/src/components/atoms/CodeEditor.tsx index b7fef292aa..2b3b3d43c4 100644 --- a/src/components/atoms/CodeEditor.tsx +++ b/src/components/atoms/CodeEditor.tsx @@ -11,6 +11,16 @@ const StyledContainer = styled.div` .CodeMirror { font-family: inherit; } + + .CodeMirror-hyperlink { + cursor: pointer; + } + + .CodeMirror-hover { + padding: 2px 4px 0 4px; + position: absolute; + z-index: 99; + } ` const defaultCodeMirrorOptions: CodeMirror.EditorConfiguration = { diff --git a/src/components/atoms/MarkdownPreviewer.tsx b/src/components/atoms/MarkdownPreviewer.tsx index ccdbdd6d35..418aaa3b82 100644 --- a/src/components/atoms/MarkdownPreviewer.tsx +++ b/src/components/atoms/MarkdownPreviewer.tsx @@ -23,6 +23,8 @@ import 'katex/dist/katex.min.css' import MarkdownCheckbox from './markdown/MarkdownCheckbox' import AttachmentImage from './markdown/AttachmentImage' import CodeFence from './markdown/CodeFence' +import { usePathnameWithoutNoteId } from '../../lib/routeParams' +import { useRouter } from '../../lib/router' const schema = mergeDeepRight(gh, { attributes: { @@ -171,12 +173,28 @@ const MarkdownPreviewer = ({ attachmentMap = {}, updateContent, }: MarkdownPreviewerProps) => { + const { replace } = useRouter() const [rendering, setRendering] = useState(false) const previousContentRef = useRef('') const previousThemeRef = useRef('') const [renderedContent, setRenderedContent] = useState([]) const checkboxIndexRef = useRef(0) + const regexIsNoteShortIdLinkWithPrefix = /\((:note:)([a-zA-Z0-9_\-]{7,14})\)/g + const regexIsNoteShortIdLink = /[a-zA-Z0-9_\-]{7,14}/ + const isNoteLink = useCallback( + (href) => { + return href.match(regexIsNoteShortIdLink) !== null + }, + [regexIsNoteShortIdLink] + ) + const currentPathnameWithoutNoteId = usePathnameWithoutNoteId() + const navigate = useCallback( + (noteId) => { + replace(currentPathnameWithoutNoteId + `/note:${noteId}`) + }, + [replace, currentPathnameWithoutNoteId] + ) const markdownProcessor = useMemo(() => { return unified() @@ -211,7 +229,14 @@ const MarkdownPreviewer = ({ href={href} onClick={(event) => { event.preventDefault() - openNew(href) + if (href) { + // See if link is to note + if (isNoteLink(href)) { + navigate(href) + } else { + openNew(href) + } + } }} > {children} @@ -244,7 +269,17 @@ const MarkdownPreviewer = ({ console.time('render') checkboxIndexRef.current = 0 - const result = await markdownProcessor.process(content) + + // Remove note link prefixes when rendering links + let noteLinkContent = content + if (regexIsNoteShortIdLinkWithPrefix.test(content)) { + noteLinkContent = content.replace( + regexIsNoteShortIdLinkWithPrefix, + '($2)' + ) + } + + const result = await markdownProcessor.process(noteLinkContent) console.timeEnd('render') setRendering(false) diff --git a/src/components/molecules/NoteItem.tsx b/src/components/molecules/NoteItem.tsx index 7350516e13..c59676c76c 100644 --- a/src/components/molecules/NoteItem.tsx +++ b/src/components/molecules/NoteItem.tsx @@ -19,6 +19,8 @@ import { GeneralNoteListViewOptions } from '../../lib/preferences' import { useGeneralStatus } from '../../lib/generalStatus' import { bookmarkItemId } from '../../lib/nav' import { openContextMenu } from '../../lib/electronOnly' +import copy from 'copy-to-clipboard' +import { useToast } from '../../lib/toast' const Container = styled.button` margin: 0; @@ -119,6 +121,7 @@ const NoteItem = ({ const href = `${basePathname}/${note._id}` const { createNote, + copyNoteLink, trashNote, purgeNote, untrashNote, @@ -129,6 +132,7 @@ const NoteItem = ({ const { addSideNavOpenedItem } = useGeneralStatus() const { messageBox } = useDialog() + const { pushMessage } = useToast() const { t } = useTranslation() const openUntrashedNoteContextMenu = useCallback( @@ -151,6 +155,28 @@ const NoteItem = ({ }) }, }, + { + type: 'normal', + label: 'Copy note link', + click: async () => { + const noteLink = await copyNoteLink(storageId, note._id, { + title: note.title, + content: note.content, + folderPathname: note.folderPathname, + tags: note.tags, + data: note.data, + }) + if (noteLink) { + copy(noteLink) + } else { + pushMessage({ + title: 'Note Link Error', + description: + 'An error occurred while attempting to create a note link', + }) + } + }, + }, { type: 'separator' }, { type: 'normal', diff --git a/src/lib/CodeMirror.ts b/src/lib/CodeMirror.ts index 5259c67129..2c7606f9c4 100644 --- a/src/lib/CodeMirror.ts +++ b/src/lib/CodeMirror.ts @@ -14,6 +14,9 @@ import 'codemirror/keymap/emacs' import 'codemirror/keymap/vim' import 'codemirror-abap' +// Custom addons +import {initHyperlink} from './addons/hyperlink' + const dispatchModeLoad = debounce(() => { window.dispatchEvent(new CustomEvent('codemirror-mode-load')) }, 300) @@ -53,6 +56,8 @@ function loadMode(_CodeMirror: any) { } } +// Initialize custom addons +initHyperlink(CodeMirror) loadMode(CodeMirror) export default CodeMirror diff --git a/src/lib/addons/hyperlink.js b/src/lib/addons/hyperlink.js new file mode 100644 index 0000000000..ed5afd98c9 --- /dev/null +++ b/src/lib/addons/hyperlink.js @@ -0,0 +1,183 @@ +export function initHyperlink(CodeMirror) { + 'use strict' + const shell = window.require('electron').shell + const remote = window.require('electron').remote + const eventEmitter = { + emit: function (eventName, ...args) { + remote.getCurrentWindow().webContents.send(eventName, args) + }, + } + const yOffset = 2 + + const macOS = global.process.platform === 'darwin' + const modifier = macOS ? 'metaKey' : 'ctrlKey' + + class HyperLink { + constructor(cm) { + this.cm = cm + this.lineDiv = cm.display.lineDiv + + this.onMouseDown = this.onMouseDown.bind(this) + this.onMouseEnter = this.onMouseEnter.bind(this) + this.onMouseLeave = this.onMouseLeave.bind(this) + this.onMouseMove = this.onMouseMove.bind(this) + + this.tooltip = document.createElement('div') + this.tooltipContent = document.createElement('div') + this.tooltipIndicator = document.createElement('div') + this.tooltip.setAttribute( + 'class', + 'CodeMirror-hover CodeMirror-matchingbracket CodeMirror-selected' + ) + this.tooltip.setAttribute('cm-ignore-events', 'true') + this.tooltip.appendChild(this.tooltipContent) + this.tooltip.appendChild(this.tooltipIndicator) + this.tooltipContent.textContent = `${ + macOS ? 'Cmd(⌘)' : 'Ctrl(^)' + } + click to follow link` + + this.lineDiv.addEventListener('mousedown', this.onMouseDown) + this.lineDiv.addEventListener('mouseenter', this.onMouseEnter, { + capture: true, + passive: true, + }) + this.lineDiv.addEventListener('mouseleave', this.onMouseLeave, { + capture: true, + passive: true, + }) + this.lineDiv.addEventListener('mousemove', this.onMouseMove, { + passive: true, + }) + } + + getUrl(el) { + const className = el.className.split(' ') + if (className.indexOf('cm-url') !== -1) { + // multiple cm-url because of search term + const cmUrlSpans = Array.from( + el.parentNode.getElementsByClassName('cm-url') + ) + const textContent = + cmUrlSpans.length > 1 + ? cmUrlSpans.map((span) => span.textContent).join('') + : el.textContent + + const match = /^\((.*)\)|\[(.*)\]|(.*)$/.exec(textContent) + const url = match[1] || match[2] || match[3] + + // `:storage` is the value of the variable `STORAGE_FOLDER_PLACEHOLDER` defined in `browser/main/lib/dataApi/attachmentManagement` + return /^:storage(?:\/|%5C)/.test(url) ? null : url + } + + return null + } + + specialLinkHandler(e, rawHref, linkHash) { + // This wil match shortId note id + // :note:cs23_d12 up to :note:cs23_d122bgCol + const regexIsNoteShortIdLink = /^:note:([a-zA-Z0-9_\-]{7,14})$/ + if (regexIsNoteShortIdLink.test(linkHash)) { + eventEmitter.emit('note:navigate', linkHash.replace(':note:', '')) + return true + } + + return false + } + + onMouseDown(e) { + const { target } = e + if (!e[modifier]) { + return + } + + // Create URL spans array used for special case "search term is hitting a link". + const cmUrlSpans = Array.from( + e.target.parentNode.getElementsByClassName('cm-url') + ) + + const innerText = + cmUrlSpans.length > 1 + ? cmUrlSpans.map((span) => span.textContent).join('') + : e.target.innerText + const rawHref = innerText.trim().slice(1, -1) // get link text from markdown text + + if (!rawHref) return // not checked href because parser will create file://... string for [empty link]() + + const parser = document.createElement('a') + parser.href = rawHref + const { href, hash } = parser + // needed because we're having special link formats that are removed by parser e.g. :line:10 + const linkHash = hash === '' ? rawHref : hash + const foundUrl = this.specialLinkHandler(target, rawHref, linkHash) + + if (!foundUrl) { + const url = this.getUrl(target) + // all special cases handled --> other case + if (url) { + e.preventDefault() + shell.openExternal(url) + } + } + } + + onMouseEnter(e) { + const { target } = e + + const url = this.getUrl(target) + if (url) { + if (e[modifier]) { + target.classList.add( + 'CodeMirror-activeline-background', + 'CodeMirror-hyperlink' + ) + } else { + target.classList.add('CodeMirror-activeline-background') + } + + this.showInfo(target) + } + } + + onMouseLeave(e) { + if (this.tooltip.parentElement === this.lineDiv) { + e.target.classList.remove( + 'CodeMirror-activeline-background', + 'CodeMirror-hyperlink' + ) + + this.lineDiv.removeChild(this.tooltip) + } + } + + onMouseMove(e) { + if (this.tooltip.parentElement === this.lineDiv) { + if (e[modifier]) { + e.target.classList.add('CodeMirror-hyperlink') + } else { + e.target.classList.remove('CodeMirror-hyperlink') + } + } + } + + showInfo(relatedTo) { + const b1 = relatedTo.getBoundingClientRect() + const b2 = this.lineDiv.getBoundingClientRect() + const tdiv = this.tooltip + + tdiv.style.left = b1.left - b2.left + 'px' + this.lineDiv.appendChild(tdiv) + + const b3 = tdiv.getBoundingClientRect() + const top = b1.top - b2.top - b3.height - yOffset + if (top < 0) { + tdiv.style.top = b1.top - b2.top + b1.height + yOffset + 'px' + } else { + tdiv.style.top = top + 'px' + } + } + } + + CodeMirror.defineOption('hyperlink', true, (cm) => { + const addon = new HyperLink(cm) + }) +} diff --git a/src/lib/db/FSNoteDb.ts b/src/lib/db/FSNoteDb.ts index 7e9f9c402d..096df2e654 100644 --- a/src/lib/db/FSNoteDb.ts +++ b/src/lib/db/FSNoteDb.ts @@ -257,6 +257,12 @@ class FSNoteDb implements NoteDb { await unlinkFile(attachmentPathname) } + + async copyNoteLink(noteId: string, noteProps: Partial) { + const noteLink = `[${noteProps.title}](:${noteId})` + return noteLink + } + async createNote(noteProps: Partial) { const now = getNow() const noteDoc: NoteDoc = { diff --git a/src/lib/db/PouchNoteDb.ts b/src/lib/db/PouchNoteDb.ts index f7a5ea68ca..74f316c6d7 100644 --- a/src/lib/db/PouchNoteDb.ts +++ b/src/lib/db/PouchNoteDb.ts @@ -405,6 +405,11 @@ export default class PouchNoteDb implements NoteDb { }) } + async copyNoteLink(noteId: string, noteProps: Partial) { + const noteLink = `[${noteProps.title}](:${noteId})` + return noteLink + } + async trashNote(noteId: string): Promise { const note = await this.getNote(noteId) if (note == null) diff --git a/src/lib/db/createStore.ts b/src/lib/db/createStore.ts index c90bbeaef2..4164aa29d8 100644 --- a/src/lib/db/createStore.ts +++ b/src/lib/db/createStore.ts @@ -62,6 +62,11 @@ export interface DbStore { newName: string ) => Promise removeFolder: (storageName: string, pathname: string) => Promise + copyNoteLink( + storageId: string, + noteId: string, + noteProps: Partial + ): Promise createNote( storageId: string, noteProps: Partial @@ -967,6 +972,22 @@ export function createDbStoreCreator( [queueSyncingStorage, setStorageMap, storageMap] ) + const copyNoteLink = useCallback( + async (storageId: string, noteId: string, noteProps: Partial) => { + const storage = storageMap[storageId] + if (storage == null) { + return + } + const noteLink = await storage.db.copyNoteLink(noteId, noteProps) + if (noteLink == null) { + return + } else { + return noteLink + } + }, + [storageMap] + ) + const trashNote = useCallback( async (storageId: string, noteId: string) => { const storage = storageMap[storageId] @@ -1331,6 +1352,7 @@ export function createDbStoreCreator( removeFolder, createNote, updateNote, + copyNoteLink, trashNote, untrashNote, purgeNote, diff --git a/src/lib/platform.ts b/src/lib/platform.ts index 3a5beee95c..c1a3d2fd60 100644 --- a/src/lib/platform.ts +++ b/src/lib/platform.ts @@ -15,7 +15,7 @@ const openInternalLink = (link: string) => { } export const openNew = (url: string) => { - if (url.length === 0) { + if (!url || url.length === 0) { return } diff --git a/tsconfig.json b/tsconfig.json index 2ae9419484..df2434bdd1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,5 +1,5 @@ { - "include": ["src/**/*.ts", "src/**/*.tsx", "typings/**/*.d.ts"], + "include": ["src/**/*.ts", "src/**/*.tsx", "typings/**/*.d.ts", "src/lib/addons/*.js"], "exclude": ["dist", "node_modules"], "compilerOptions": { "allowSyntheticDefaultImports": true,