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'), label: _('Website and documentation'),
accelerator: 'F1', accelerator: 'F1',
click () { bridge().openExternal('https://joplinapp.org'); }, click () { bridge().openExternal('https://joplinapp.org'); },
}, {
label: _('Joplin Forum'),
click () { bridge().openExternal('https://discourse.joplinapp.org'); },
}, { }, {
label: _('Make a donation'), label: _('Make a donation'),
click () { bridge().openExternal('https://joplinapp.org/donate/'); }, click () { bridge().openExternal('https://joplinapp.org/donate/'); },

View File

@ -574,8 +574,14 @@ class NoteTextComponent extends React.Component {
this.editor_.editor.moveCursorTo(0, 0); this.editor_.editor.moveCursorTo(0, 0);
setTimeout(() => { setTimeout(() => {
this.setEditorPercentScroll(scrollPercent ? scrollPercent : 0); // If we have an anchor hash, jump to that anchor
this.setViewerPercentScroll(scrollPercent ? scrollPercent : 0); 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); }, 10);
} }
@ -797,7 +803,8 @@ class NoteTextComponent extends React.Component {
menu.popup(bridge().window()); menu.popup(bridge().window());
} else if (msg.indexOf('joplin://') === 0) { } 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); const item = await BaseItem.loadItemById(itemId);
if (!item) throw new Error('No item with ID ' + 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', type: 'FOLDER_AND_NOTE_SELECT',
folderId: item.parent_id, folderId: item.parent_id,
noteId: item.id, noteId: item.id,
hash: resourceUrlInfo.hash,
historyNoteAction: { historyNoteAction: {
id: this.state.note.id, id: this.state.note.id,
parent_id: this.state.note.parent_id, parent_id: this.state.note.parent_id,
@ -2055,6 +2063,7 @@ const mapStateToProps = state => {
noteId: state.selectedNoteIds.length === 1 ? state.selectedNoteIds[0] : null, noteId: state.selectedNoteIds.length === 1 ? state.selectedNoteIds[0] : null,
notes: state.notes, notes: state.notes,
selectedNoteIds: state.selectedNoteIds, selectedNoteIds: state.selectedNoteIds,
selectedNoteHash: state.selectedNoteHash,
noteTags: state.selectedNoteTags, noteTags: state.selectedNoteTags,
folderId: state.selectedFolderId, folderId: state.selectedFolderId,
itemType: state.selectedItemType, itemType: state.selectedItemType,

View File

@ -105,6 +105,28 @@
setPercentScroll(percentScroll_); 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) => { ipc.setHtml = (event) => {
const html = event.html; const html = event.html;
@ -265,13 +287,17 @@
document.getElementById('content').style.height = window.innerHeight + 'px'; 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 => { contentElement.addEventListener('scroll', webviewLib.logEnabledEventHandler(e => {
if (ignoreNextScrollEvent) { if (ignoreNextScrollEvent) {
ignoreNextScrollEvent = false; ignoreNextScrollEvent = false;
return; return;
} }
const m = maxScrollTop(); const percent = currentPercentScroll();
const percent = m ? contentElement.scrollTop / m : 0;
setPercentScroll(percent); setPercentScroll(percent);
ipcProxySendToHost('percentScroll', 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 } }, '*'); 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) => { ipcRenderer.on('setPercentScroll', (event, percent) => {
window.postMessage({ target: 'webview', name: 'setPercentScroll', data: { percent: percent } }, '*'); window.postMessage({ target: 'webview', name: 'setPercentScroll', data: { percent: percent } }, '*');
}); });

View File

@ -196,7 +196,7 @@ class BaseApplication {
process.exit(code); process.exit(code);
} }
async refreshNotes(state, useSelectedNoteId = false) { async refreshNotes(state, useSelectedNoteId = false, noteHash = '') {
let parentType = state.notesParentType; let parentType = state.notesParentType;
let parentId = null; let parentId = null;
@ -248,6 +248,7 @@ class BaseApplication {
this.store().dispatch({ this.store().dispatch({
type: 'NOTE_SELECT', type: 'NOTE_SELECT',
id: state.selectedNoteIds && state.selectedNoteIds.length ? state.selectedNoteIds[0] : null, id: state.selectedNoteIds && state.selectedNoteIds.length ? state.selectedNoteIds[0] : null,
hash: noteHash,
}); });
} else { } else {
const lastSelectedNoteIds = stateUtils.lastSelectedNoteIds(state); const lastSelectedNoteIds = stateUtils.lastSelectedNoteIds(state);
@ -388,6 +389,7 @@ class BaseApplication {
let refreshFolders = false; let refreshFolders = false;
// let refreshTags = false; // let refreshTags = false;
let refreshNotesUseSelectedNoteId = false; let refreshNotesUseSelectedNoteId = false;
let refreshNotesHash = '';
await reduxSharedMiddleware(store, next, action); await reduxSharedMiddleware(store, next, action);
@ -407,7 +409,10 @@ class BaseApplication {
this.currentFolder_ = newState.selectedFolderId ? await Folder.load(newState.selectedFolderId) : null; this.currentFolder_ = newState.selectedFolderId ? await Folder.load(newState.selectedFolderId) : null;
refreshNotes = true; 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')) { if (this.hasGui() && ((action.type == 'SETTING_UPDATE_ONE' && action.key == 'uncompletedTodosOnTop') || action.type == 'SETTING_UPDATE_ALL')) {
@ -431,7 +436,7 @@ class BaseApplication {
} }
if (refreshNotes) { if (refreshNotes) {
await this.refreshNotes(newState, refreshNotesUseSelectedNoteId); await this.refreshNotes(newState, refreshNotesUseSelectedNoteId, refreshNotesHash);
} }
if (action.type === 'NOTE_UPDATE_ONE') { if (action.type === 'NOTE_UPDATE_ONE') {

View File

@ -114,6 +114,20 @@ class NoteBodyViewer extends Component {
if (document.readyState === "complete") { if (document.readyState === "complete") {
clearInterval(readyStateCheckInterval); clearInterval(readyStateCheckInterval);
if ("${resourceDownloadMode}" === "manual") webviewLib.setupResourceManualDownload(); 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); }, 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 ShareExtension = require('react-native-share-extension').default;
const CameraView = require('lib/components/CameraView'); const CameraView = require('lib/components/CameraView');
const SearchEngine = require('lib/services/SearchEngine'); const SearchEngine = require('lib/services/SearchEngine');
const urlUtils = require('lib/urlUtils');
import FileViewer from 'react-native-file-viewer'; import FileViewer from 'react-native-file-viewer';
@ -123,7 +124,8 @@ class NoteScreenComponent extends BaseScreenComponent {
this.onJoplinLinkClick_ = async msg => { this.onJoplinLinkClick_ = async msg => {
try { try {
if (msg.indexOf('joplin://') === 0) { 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); const item = await BaseItem.loadItemById(itemId);
if (!item) throw new Error(_('No item with ID %s', itemId)); if (!item) throw new Error(_('No item with ID %s', itemId));
@ -140,6 +142,7 @@ class NoteScreenComponent extends BaseScreenComponent {
type: 'NAV_GO', type: 'NAV_GO',
routeName: 'Note', routeName: 'Note',
noteId: item.id, noteId: item.id,
noteHash: resourceUrlInfo.hash,
}); });
}, 5); }, 5);
} else if (item.type_ === BaseModel.TYPE_RESOURCE) { } else if (item.type_ === BaseModel.TYPE_RESOURCE) {
@ -823,6 +826,7 @@ class NoteScreenComponent extends BaseScreenComponent {
noteResources={this.state.noteResources} noteResources={this.state.noteResources}
highlightedKeywords={keywords} highlightedKeywords={keywords}
theme={this.props.theme} theme={this.props.theme}
noteHash={this.props.noteHash}
onCheckboxChange={newBody => { onCheckboxChange={newBody => {
onCheckboxChange(newBody); onCheckboxChange(newBody);
}} }}
@ -906,6 +910,7 @@ class NoteScreenComponent extends BaseScreenComponent {
const NoteScreen = connect(state => { const NoteScreen = connect(state => {
return { return {
noteId: state.selectedNoteIds.length ? state.selectedNoteIds[0] : null, noteId: state.selectedNoteIds.length ? state.selectedNoteIds[0] : null,
noteHash: state.selectedNoteHash,
folderId: state.selectedFolderId, folderId: state.selectedFolderId,
itemType: state.selectedItemType, itemType: state.selectedItemType,
folders: state.folders, folders: state.folders,

View File

@ -12,6 +12,7 @@ const defaultState = {
notLoadedMasterKeys: [], notLoadedMasterKeys: [],
searches: [], searches: [],
selectedNoteIds: [], selectedNoteIds: [],
selectedNoteHash: '',
selectedFolderId: null, selectedFolderId: null,
selectedTagId: null, selectedTagId: null,
selectedSearchId: null, selectedSearchId: null,
@ -267,6 +268,7 @@ function changeSelectedNotes(state, action, options = null) {
if (JSON.stringify(newState.selectedNoteIds) === JSON.stringify(noteIds)) return state; if (JSON.stringify(newState.selectedNoteIds) === JSON.stringify(noteIds)) return state;
newState.selectedNoteIds = noteIds; newState.selectedNoteIds = noteIds;
newState.newNote = null; newState.newNote = null;
newState.selectedNoteHash = action.hash ? action.hash : '';
} else if (action.type === 'NOTE_SELECT_ADD') { } else if (action.type === 'NOTE_SELECT_ADD') {
if (!noteIds.length) return state; if (!noteIds.length) return state;
newState.selectedNoteIds = ArrayUtils.unique(newState.selectedNoteIds.concat(noteIds)); newState.selectedNoteIds = ArrayUtils.unique(newState.selectedNoteIds.concat(noteIds));

View File

@ -1,20 +1,21 @@
const Entities = require('html-entities').AllHtmlEntities; const Entities = require('html-entities').AllHtmlEntities;
const htmlentities = new Entities().encode; const htmlentities = new Entities().encode;
const Resource = require('lib/models/Resource.js');
const utils = require('../../utils'); const utils = require('../../utils');
const urlUtils = require('lib/urlUtils.js');
function installRule(markdownIt, mdOptions, ruleOptions) { function installRule(markdownIt, mdOptions, ruleOptions) {
markdownIt.renderer.rules.link_open = function(tokens, idx, options, env, self) { markdownIt.renderer.rules.link_open = function(tokens, idx, options, env, self) {
const token = tokens[idx]; const token = tokens[idx];
let href = utils.getAttr(token.attrs, 'href'); 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; const title = isResourceUrl ? utils.getAttr(token.attrs, 'title') : href;
let resourceIdAttr = ''; let resourceIdAttr = '';
let icon = ''; let icon = '';
let hrefAttr = '#'; let hrefAttr = '#';
if (isResourceUrl) { if (isResourceUrl) {
const resourceId = Resource.pathToId(href); const resourceId = resourceHrefInfo.itemId;
const result = ruleOptions.resources[resourceId]; const result = ruleOptions.resources[resourceId];
const resourceStatus = utils.resourceStatus(result); 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) + '"/>'; return '<a class="not-loaded-resource resource-status-' + resourceStatus + '" data-resource-id="' + resourceId + '">' + '<img src="data:image/svg+xml;utf8,' + htmlentities(icon) + '"/>';
} else { } else {
href = 'joplin://' + resourceId; href = 'joplin://' + resourceId;
if (resourceHrefInfo.hash) href += '#' + resourceHrefInfo.hash;
resourceIdAttr = 'data-resource-id=\'' + resourceId + '\''; resourceIdAttr = 'data-resource-id=\'' + resourceId + '\'';
icon = '<span class="resource-icon"></span>'; 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; module.exports = urlUtils;

View File

@ -236,6 +236,8 @@ const appReducer = (state = appDefaultState, action) => {
newState = Object.assign({}, state); newState = Object.assign({}, state);
newState.selectedNoteHash = '';
if ('noteId' in action) { if ('noteId' in action) {
newState.selectedNoteIds = action.noteId ? [action.noteId] : []; newState.selectedNoteIds = action.noteId ? [action.noteId] : [];
} }
@ -259,6 +261,10 @@ const appReducer = (state = appDefaultState, action) => {
newState.selectedItemType = action.itemType; newState.selectedItemType = action.itemType;
} }
if ('noteHash' in action) {
newState.selectedNoteHash = action.noteHash;
}
if ('sharedData' in action) { if ('sharedData' in action) {
newState.sharedData = action.sharedData; newState.sharedData = action.sharedData;
} else { } else {