1
0
mirror of https://github.com/laurent22/joplin.git synced 2024-12-24 10:27:10 +02:00

Desktop: Resolves #1490: Add support for anchor hashes in note links

This commit is contained in:
Laurent Cozic 2019-09-09 18:16:00 +01:00
parent fa83107840
commit 7aea2cec69
11 changed files with 103 additions and 12 deletions

View File

@ -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/'); },

View File

@ -574,8 +574,14 @@ class NoteTextComponent extends React.Component {
this.editor_.editor.moveCursorTo(0, 0);
setTimeout(() => {
// 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,

View File

@ -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);

View File

@ -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 } }, '*');
});

View File

@ -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') {

View File

@ -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);
`);

View File

@ -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,

View File

@ -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));

View File

@ -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 '<a class="not-loaded-resource resource-status-' + resourceStatus + '" data-resource-id="' + resourceId + '">' + '<img src="data:image/svg+xml;utf8,' + htmlentities(icon) + '"/>';
} else {
href = 'joplin://' + resourceId;
if (resourceHrefInfo.hash) href += '#' + resourceHrefInfo.hash;
resourceIdAttr = 'data-resource-id=\'' + resourceId + '\'';
icon = '<span class="resource-icon"></span>';
}

View File

@ -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;

View File

@ -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 {