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:
parent
fa83107840
commit
7aea2cec69
@ -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/'); },
|
||||||
|
@ -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,
|
||||||
|
@ -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);
|
||||||
|
@ -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 } }, '*');
|
||||||
});
|
});
|
||||||
|
@ -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') {
|
||||||
|
@ -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);
|
||||||
`);
|
`);
|
||||||
|
@ -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,
|
||||||
|
@ -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));
|
||||||
|
@ -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>';
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
@ -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 {
|
||||||
|
Loading…
Reference in New Issue
Block a user