Skip to content

Commit

Permalink
Add initial open note-link functionality (BoostIO#313)
Browse files Browse the repository at this point in the history
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
  • Loading branch information
Komediruzecki committed Nov 18, 2020
1 parent 37d5c7c commit d75ab14
Show file tree
Hide file tree
Showing 11 changed files with 312 additions and 4 deletions.
16 changes: 16 additions & 0 deletions src/components/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 () => {
Expand Down
10 changes: 10 additions & 0 deletions src/components/atoms/CodeEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
39 changes: 37 additions & 2 deletions src/components/atoms/MarkdownPreviewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -171,12 +173,28 @@ const MarkdownPreviewer = ({
attachmentMap = {},
updateContent,
}: MarkdownPreviewerProps) => {
const { replace } = useRouter()
const [rendering, setRendering] = useState(false)
const previousContentRef = useRef('')
const previousThemeRef = useRef<string | undefined>('')
const [renderedContent, setRenderedContent] = useState<React.ReactNode>([])

const checkboxIndexRef = useRef<number>(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()
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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)
Expand Down
26 changes: 26 additions & 0 deletions src/components/molecules/NoteItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -119,6 +121,7 @@ const NoteItem = ({
const href = `${basePathname}/${note._id}`
const {
createNote,
copyNoteLink,
trashNote,
purgeNote,
untrashNote,
Expand All @@ -129,6 +132,7 @@ const NoteItem = ({
const { addSideNavOpenedItem } = useGeneralStatus()

const { messageBox } = useDialog()
const { pushMessage } = useToast()
const { t } = useTranslation()

const openUntrashedNoteContextMenu = useCallback(
Expand All @@ -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',
Expand Down
5 changes: 5 additions & 0 deletions src/lib/CodeMirror.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -53,6 +56,8 @@ function loadMode(_CodeMirror: any) {
}
}

// Initialize custom addons
initHyperlink(CodeMirror)
loadMode(CodeMirror)

export default CodeMirror
Expand Down
183 changes: 183 additions & 0 deletions src/lib/addons/hyperlink.js
Original file line number Diff line number Diff line change
@@ -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)
})
}
Loading

0 comments on commit d75ab14

Please sign in to comment.