diff --git a/ElectronClient/app/app.js b/ElectronClient/app/app.js index a10c98fed..fb1dbb524 100644 --- a/ElectronClient/app/app.js +++ b/ElectronClient/app/app.js @@ -908,6 +908,9 @@ class Application extends BaseApplication { label: _('Website and documentation'), accelerator: 'F1', click () { bridge().openExternal('https://joplinapp.org'); }, + }, { + label: _('Joplin Forum'), + click () { bridge().openExternal('https://discourse.joplinapp.org'); }, }, { label: _('Make a donation'), click () { bridge().openExternal('https://joplinapp.org/donate/'); }, diff --git a/ElectronClient/app/gui/NoteText.jsx b/ElectronClient/app/gui/NoteText.jsx index eb1153507..222752669 100644 --- a/ElectronClient/app/gui/NoteText.jsx +++ b/ElectronClient/app/gui/NoteText.jsx @@ -574,8 +574,14 @@ class NoteTextComponent extends React.Component { this.editor_.editor.moveCursorTo(0, 0); setTimeout(() => { - this.setEditorPercentScroll(scrollPercent ? scrollPercent : 0); - this.setViewerPercentScroll(scrollPercent ? scrollPercent : 0); + // If we have an anchor hash, jump to that anchor + if (this.props.selectedNoteHash) { + this.webviewRef_.current.wrappedInstance.send('scrollToHash', this.props.selectedNoteHash); + } else { + // Otherwise restore the normal scroll position + this.setEditorPercentScroll(scrollPercent ? scrollPercent : 0); + this.setViewerPercentScroll(scrollPercent ? scrollPercent : 0); + } }, 10); } @@ -797,7 +803,8 @@ class NoteTextComponent extends React.Component { menu.popup(bridge().window()); } else if (msg.indexOf('joplin://') === 0) { - const itemId = msg.substr('joplin://'.length); + const resourceUrlInfo = urlUtils.parseResourceUrl(msg); + const itemId = resourceUrlInfo.itemId; const item = await BaseItem.loadItemById(itemId); if (!item) throw new Error('No item with ID ' + itemId); @@ -815,6 +822,7 @@ class NoteTextComponent extends React.Component { type: 'FOLDER_AND_NOTE_SELECT', folderId: item.parent_id, noteId: item.id, + hash: resourceUrlInfo.hash, historyNoteAction: { id: this.state.note.id, parent_id: this.state.note.parent_id, @@ -2055,6 +2063,7 @@ const mapStateToProps = state => { noteId: state.selectedNoteIds.length === 1 ? state.selectedNoteIds[0] : null, notes: state.notes, selectedNoteIds: state.selectedNoteIds, + selectedNoteHash: state.selectedNoteHash, noteTags: state.selectedNoteTags, folderId: state.selectedFolderId, itemType: state.selectedItemType, diff --git a/ElectronClient/app/gui/note-viewer/index.html b/ElectronClient/app/gui/note-viewer/index.html index 7624fd862..e2620c964 100644 --- a/ElectronClient/app/gui/note-viewer/index.html +++ b/ElectronClient/app/gui/note-viewer/index.html @@ -105,6 +105,28 @@ setPercentScroll(percentScroll_); } + ipc.scrollToHash = (event) => { + if (window.scrollToHashIID_) clearInterval(window.scrollToHashIID_); + window.scrollToHashIID_ = setInterval(() => { + if (document.readyState !== 'complete') return; + clearInterval(window.scrollToHashIID_); + const hash = event.hash.toLowerCase(); + const e = document.getElementById(hash); + if (!e) { + console.warn('Cannot find hash', hash); + return; + } + e.scrollIntoView(); + + // Make sure the editor pane is also scrolled + setTimeout(() => { + const percent = currentPercentScroll(); + setPercentScroll(percent); + ipcProxySendToHost('percentScroll', percent); + }, 10); + }, 100); + } + ipc.setHtml = (event) => { const html = event.html; @@ -265,13 +287,17 @@ document.getElementById('content').style.height = window.innerHeight + 'px'; } + function currentPercentScroll() { + const m = maxScrollTop(); + return m ? contentElement.scrollTop / m : 0; + } + contentElement.addEventListener('scroll', webviewLib.logEnabledEventHandler(e => { if (ignoreNextScrollEvent) { ignoreNextScrollEvent = false; return; } - const m = maxScrollTop(); - const percent = m ? contentElement.scrollTop / m : 0; + const percent = currentPercentScroll(); setPercentScroll(percent); ipcProxySendToHost('percentScroll', percent); diff --git a/ElectronClient/app/gui/note-viewer/preload.js b/ElectronClient/app/gui/note-viewer/preload.js index 65de92a97..42ee97654 100644 --- a/ElectronClient/app/gui/note-viewer/preload.js +++ b/ElectronClient/app/gui/note-viewer/preload.js @@ -9,6 +9,10 @@ ipcRenderer.on('setHtml', (event, html, options) => { window.postMessage({ target: 'webview', name: 'setHtml', data: { html: html, options: options } }, '*'); }); +ipcRenderer.on('scrollToHash', (event, hash) => { + window.postMessage({ target: 'webview', name: 'scrollToHash', data: { hash: hash } }, '*'); +}); + ipcRenderer.on('setPercentScroll', (event, percent) => { window.postMessage({ target: 'webview', name: 'setPercentScroll', data: { percent: percent } }, '*'); }); diff --git a/ReactNativeClient/lib/BaseApplication.js b/ReactNativeClient/lib/BaseApplication.js index ab1cacdc5..b8f4c4f5d 100644 --- a/ReactNativeClient/lib/BaseApplication.js +++ b/ReactNativeClient/lib/BaseApplication.js @@ -196,7 +196,7 @@ class BaseApplication { process.exit(code); } - async refreshNotes(state, useSelectedNoteId = false) { + async refreshNotes(state, useSelectedNoteId = false, noteHash = '') { let parentType = state.notesParentType; let parentId = null; @@ -248,6 +248,7 @@ class BaseApplication { this.store().dispatch({ type: 'NOTE_SELECT', id: state.selectedNoteIds && state.selectedNoteIds.length ? state.selectedNoteIds[0] : null, + hash: noteHash, }); } else { const lastSelectedNoteIds = stateUtils.lastSelectedNoteIds(state); @@ -388,6 +389,7 @@ class BaseApplication { let refreshFolders = false; // let refreshTags = false; let refreshNotesUseSelectedNoteId = false; + let refreshNotesHash = ''; await reduxSharedMiddleware(store, next, action); @@ -407,7 +409,10 @@ class BaseApplication { this.currentFolder_ = newState.selectedFolderId ? await Folder.load(newState.selectedFolderId) : null; refreshNotes = true; - if (action.type === 'FOLDER_AND_NOTE_SELECT') refreshNotesUseSelectedNoteId = true; + if (action.type === 'FOLDER_AND_NOTE_SELECT') { + refreshNotesUseSelectedNoteId = true; + refreshNotesHash = action.hash; + } } if (this.hasGui() && ((action.type == 'SETTING_UPDATE_ONE' && action.key == 'uncompletedTodosOnTop') || action.type == 'SETTING_UPDATE_ALL')) { @@ -431,7 +436,7 @@ class BaseApplication { } if (refreshNotes) { - await this.refreshNotes(newState, refreshNotesUseSelectedNoteId); + await this.refreshNotes(newState, refreshNotesUseSelectedNoteId, refreshNotesHash); } if (action.type === 'NOTE_UPDATE_ONE') { diff --git a/ReactNativeClient/lib/components/note-body-viewer.js b/ReactNativeClient/lib/components/note-body-viewer.js index aed30cddd..86d7f46d0 100644 --- a/ReactNativeClient/lib/components/note-body-viewer.js +++ b/ReactNativeClient/lib/components/note-body-viewer.js @@ -114,6 +114,20 @@ class NoteBodyViewer extends Component { if (document.readyState === "complete") { clearInterval(readyStateCheckInterval); if ("${resourceDownloadMode}" === "manual") webviewLib.setupResourceManualDownload(); + + const hash = "${this.props.noteHash}"; + // Gives it a bit of time before scrolling to the anchor + // so that images are loaded. + if (hash) { + setTimeout(() => { + const e = document.getElementById(hash); + if (!e) { + console.warn('Cannot find hash', hash); + return; + } + e.scrollIntoView(); + }, 500); + } } }, 10); `); diff --git a/ReactNativeClient/lib/components/screens/note.js b/ReactNativeClient/lib/components/screens/note.js index 18f005a9a..e64fa1550 100644 --- a/ReactNativeClient/lib/components/screens/note.js +++ b/ReactNativeClient/lib/components/screens/note.js @@ -36,6 +36,7 @@ const { SelectDateTimeDialog } = require('lib/components/select-date-time-dialog const ShareExtension = require('react-native-share-extension').default; const CameraView = require('lib/components/CameraView'); const SearchEngine = require('lib/services/SearchEngine'); +const urlUtils = require('lib/urlUtils'); import FileViewer from 'react-native-file-viewer'; @@ -123,7 +124,8 @@ class NoteScreenComponent extends BaseScreenComponent { this.onJoplinLinkClick_ = async msg => { try { if (msg.indexOf('joplin://') === 0) { - const itemId = msg.substr('joplin://'.length); + const resourceUrlInfo = urlUtils.parseResourceUrl(msg); + const itemId = resourceUrlInfo.itemId; const item = await BaseItem.loadItemById(itemId); if (!item) throw new Error(_('No item with ID %s', itemId)); @@ -140,6 +142,7 @@ class NoteScreenComponent extends BaseScreenComponent { type: 'NAV_GO', routeName: 'Note', noteId: item.id, + noteHash: resourceUrlInfo.hash, }); }, 5); } else if (item.type_ === BaseModel.TYPE_RESOURCE) { @@ -823,6 +826,7 @@ class NoteScreenComponent extends BaseScreenComponent { noteResources={this.state.noteResources} highlightedKeywords={keywords} theme={this.props.theme} + noteHash={this.props.noteHash} onCheckboxChange={newBody => { onCheckboxChange(newBody); }} @@ -906,6 +910,7 @@ class NoteScreenComponent extends BaseScreenComponent { const NoteScreen = connect(state => { return { noteId: state.selectedNoteIds.length ? state.selectedNoteIds[0] : null, + noteHash: state.selectedNoteHash, folderId: state.selectedFolderId, itemType: state.selectedItemType, folders: state.folders, diff --git a/ReactNativeClient/lib/reducer.js b/ReactNativeClient/lib/reducer.js index e4fde0733..1b80fc371 100644 --- a/ReactNativeClient/lib/reducer.js +++ b/ReactNativeClient/lib/reducer.js @@ -12,6 +12,7 @@ const defaultState = { notLoadedMasterKeys: [], searches: [], selectedNoteIds: [], + selectedNoteHash: '', selectedFolderId: null, selectedTagId: null, selectedSearchId: null, @@ -267,6 +268,7 @@ function changeSelectedNotes(state, action, options = null) { if (JSON.stringify(newState.selectedNoteIds) === JSON.stringify(noteIds)) return state; newState.selectedNoteIds = noteIds; newState.newNote = null; + newState.selectedNoteHash = action.hash ? action.hash : ''; } else if (action.type === 'NOTE_SELECT_ADD') { if (!noteIds.length) return state; newState.selectedNoteIds = ArrayUtils.unique(newState.selectedNoteIds.concat(noteIds)); diff --git a/ReactNativeClient/lib/renderers/MdToHtml/rules/link_open.js b/ReactNativeClient/lib/renderers/MdToHtml/rules/link_open.js index 2db4f633f..4fe6e6c9f 100644 --- a/ReactNativeClient/lib/renderers/MdToHtml/rules/link_open.js +++ b/ReactNativeClient/lib/renderers/MdToHtml/rules/link_open.js @@ -1,20 +1,21 @@ const Entities = require('html-entities').AllHtmlEntities; const htmlentities = new Entities().encode; -const Resource = require('lib/models/Resource.js'); const utils = require('../../utils'); +const urlUtils = require('lib/urlUtils.js'); function installRule(markdownIt, mdOptions, ruleOptions) { markdownIt.renderer.rules.link_open = function(tokens, idx, options, env, self) { const token = tokens[idx]; let href = utils.getAttr(token.attrs, 'href'); - const isResourceUrl = Resource.isResourceUrl(href); + const resourceHrefInfo = urlUtils.parseResourceUrl(href); + const isResourceUrl = !!resourceHrefInfo.itemId; const title = isResourceUrl ? utils.getAttr(token.attrs, 'title') : href; let resourceIdAttr = ''; let icon = ''; let hrefAttr = '#'; if (isResourceUrl) { - const resourceId = Resource.pathToId(href); + const resourceId = resourceHrefInfo.itemId; const result = ruleOptions.resources[resourceId]; const resourceStatus = utils.resourceStatus(result); @@ -24,6 +25,7 @@ function installRule(markdownIt, mdOptions, ruleOptions) { return '' + ''; } else { href = 'joplin://' + resourceId; + if (resourceHrefInfo.hash) href += '#' + resourceHrefInfo.hash; resourceIdAttr = 'data-resource-id=\'' + resourceId + '\''; icon = ''; } diff --git a/ReactNativeClient/lib/urlUtils.js b/ReactNativeClient/lib/urlUtils.js index a4e06e7ae..bc31177cc 100644 --- a/ReactNativeClient/lib/urlUtils.js +++ b/ReactNativeClient/lib/urlUtils.js @@ -39,4 +39,19 @@ urlUtils.prependBaseUrl = function(url, baseUrl) { } }; +urlUtils.parseResourceUrl = function(url) { + const filename = url.split('/').pop(); + const splitted = filename.split('#'); + + const output = { + itemId: '', + hash: '', + }; + + if (splitted.length) output.itemId = splitted[0]; + if (splitted.length >= 2) output.hash = splitted[1]; + + return output; +}; + module.exports = urlUtils; diff --git a/ReactNativeClient/root.js b/ReactNativeClient/root.js index 47e6912a3..6e15fd8c6 100644 --- a/ReactNativeClient/root.js +++ b/ReactNativeClient/root.js @@ -236,6 +236,8 @@ const appReducer = (state = appDefaultState, action) => { newState = Object.assign({}, state); + newState.selectedNoteHash = ''; + if ('noteId' in action) { newState.selectedNoteIds = action.noteId ? [action.noteId] : []; } @@ -259,6 +261,10 @@ const appReducer = (state = appDefaultState, action) => { newState.selectedItemType = action.itemType; } + if ('noteHash' in action) { + newState.selectedNoteHash = action.noteHash; + } + if ('sharedData' in action) { newState.sharedData = action.sharedData; } else {