Skip to content

Commit 9dd8225

Browse files
Desktop: Fixes #12105: Link to header: Move the Markdown editor cursor to the location of the link target (#12118)
1 parent 2dbdf47 commit 9dd8225

File tree

26 files changed

+346
-239
lines changed

26 files changed

+346
-239
lines changed

.eslintignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ packages/app-mobile/locales
6262
packages/app-mobile/node_modules
6363
packages/app-mobile/pluginAssets/
6464
packages/fork-*
65+
!packages/fork-uslug
6566
packages/default-plugins/plugin-base-repo/
6667
packages/default-plugins/plugin-sources/
6768
packages/htmlpack/dist/
@@ -924,6 +925,8 @@ packages/editor/CodeMirror/editorCommands/duplicateLine.js
924925
packages/editor/CodeMirror/editorCommands/editorCommands.js
925926
packages/editor/CodeMirror/editorCommands/insertLineAfter.test.js
926927
packages/editor/CodeMirror/editorCommands/insertLineAfter.js
928+
packages/editor/CodeMirror/editorCommands/jumpToHash.test.js
929+
packages/editor/CodeMirror/editorCommands/jumpToHash.js
927930
packages/editor/CodeMirror/editorCommands/sortSelectedLines.test.js
928931
packages/editor/CodeMirror/editorCommands/sortSelectedLines.js
929932
packages/editor/CodeMirror/editorCommands/supportsCommand.js
@@ -1002,6 +1005,8 @@ packages/fork-htmlparser2/src/__tests__/events.js
10021005
packages/fork-htmlparser2/src/__tests__/stream.js
10031006
packages/fork-htmlparser2/src/index.spec.js
10041007
packages/fork-htmlparser2/src/index.js
1008+
packages/fork-uslug/lib/uslug.test.js
1009+
packages/fork-uslug/lib/uslug.js
10051010
packages/generator-joplin/generators/app/templates/api/index.js
10061011
packages/generator-joplin/generators/app/templates/api/noteListType.js
10071012
packages/generator-joplin/generators/app/templates/api/types.js

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -899,6 +899,8 @@ packages/editor/CodeMirror/editorCommands/duplicateLine.js
899899
packages/editor/CodeMirror/editorCommands/editorCommands.js
900900
packages/editor/CodeMirror/editorCommands/insertLineAfter.test.js
901901
packages/editor/CodeMirror/editorCommands/insertLineAfter.js
902+
packages/editor/CodeMirror/editorCommands/jumpToHash.test.js
903+
packages/editor/CodeMirror/editorCommands/jumpToHash.js
902904
packages/editor/CodeMirror/editorCommands/sortSelectedLines.test.js
903905
packages/editor/CodeMirror/editorCommands/sortSelectedLines.js
904906
packages/editor/CodeMirror/editorCommands/supportsCommand.js
@@ -977,6 +979,8 @@ packages/fork-htmlparser2/src/__tests__/events.js
977979
packages/fork-htmlparser2/src/__tests__/stream.js
978980
packages/fork-htmlparser2/src/index.spec.js
979981
packages/fork-htmlparser2/src/index.js
982+
packages/fork-uslug/lib/uslug.test.js
983+
packages/fork-uslug/lib/uslug.js
980984
packages/generator-joplin/generators/app/templates/api/index.js
981985
packages/generator-joplin/generators/app/templates/api/noteListType.js
982986
packages/generator-joplin/generators/app/templates/api/types.js

packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/CodeMirror.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,9 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
167167
scrollTo: (options: ScrollOptions) => {
168168
if (options.type === ScrollOptionTypes.Hash) {
169169
if (!webviewRef.current) return;
170-
webviewRef.current.send('scrollToHash', options.value as string);
170+
const hash: string = options.value;
171+
webviewRef.current.send('scrollToHash', hash);
172+
editorRef.current.jumpToHash(hash);
171173
} else if (options.type === ScrollOptionTypes.Percent) {
172174
const percent = options.value as number;
173175
setEditorPercentScroll(percent);

packages/app-desktop/gui/note-viewer/index.html

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,8 +139,11 @@
139139
const viewerPercent = scrollmap.translateL2V(percent);
140140
const newScrollTop = viewerPercent * maxScrollTop();
141141

142-
// The next scroll event cannot be skipped in order to correctly
143-
// scroll to the target section in a different note when follwing a link
142+
// Even if the scroll position hasn't changed (percent is the same),
143+
// we still ignore the next scroll event, so that it doesn't create
144+
// undesired side effects.
145+
// https://github.com/laurent22/joplin/issues/7617
146+
ignoreNextScrollEvent();
144147

145148
if (Math.floor(contentElement.scrollTop) !== Math.floor(newScrollTop)) {
146149
percentScroll_ = percent;

packages/editor/CodeMirror/CodeMirrorControl.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { RegionSpec } from './utils/formatting/RegionSpec';
1212
import toggleInlineSelectionFormat from './utils/formatting/toggleInlineSelectionFormat';
1313
import getSearchState from './utils/getSearchState';
1414
import { noteIdFacet, setNoteIdEffect } from './utils/selectedNoteIdExtension';
15+
import jumpToHash from './editorCommands/jumpToHash';
1516

1617
interface Callbacks {
1718
onUndoRedo(): void;
@@ -207,6 +208,10 @@ export default class CodeMirrorControl extends CodeMirror5Emulation implements E
207208
return textFound;
208209
}
209210

211+
public jumpToHash(hash: string) {
212+
return jumpToHash(this.editor, hash);
213+
}
214+
210215
public addStyles(...styles: Parameters<typeof EditorView.theme>) {
211216
const compartment = new Compartment();
212217
this.editor.dispatch({

packages/editor/CodeMirror/editorCommands/editorCommands.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import sortSelectedLines from './sortSelectedLines';
1313
import { closeSearchPanel, findNext, findPrevious, openSearchPanel, replaceAll, replaceNext, searchPanelOpen } from '@codemirror/search';
1414
import { focus } from '@joplin/lib/utils/focusHandler';
1515
import { showLinkEditor } from '../utils/handleLinkEditRequests';
16+
import jumpToHash from './jumpToHash';
1617

1718
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
1819
export type EditorCommandFunction = (editor: EditorView, ...args: any[])=> void|any;
@@ -107,6 +108,10 @@ const editorCommands: Record<EditorCommandType, EditorCommandFunction> = {
107108
}],
108109
});
109110
},
111+
112+
[EditorCommandType.JumpToHash]: (editor, hash: string) => {
113+
return jumpToHash(editor, hash);
114+
},
110115
};
111116
export default editorCommands;
112117

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { EditorSelection } from '@codemirror/state';
2+
import createTestEditor from '../testUtil/createTestEditor';
3+
import jumpToHash from './jumpToHash';
4+
5+
describe('jumpToHash', () => {
6+
test.each([
7+
{
8+
doc: 'This is an anchor: <a id="test">Test</a>',
9+
expectedCursorLocation: 'This is an anchor: <a id="test">'.length,
10+
waitForTags: ['HTMLTag'],
11+
},
12+
{
13+
doc: '<div>HTML block: This is an anchor: <a id="test">Test</a></div>',
14+
expectedCursorLocation: '<div>HTML block: This is an anchor: <a id="test">'.length,
15+
waitForTags: ['HTMLBlock'],
16+
},
17+
])('should support jumping to elements with ID set to "test" (case %#)', async ({ doc: docText, expectedCursorLocation, waitForTags }) => {
18+
const editor = await createTestEditor(
19+
docText,
20+
EditorSelection.cursor(1),
21+
waitForTags,
22+
);
23+
expect(jumpToHash(editor, 'test')).toBe(true);
24+
const cursorPosition = editor.state.selection.main.anchor;
25+
expect(
26+
editor.state.sliceDoc(0, cursorPosition),
27+
).toBe(
28+
editor.state.sliceDoc(0, expectedCursorLocation),
29+
);
30+
});
31+
32+
test('should jump to Markdown headers', async () => {
33+
const editor = await createTestEditor(
34+
'Line 1\n## Line 2',
35+
EditorSelection.cursor(0),
36+
['ATXHeading2'],
37+
);
38+
expect(jumpToHash(editor, 'line-2')).toBe(true);
39+
expect(editor.state.selection.main.anchor).toBe(editor.state.doc.length);
40+
});
41+
});
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { ensureSyntaxTree } from '@codemirror/language';
2+
import { EditorSelection } from '@codemirror/state';
3+
import { EditorView } from '@codemirror/view';
4+
import uslug from '@joplin/fork-uslug/lib/uslug';
5+
import { SyntaxNodeRef } from '@lezer/common';
6+
7+
const jumpToHash = (view: EditorView, hash: string) => {
8+
const state = view.state;
9+
const timeout = 1_000; // Maximum time to spend parsing the syntax tree
10+
let targetLocation: number|undefined = undefined;
11+
12+
const removeQuotes = (quoted: string) => quoted.replace(/^["'](.*)["']$/, '$1');
13+
14+
const makeEnterNode = (offset: number) => (node: SyntaxNodeRef) => {
15+
const nodeToText = (node: SyntaxNodeRef) => {
16+
return state.sliceDoc(node.from + offset, node.to + offset);
17+
};
18+
// Returns the attribute with the given name for [node]
19+
const getHtmlNodeAttr = (node: SyntaxNodeRef, attrName: string) => {
20+
if (node.from === node.to) return null; // Empty
21+
const content = node.node.resolveInner(node.from + 1);
22+
23+
// Search for the "id" attribute
24+
const attributes = content.getChildren('Attribute');
25+
for (const attribute of attributes) {
26+
const nameNode = attribute.getChild('AttributeName');
27+
const valueNode = attribute.getChild('AttributeValue');
28+
29+
if (nameNode && valueNode) {
30+
const name = nodeToText(nameNode).toLowerCase().replace(/^"(.*)"$/, '$1');
31+
if (name === attrName) {
32+
return removeQuotes(nodeToText(valueNode));
33+
}
34+
}
35+
}
36+
37+
return null;
38+
};
39+
40+
const found = targetLocation !== undefined;
41+
if (found) return false; // Skip this node
42+
43+
let matches = false;
44+
if (node.name.startsWith('SetextHeading') || node.name.startsWith('ATXHeading')) {
45+
const nodeText = nodeToText(node)
46+
.replace(/^#+\s/, '') // Leading #s in headers
47+
.replace(/\n-+$/, ''); // Trailing --s in headers
48+
matches = hash === uslug(nodeText);
49+
} else if (node.name === 'HTMLTag' || node.name === 'HTMLBlock') {
50+
// CodeMirror adds HTML information to Markdown documents using overlays attached
51+
// to HTMLTag and HTMLBlock nodes.
52+
// Use .enter to enter the overlay and visit the HTML nodes:
53+
node.node.enter(node.from, 1).toTree().iterate({ enter: makeEnterNode(node.from) });
54+
} else if (node.name === 'OpenTag') {
55+
matches = getHtmlNodeAttr(node, 'id') === hash || getHtmlNodeAttr(node, 'name') === hash;
56+
}
57+
58+
if (matches) {
59+
targetLocation = node.to + offset;
60+
return false;
61+
}
62+
63+
const keepIterating = !matches;
64+
return keepIterating;
65+
};
66+
67+
// Iterate over the entire syntax tree.
68+
ensureSyntaxTree(state, state.doc.length, timeout).iterate({
69+
enter: makeEnterNode(0),
70+
});
71+
72+
if (targetLocation !== undefined) {
73+
view.dispatch({
74+
selection: EditorSelection.cursor(targetLocation),
75+
scrollIntoView: true,
76+
});
77+
return true;
78+
}
79+
return false;
80+
};
81+
82+
export default jumpToHash;

packages/editor/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
"@codemirror/search": "6.5.8",
3838
"@codemirror/state": "6.4.1",
3939
"@codemirror/view": "6.35.0",
40+
"@joplin/fork-uslug": "^2.0.0",
4041
"@lezer/common": "1.2.3",
4142
"@lezer/highlight": "1.2.1",
4243
"@lezer/markdown": "1.3.2",

packages/editor/types.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,9 @@ export enum EditorCommandType {
7474
SelectedText = 'selectedText',
7575
InsertText = 'insertText',
7676
ReplaceSelection = 'replaceSelection',
77-
7877
SetText = 'setText',
78+
79+
JumpToHash = 'jumpToHash',
7980
}
8081

8182
// Because the editor package can run in a WebView, plugin content scripts

packages/fork-uslug/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
Modified for Joplin:
66

77
- Added support for emojis - "🐶🐶🐶🐱" => "dogdogdogcat"
8+
- Smaller package size: Removed dependencies on functionality that's now built-in to JavaScript (Unicode normalization, Unicode character class regular expressions).
9+
- Types: Migrated to TypeScript.
810

911
* * *
1012

packages/fork-uslug/index.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
module.exports = require('./lib/uslug');
1+
module.exports = require('./lib/uslug').default;

packages/fork-uslug/jest.config.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
module.exports = {
2+
testMatch: [
3+
'**/*.test.js',
4+
],
5+
};

packages/fork-uslug/lib/L.js

Lines changed: 0 additions & 15 deletions
This file was deleted.

0 commit comments

Comments
 (0)