jupyterlab_edit_markdown_at_content_extension 0.4.3 → 0.6.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/lib/index.js +189 -37
- package/package.json +7 -3
- package/schema/plugin.json +23 -0
- package/src/index.ts +211 -45
package/README.md
CHANGED
|
@@ -12,9 +12,9 @@ Jump straight from a rendered markdown file into the editor at the exact line yo
|
|
|
12
12
|
|
|
13
13
|
## Features
|
|
14
14
|
|
|
15
|
-
- **
|
|
16
|
-
- **
|
|
17
|
-
- **
|
|
15
|
+
- **Show Markdown Editor** - right-click rendered content and pick "Show Markdown Editor"; the editor opens split-right with the cursor on the line that produced what you clicked. This replaces JupyterLab core's identically named command, which always opened at line 0
|
|
16
|
+
- **Reveal in Markdown Preview** - right-click in the editor and pick "Reveal in Markdown Preview" to scroll the rendered preview to the block at the cursor
|
|
17
|
+
- **Synced scrolling** - once the editor is opened from the preview, the two panes track each other: the pane you are scrolling drives, the other follows to the matching location. Toggle with the `trackEditor` setting (on by default) under Settings → Edit Markdown at Content
|
|
18
18
|
|
|
19
19
|
## Requirements
|
|
20
20
|
|
package/lib/index.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { IDocumentManager } from '@jupyterlab/docmanager';
|
|
2
2
|
import { IEditorTracker } from '@jupyterlab/fileeditor';
|
|
3
3
|
import { IMarkdownViewerTracker } from '@jupyterlab/markdownviewer';
|
|
4
|
+
import { ISettingRegistry } from '@jupyterlab/settingregistry';
|
|
5
|
+
import { EditorView } from '@codemirror/view';
|
|
4
6
|
import { buildBlockMap, blockToLine, lineToBlock, headingSlug } from './mapping';
|
|
5
7
|
const PLUGIN_ID = 'jupyterlab_edit_markdown_at_content_extension:plugin';
|
|
6
8
|
const CMD_EDIT_AT = 'editmarkdownatcontent:edit-at-location';
|
|
@@ -10,6 +12,38 @@ const EDITOR_SELECTOR = '.jp-FileEditor';
|
|
|
10
12
|
const EDITOR_FACTORY = 'Editor';
|
|
11
13
|
const PREVIEW_FACTORY = 'Markdown Preview';
|
|
12
14
|
const LOG = '[edit-markdown-at-content]';
|
|
15
|
+
/**
|
|
16
|
+
* Scroll a rendered preview `host` so the block that produced source `line` is
|
|
17
|
+
* at the top. Tries ordinal alignment first; falls back to matching the
|
|
18
|
+
* nearest-preceding heading by its createHeaderId slug when the rendered child
|
|
19
|
+
* count diverges from the lexed block count (math, sanitizer, injected nodes).
|
|
20
|
+
*/
|
|
21
|
+
function revealLineInPreview(host, source, line) {
|
|
22
|
+
var _a;
|
|
23
|
+
const { ordinal, headingSlug: slug, headingNth } = lineToBlock(source, line);
|
|
24
|
+
const children = Array.from(host.children);
|
|
25
|
+
const expected = buildBlockMap(source).blocks.length;
|
|
26
|
+
if (children.length === expected && ordinal >= 0) {
|
|
27
|
+
children[ordinal].scrollIntoView({ block: 'start' });
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
if (slug) {
|
|
31
|
+
const headings = Array.from(host.querySelectorAll('h1, h2, h3, h4, h5, h6'));
|
|
32
|
+
let seen = 0;
|
|
33
|
+
for (const h of headings) {
|
|
34
|
+
const id = h.id ||
|
|
35
|
+
h.getAttribute('data-jupyter-id') ||
|
|
36
|
+
headingSlug((_a = h.textContent) !== null && _a !== void 0 ? _a : '');
|
|
37
|
+
if (id === slug) {
|
|
38
|
+
seen += 1;
|
|
39
|
+
if (seen === (headingNth !== null && headingNth !== void 0 ? headingNth : 1)) {
|
|
40
|
+
h.scrollIntoView({ block: 'start' });
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
13
47
|
/**
|
|
14
48
|
* Initialization data for the jupyterlab_edit_markdown_at_content_extension extension.
|
|
15
49
|
*/
|
|
@@ -18,8 +52,25 @@ const plugin = {
|
|
|
18
52
|
description: 'Jupyterlab extension to save you the scrolling time from when you are at markdown file location and open editor and need to scroll to the exact place in the file where the content is. This extension opens the editor at the place where the content is',
|
|
19
53
|
autoStart: true,
|
|
20
54
|
requires: [IDocumentManager, IEditorTracker, IMarkdownViewerTracker],
|
|
21
|
-
|
|
55
|
+
optional: [ISettingRegistry],
|
|
56
|
+
activate: (app, docManager, editorTracker, markdownTracker, settingRegistry) => {
|
|
22
57
|
console.log('JupyterLab extension jupyterlab_edit_markdown_at_content_extension is activated!');
|
|
58
|
+
// `trackEditor` (default true): keep the editor and preview scrolled
|
|
59
|
+
// together once the editor is opened via the command. Read from settings;
|
|
60
|
+
// defaults to enabled when no setting registry is available.
|
|
61
|
+
let trackEnabled = true;
|
|
62
|
+
if (settingRegistry) {
|
|
63
|
+
settingRegistry
|
|
64
|
+
.load(PLUGIN_ID)
|
|
65
|
+
.then(settings => {
|
|
66
|
+
const refresh = () => {
|
|
67
|
+
trackEnabled = settings.get('trackEditor').composite !== false;
|
|
68
|
+
};
|
|
69
|
+
refresh();
|
|
70
|
+
settings.changed.connect(refresh);
|
|
71
|
+
})
|
|
72
|
+
.catch(err => console.warn(`${LOG} could not load settings`, err));
|
|
73
|
+
}
|
|
23
74
|
// Lumino commands receive no DOM target, so a single capture-phase
|
|
24
75
|
// listener stashes the right-clicked node for both directions.
|
|
25
76
|
let lastPreviewTarget = null;
|
|
@@ -52,9 +103,132 @@ const plugin = {
|
|
|
52
103
|
};
|
|
53
104
|
/** The `.jp-RenderedMarkdown` host element inside a preview/editor widget. */
|
|
54
105
|
const renderedHost = (widget) => { var _a, _b; return (_b = (_a = widget === null || widget === void 0 ? void 0 : widget.node) === null || _a === void 0 ? void 0 : _a.querySelector('.jp-RenderedMarkdown')) !== null && _b !== void 0 ? _b : null; };
|
|
106
|
+
/** 0-based index of the first preview block whose bottom is below the host top. */
|
|
107
|
+
const previewTopOrdinal = (host) => {
|
|
108
|
+
const top = host.getBoundingClientRect().top;
|
|
109
|
+
const kids = Array.from(host.children);
|
|
110
|
+
for (let i = 0; i < kids.length; i++) {
|
|
111
|
+
if (kids[i].getBoundingClientRect().bottom > top + 4) {
|
|
112
|
+
return i;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return Math.max(0, kids.length - 1);
|
|
116
|
+
};
|
|
117
|
+
/** 0-based source line at the top of the editor viewport (CodeMirror view). */
|
|
118
|
+
const editorTopLine = (editor) => {
|
|
119
|
+
try {
|
|
120
|
+
const view = editor.editor; // CodeMirror EditorView
|
|
121
|
+
const info = view.lineBlockAtHeight(view.scrollDOM.scrollTop);
|
|
122
|
+
return view.state.doc.lineAt(info.from).number - 1;
|
|
123
|
+
}
|
|
124
|
+
catch (_a) {
|
|
125
|
+
return editor.getCursorPosition().line;
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
/**
|
|
129
|
+
* Scroll the editor so 0-based `line` sits at the top of the viewport.
|
|
130
|
+
* Uses CodeMirror's own scrollIntoView effect (y: 'start'), which scrolls
|
|
131
|
+
* on the measure cycle - correct even on a freshly opened editor whose line
|
|
132
|
+
* heights are not yet measured. Near the document end CodeMirror clamps, so
|
|
133
|
+
* the line sits as high as it can.
|
|
134
|
+
*/
|
|
135
|
+
const scrollEditorToTop = (editor, line) => {
|
|
136
|
+
const clamped = Math.max(0, Math.min(line, editor.lineCount - 1));
|
|
137
|
+
try {
|
|
138
|
+
const view = editor.editor;
|
|
139
|
+
const pos = view.state.doc.line(clamped + 1).from;
|
|
140
|
+
view.dispatch({
|
|
141
|
+
effects: EditorView.scrollIntoView(pos, { y: 'start' })
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
catch (_a) {
|
|
145
|
+
editor.revealPosition({ line: clamped, column: 0 });
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
/**
|
|
149
|
+
* Bidirectional scroll sync between a preview and its editor, established
|
|
150
|
+
* when the editor is opened via the command and `trackEditor` is on.
|
|
151
|
+
*
|
|
152
|
+
* The pane the user is interacting with (last pointer/wheel/focus) is the
|
|
153
|
+
* sole driver; the other pane only follows. This avoids the feedback loop
|
|
154
|
+
* where a follower's programmatic scroll would scroll the driver back, and
|
|
155
|
+
* guarantees the follower is resolved to the driver's exact line rather
|
|
156
|
+
* than nudged by a relative amount.
|
|
157
|
+
*/
|
|
158
|
+
const establishSync = (previewWidget, editorWidget) => {
|
|
159
|
+
if (editorWidget.__emacSynced) {
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
editorWidget.__emacSynced = true;
|
|
163
|
+
const editor = editorWidget.content.editor;
|
|
164
|
+
// The editor was just focused on open, so it drives first.
|
|
165
|
+
let driver = 'editor';
|
|
166
|
+
const claimEditor = () => {
|
|
167
|
+
driver = 'editor';
|
|
168
|
+
};
|
|
169
|
+
const claimPreview = () => {
|
|
170
|
+
driver = 'preview';
|
|
171
|
+
};
|
|
172
|
+
const onEditorScroll = () => {
|
|
173
|
+
if (driver !== 'editor') {
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
const host = renderedHost(previewWidget);
|
|
177
|
+
if (!host || previewWidget.isDisposed || editorWidget.isDisposed) {
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
const source = editorWidget.context.model.toString();
|
|
181
|
+
revealLineInPreview(host, source, editorTopLine(editor));
|
|
182
|
+
};
|
|
183
|
+
const onPreviewScroll = () => {
|
|
184
|
+
if (driver !== 'preview') {
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
const host = renderedHost(previewWidget);
|
|
188
|
+
if (!host || previewWidget.isDisposed || editorWidget.isDisposed) {
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
const source = editorWidget.context.model.toString();
|
|
192
|
+
const line = blockToLine(source, previewTopOrdinal(host));
|
|
193
|
+
if (line >= 0) {
|
|
194
|
+
scrollEditorToTop(editor, line);
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
// Pointer/wheel/focus on a pane (capture phase, before its scroll fires)
|
|
198
|
+
// makes it the driver. Scroll events do not bubble but are seen in
|
|
199
|
+
// capture, so one listener per widget node catches its inner scroller.
|
|
200
|
+
const claimOpts = { capture: true, passive: true };
|
|
201
|
+
const ed = editorWidget.node;
|
|
202
|
+
const pv = previewWidget.node;
|
|
203
|
+
ed.addEventListener('pointerdown', claimEditor, claimOpts);
|
|
204
|
+
ed.addEventListener('wheel', claimEditor, claimOpts);
|
|
205
|
+
ed.addEventListener('focusin', claimEditor, claimOpts);
|
|
206
|
+
pv.addEventListener('pointerdown', claimPreview, claimOpts);
|
|
207
|
+
pv.addEventListener('wheel', claimPreview, claimOpts);
|
|
208
|
+
pv.addEventListener('focusin', claimPreview, claimOpts);
|
|
209
|
+
ed.addEventListener('scroll', onEditorScroll, claimOpts);
|
|
210
|
+
pv.addEventListener('scroll', onPreviewScroll, claimOpts);
|
|
211
|
+
const cleanup = () => {
|
|
212
|
+
ed.removeEventListener('pointerdown', claimEditor, claimOpts);
|
|
213
|
+
ed.removeEventListener('wheel', claimEditor, claimOpts);
|
|
214
|
+
ed.removeEventListener('focusin', claimEditor, claimOpts);
|
|
215
|
+
pv.removeEventListener('pointerdown', claimPreview, claimOpts);
|
|
216
|
+
pv.removeEventListener('wheel', claimPreview, claimOpts);
|
|
217
|
+
pv.removeEventListener('focusin', claimPreview, claimOpts);
|
|
218
|
+
ed.removeEventListener('scroll', onEditorScroll, claimOpts);
|
|
219
|
+
pv.removeEventListener('scroll', onPreviewScroll, claimOpts);
|
|
220
|
+
editorWidget.__emacSynced = false;
|
|
221
|
+
};
|
|
222
|
+
editorWidget.disposed.connect(cleanup);
|
|
223
|
+
previewWidget.disposed.connect(cleanup);
|
|
224
|
+
};
|
|
55
225
|
// ---- Preview -> Editor -------------------------------------------------
|
|
226
|
+
// Labelled "Show Markdown Editor" to replace JupyterLab core's identically
|
|
227
|
+
// named command (`markdownviewer:edit`), which always opens the editor at
|
|
228
|
+
// line 0. The core context-menu item is disabled in `schema/plugin.json`,
|
|
229
|
+
// so this position-aware command is the only one shown.
|
|
56
230
|
app.commands.addCommand(CMD_EDIT_AT, {
|
|
57
|
-
label: '
|
|
231
|
+
label: 'Show Markdown Editor',
|
|
58
232
|
execute: async () => {
|
|
59
233
|
const target = lastPreviewTarget;
|
|
60
234
|
if (!target) {
|
|
@@ -87,7 +261,10 @@ const plugin = {
|
|
|
87
261
|
console.warn(`${LOG} block ordinal ${ordinal} is not mappable`);
|
|
88
262
|
return;
|
|
89
263
|
}
|
|
90
|
-
|
|
264
|
+
// Match core's `markdownviewer:edit`: open the editor split-right when
|
|
265
|
+
// it is not already open. When it is open (the side-by-side case), this
|
|
266
|
+
// just reveals the existing editor and we scroll it below.
|
|
267
|
+
const editorWidget = docManager.openOrReveal(widget.context.path, EDITOR_FACTORY, undefined, { mode: 'split-right' });
|
|
91
268
|
if (!editorWidget) {
|
|
92
269
|
return;
|
|
93
270
|
}
|
|
@@ -96,10 +273,15 @@ const plugin = {
|
|
|
96
273
|
const editor = editorWidget.content.editor;
|
|
97
274
|
const clamped = Math.min(line, editor.lineCount - 1);
|
|
98
275
|
editor.setCursorPosition({ line: clamped, column: 0 });
|
|
99
|
-
// Focus so the cursor is live (you asked to edit here)
|
|
100
|
-
// line
|
|
276
|
+
// Focus so the cursor is live (you asked to edit here), then scroll the
|
|
277
|
+
// line to the TOP of the viewport. Near the end of the document the
|
|
278
|
+
// browser clamps scrollTop, so the line sits as high as it can.
|
|
101
279
|
editor.focus();
|
|
102
|
-
editor
|
|
280
|
+
scrollEditorToTop(editor, clamped);
|
|
281
|
+
// Keep the two panes scrolled together from here on.
|
|
282
|
+
if (trackEnabled) {
|
|
283
|
+
establishSync(widget, editorWidget);
|
|
284
|
+
}
|
|
103
285
|
}
|
|
104
286
|
});
|
|
105
287
|
app.contextMenu.addItem({
|
|
@@ -111,7 +293,6 @@ const plugin = {
|
|
|
111
293
|
app.commands.addCommand(CMD_REVEAL_IN, {
|
|
112
294
|
label: 'Reveal in Markdown Preview',
|
|
113
295
|
execute: async () => {
|
|
114
|
-
var _a;
|
|
115
296
|
const target = lastEditorTarget;
|
|
116
297
|
if (!target) {
|
|
117
298
|
console.warn(`${LOG} no editor target under the cursor`);
|
|
@@ -125,7 +306,6 @@ const plugin = {
|
|
|
125
306
|
const editor = widget.content.editor;
|
|
126
307
|
const line = editor.getCursorPosition().line;
|
|
127
308
|
const source = widget.context.model.toString();
|
|
128
|
-
const { ordinal, headingSlug: slug, headingNth } = lineToBlock(source, line);
|
|
129
309
|
const previewWidget = docManager.openOrReveal(widget.context.path, PREVIEW_FACTORY);
|
|
130
310
|
if (!previewWidget) {
|
|
131
311
|
return;
|
|
@@ -137,35 +317,7 @@ const plugin = {
|
|
|
137
317
|
console.warn(`${LOG} rendered preview host not found`);
|
|
138
318
|
return;
|
|
139
319
|
}
|
|
140
|
-
|
|
141
|
-
const expected = buildBlockMap(source).blocks.length;
|
|
142
|
-
if (children.length === expected && ordinal >= 0) {
|
|
143
|
-
children[ordinal].scrollIntoView({ block: 'start' });
|
|
144
|
-
return;
|
|
145
|
-
}
|
|
146
|
-
// Fallback (AC #8): ordinal alignment is unreliable (math, sanitizer,
|
|
147
|
-
// injected nodes). Re-derive heading ids from the live headings and
|
|
148
|
-
// scroll to the headingNth-th match.
|
|
149
|
-
if (slug) {
|
|
150
|
-
const headings = Array.from(host.querySelectorAll('h1, h2, h3, h4, h5, h6'));
|
|
151
|
-
let seen = 0;
|
|
152
|
-
for (const h of headings) {
|
|
153
|
-
// rendermime stores createHeaderId in `id` (trusted) or
|
|
154
|
-
// `data-jupyter-id` (untrusted); the live textContent also contains
|
|
155
|
-
// the appended '¶' anchor, so prefer the stored attribute.
|
|
156
|
-
const id = h.id ||
|
|
157
|
-
h.getAttribute('data-jupyter-id') ||
|
|
158
|
-
headingSlug((_a = h.textContent) !== null && _a !== void 0 ? _a : '');
|
|
159
|
-
if (id === slug) {
|
|
160
|
-
seen += 1;
|
|
161
|
-
if (seen === (headingNth !== null && headingNth !== void 0 ? headingNth : 1)) {
|
|
162
|
-
h.scrollIntoView({ block: 'start' });
|
|
163
|
-
return;
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
console.warn(`${LOG} could not align preview to line ${line}`);
|
|
320
|
+
revealLineInPreview(host, source, line);
|
|
169
321
|
}
|
|
170
322
|
});
|
|
171
323
|
app.contextMenu.addItem({
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jupyterlab_edit_markdown_at_content_extension",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.7",
|
|
4
4
|
"description": "Jupyterlab extension to save you the scrolling time from when you are at markdown file location and open editor and need to scroll to the exact place in the file where the content is. This extension opens the editor at the place where the content is",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"jupyter",
|
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
"files": [
|
|
20
20
|
"lib/**/*.{d.ts,eot,gif,html,jpg,js,js.map,json,png,svg,woff2,ttf}",
|
|
21
21
|
"style/**/*.{css,js,eot,gif,html,jpg,json,png,svg,woff2,ttf}",
|
|
22
|
-
"src/**/*.{ts,tsx}"
|
|
22
|
+
"src/**/*.{ts,tsx}",
|
|
23
|
+
"schema/*.json"
|
|
23
24
|
],
|
|
24
25
|
"main": "lib/index.js",
|
|
25
26
|
"types": "lib/index.d.ts",
|
|
@@ -56,12 +57,14 @@
|
|
|
56
57
|
"watch:labextension": "jupyter labextension watch ."
|
|
57
58
|
},
|
|
58
59
|
"dependencies": {
|
|
60
|
+
"@codemirror/view": "^6.26.0",
|
|
59
61
|
"@jupyterlab/application": "^4.5.0",
|
|
60
62
|
"@jupyterlab/codeeditor": "^4.5.0",
|
|
61
63
|
"@jupyterlab/docmanager": "^4.5.0",
|
|
62
64
|
"@jupyterlab/docregistry": "^4.5.0",
|
|
63
65
|
"@jupyterlab/fileeditor": "^4.5.0",
|
|
64
66
|
"@jupyterlab/markdownviewer": "^4.5.0",
|
|
67
|
+
"@jupyterlab/settingregistry": "^4.5.0",
|
|
65
68
|
"marked": "^17.0.0"
|
|
66
69
|
},
|
|
67
70
|
"devDependencies": {
|
|
@@ -111,7 +114,8 @@
|
|
|
111
114
|
},
|
|
112
115
|
"jupyterlab": {
|
|
113
116
|
"extension": true,
|
|
114
|
-
"outputDir": "jupyterlab_edit_markdown_at_content_extension/labextension"
|
|
117
|
+
"outputDir": "jupyterlab_edit_markdown_at_content_extension/labextension",
|
|
118
|
+
"schemaDir": "schema"
|
|
115
119
|
},
|
|
116
120
|
"eslintIgnore": [
|
|
117
121
|
"node_modules",
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"title": "Edit Markdown at Content",
|
|
3
|
+
"description": "Settings for the edit-markdown-at-content extension.",
|
|
4
|
+
"jupyter.lab.menus": {
|
|
5
|
+
"context": [
|
|
6
|
+
{
|
|
7
|
+
"command": "markdownviewer:edit",
|
|
8
|
+
"selector": ".jp-RenderedMarkdown",
|
|
9
|
+
"disabled": true
|
|
10
|
+
}
|
|
11
|
+
]
|
|
12
|
+
},
|
|
13
|
+
"properties": {
|
|
14
|
+
"trackEditor": {
|
|
15
|
+
"title": "Track editor and preview together",
|
|
16
|
+
"description": "When the editor is opened from the preview via \"Show Markdown Editor\", keep the editor and the Markdown Preview scrolled to the same location: scrolling either pane scrolls the other.",
|
|
17
|
+
"type": "boolean",
|
|
18
|
+
"default": true
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"additionalProperties": false,
|
|
22
|
+
"type": "object"
|
|
23
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -9,6 +9,10 @@ import { IEditorTracker } from '@jupyterlab/fileeditor';
|
|
|
9
9
|
|
|
10
10
|
import { IMarkdownViewerTracker } from '@jupyterlab/markdownviewer';
|
|
11
11
|
|
|
12
|
+
import { ISettingRegistry } from '@jupyterlab/settingregistry';
|
|
13
|
+
|
|
14
|
+
import { EditorView } from '@codemirror/view';
|
|
15
|
+
|
|
12
16
|
import {
|
|
13
17
|
buildBlockMap,
|
|
14
18
|
blockToLine,
|
|
@@ -27,6 +31,45 @@ const PREVIEW_FACTORY = 'Markdown Preview';
|
|
|
27
31
|
|
|
28
32
|
const LOG = '[edit-markdown-at-content]';
|
|
29
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Scroll a rendered preview `host` so the block that produced source `line` is
|
|
36
|
+
* at the top. Tries ordinal alignment first; falls back to matching the
|
|
37
|
+
* nearest-preceding heading by its createHeaderId slug when the rendered child
|
|
38
|
+
* count diverges from the lexed block count (math, sanitizer, injected nodes).
|
|
39
|
+
*/
|
|
40
|
+
function revealLineInPreview(
|
|
41
|
+
host: HTMLElement,
|
|
42
|
+
source: string,
|
|
43
|
+
line: number
|
|
44
|
+
): void {
|
|
45
|
+
const { ordinal, headingSlug: slug, headingNth } = lineToBlock(source, line);
|
|
46
|
+
const children = Array.from(host.children);
|
|
47
|
+
const expected = buildBlockMap(source).blocks.length;
|
|
48
|
+
if (children.length === expected && ordinal >= 0) {
|
|
49
|
+
children[ordinal].scrollIntoView({ block: 'start' });
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (slug) {
|
|
53
|
+
const headings = Array.from(
|
|
54
|
+
host.querySelectorAll('h1, h2, h3, h4, h5, h6')
|
|
55
|
+
);
|
|
56
|
+
let seen = 0;
|
|
57
|
+
for (const h of headings) {
|
|
58
|
+
const id =
|
|
59
|
+
(h as HTMLElement).id ||
|
|
60
|
+
h.getAttribute('data-jupyter-id') ||
|
|
61
|
+
headingSlug(h.textContent ?? '');
|
|
62
|
+
if (id === slug) {
|
|
63
|
+
seen += 1;
|
|
64
|
+
if (seen === (headingNth ?? 1)) {
|
|
65
|
+
h.scrollIntoView({ block: 'start' });
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
30
73
|
/**
|
|
31
74
|
* Initialization data for the jupyterlab_edit_markdown_at_content_extension extension.
|
|
32
75
|
*/
|
|
@@ -36,16 +79,35 @@ const plugin: JupyterFrontEndPlugin<void> = {
|
|
|
36
79
|
'Jupyterlab extension to save you the scrolling time from when you are at markdown file location and open editor and need to scroll to the exact place in the file where the content is. This extension opens the editor at the place where the content is',
|
|
37
80
|
autoStart: true,
|
|
38
81
|
requires: [IDocumentManager, IEditorTracker, IMarkdownViewerTracker],
|
|
82
|
+
optional: [ISettingRegistry],
|
|
39
83
|
activate: (
|
|
40
84
|
app: JupyterFrontEnd,
|
|
41
85
|
docManager: IDocumentManager,
|
|
42
86
|
editorTracker: IEditorTracker,
|
|
43
|
-
markdownTracker: IMarkdownViewerTracker
|
|
87
|
+
markdownTracker: IMarkdownViewerTracker,
|
|
88
|
+
settingRegistry: ISettingRegistry | null
|
|
44
89
|
) => {
|
|
45
90
|
console.log(
|
|
46
91
|
'JupyterLab extension jupyterlab_edit_markdown_at_content_extension is activated!'
|
|
47
92
|
);
|
|
48
93
|
|
|
94
|
+
// `trackEditor` (default true): keep the editor and preview scrolled
|
|
95
|
+
// together once the editor is opened via the command. Read from settings;
|
|
96
|
+
// defaults to enabled when no setting registry is available.
|
|
97
|
+
let trackEnabled = true;
|
|
98
|
+
if (settingRegistry) {
|
|
99
|
+
settingRegistry
|
|
100
|
+
.load(PLUGIN_ID)
|
|
101
|
+
.then(settings => {
|
|
102
|
+
const refresh = () => {
|
|
103
|
+
trackEnabled = settings.get('trackEditor').composite !== false;
|
|
104
|
+
};
|
|
105
|
+
refresh();
|
|
106
|
+
settings.changed.connect(refresh);
|
|
107
|
+
})
|
|
108
|
+
.catch(err => console.warn(`${LOG} could not load settings`, err));
|
|
109
|
+
}
|
|
110
|
+
|
|
49
111
|
// Lumino commands receive no DOM target, so a single capture-phase
|
|
50
112
|
// listener stashes the right-clicked node for both directions.
|
|
51
113
|
let lastPreviewTarget: Element | null = null;
|
|
@@ -87,9 +149,140 @@ const plugin: JupyterFrontEndPlugin<void> = {
|
|
|
87
149
|
const renderedHost = (widget: any): HTMLElement | null =>
|
|
88
150
|
widget?.node?.querySelector('.jp-RenderedMarkdown') ?? null;
|
|
89
151
|
|
|
152
|
+
/** 0-based index of the first preview block whose bottom is below the host top. */
|
|
153
|
+
const previewTopOrdinal = (host: HTMLElement): number => {
|
|
154
|
+
const top = host.getBoundingClientRect().top;
|
|
155
|
+
const kids = Array.from(host.children);
|
|
156
|
+
for (let i = 0; i < kids.length; i++) {
|
|
157
|
+
if (kids[i].getBoundingClientRect().bottom > top + 4) {
|
|
158
|
+
return i;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return Math.max(0, kids.length - 1);
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
/** 0-based source line at the top of the editor viewport (CodeMirror view). */
|
|
165
|
+
const editorTopLine = (editor: any): number => {
|
|
166
|
+
try {
|
|
167
|
+
const view = editor.editor; // CodeMirror EditorView
|
|
168
|
+
const info = view.lineBlockAtHeight(view.scrollDOM.scrollTop);
|
|
169
|
+
return view.state.doc.lineAt(info.from).number - 1;
|
|
170
|
+
} catch {
|
|
171
|
+
return editor.getCursorPosition().line;
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Scroll the editor so 0-based `line` sits at the top of the viewport.
|
|
177
|
+
* Uses CodeMirror's own scrollIntoView effect (y: 'start'), which scrolls
|
|
178
|
+
* on the measure cycle - correct even on a freshly opened editor whose line
|
|
179
|
+
* heights are not yet measured. Near the document end CodeMirror clamps, so
|
|
180
|
+
* the line sits as high as it can.
|
|
181
|
+
*/
|
|
182
|
+
const scrollEditorToTop = (editor: any, line: number): void => {
|
|
183
|
+
const clamped = Math.max(0, Math.min(line, editor.lineCount - 1));
|
|
184
|
+
try {
|
|
185
|
+
const view = editor.editor as EditorView;
|
|
186
|
+
const pos = view.state.doc.line(clamped + 1).from;
|
|
187
|
+
view.dispatch({
|
|
188
|
+
effects: EditorView.scrollIntoView(pos, { y: 'start' })
|
|
189
|
+
});
|
|
190
|
+
} catch {
|
|
191
|
+
editor.revealPosition({ line: clamped, column: 0 });
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Bidirectional scroll sync between a preview and its editor, established
|
|
197
|
+
* when the editor is opened via the command and `trackEditor` is on.
|
|
198
|
+
*
|
|
199
|
+
* The pane the user is interacting with (last pointer/wheel/focus) is the
|
|
200
|
+
* sole driver; the other pane only follows. This avoids the feedback loop
|
|
201
|
+
* where a follower's programmatic scroll would scroll the driver back, and
|
|
202
|
+
* guarantees the follower is resolved to the driver's exact line rather
|
|
203
|
+
* than nudged by a relative amount.
|
|
204
|
+
*/
|
|
205
|
+
const establishSync = (previewWidget: any, editorWidget: any): void => {
|
|
206
|
+
if (editorWidget.__emacSynced) {
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
editorWidget.__emacSynced = true;
|
|
210
|
+
|
|
211
|
+
const editor = editorWidget.content.editor;
|
|
212
|
+
// The editor was just focused on open, so it drives first.
|
|
213
|
+
let driver: 'editor' | 'preview' = 'editor';
|
|
214
|
+
|
|
215
|
+
const claimEditor = () => {
|
|
216
|
+
driver = 'editor';
|
|
217
|
+
};
|
|
218
|
+
const claimPreview = () => {
|
|
219
|
+
driver = 'preview';
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const onEditorScroll = () => {
|
|
223
|
+
if (driver !== 'editor') {
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
const host = renderedHost(previewWidget);
|
|
227
|
+
if (!host || previewWidget.isDisposed || editorWidget.isDisposed) {
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
const source = editorWidget.context.model.toString();
|
|
231
|
+
revealLineInPreview(host, source, editorTopLine(editor));
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
const onPreviewScroll = () => {
|
|
235
|
+
if (driver !== 'preview') {
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
const host = renderedHost(previewWidget);
|
|
239
|
+
if (!host || previewWidget.isDisposed || editorWidget.isDisposed) {
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
const source = editorWidget.context.model.toString();
|
|
243
|
+
const line = blockToLine(source, previewTopOrdinal(host));
|
|
244
|
+
if (line >= 0) {
|
|
245
|
+
scrollEditorToTop(editor, line);
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
// Pointer/wheel/focus on a pane (capture phase, before its scroll fires)
|
|
250
|
+
// makes it the driver. Scroll events do not bubble but are seen in
|
|
251
|
+
// capture, so one listener per widget node catches its inner scroller.
|
|
252
|
+
const claimOpts = { capture: true, passive: true } as const;
|
|
253
|
+
const ed = editorWidget.node;
|
|
254
|
+
const pv = previewWidget.node;
|
|
255
|
+
ed.addEventListener('pointerdown', claimEditor, claimOpts);
|
|
256
|
+
ed.addEventListener('wheel', claimEditor, claimOpts);
|
|
257
|
+
ed.addEventListener('focusin', claimEditor, claimOpts);
|
|
258
|
+
pv.addEventListener('pointerdown', claimPreview, claimOpts);
|
|
259
|
+
pv.addEventListener('wheel', claimPreview, claimOpts);
|
|
260
|
+
pv.addEventListener('focusin', claimPreview, claimOpts);
|
|
261
|
+
ed.addEventListener('scroll', onEditorScroll, claimOpts);
|
|
262
|
+
pv.addEventListener('scroll', onPreviewScroll, claimOpts);
|
|
263
|
+
|
|
264
|
+
const cleanup = () => {
|
|
265
|
+
ed.removeEventListener('pointerdown', claimEditor, claimOpts as any);
|
|
266
|
+
ed.removeEventListener('wheel', claimEditor, claimOpts as any);
|
|
267
|
+
ed.removeEventListener('focusin', claimEditor, claimOpts as any);
|
|
268
|
+
pv.removeEventListener('pointerdown', claimPreview, claimOpts as any);
|
|
269
|
+
pv.removeEventListener('wheel', claimPreview, claimOpts as any);
|
|
270
|
+
pv.removeEventListener('focusin', claimPreview, claimOpts as any);
|
|
271
|
+
ed.removeEventListener('scroll', onEditorScroll, claimOpts as any);
|
|
272
|
+
pv.removeEventListener('scroll', onPreviewScroll, claimOpts as any);
|
|
273
|
+
editorWidget.__emacSynced = false;
|
|
274
|
+
};
|
|
275
|
+
editorWidget.disposed.connect(cleanup);
|
|
276
|
+
previewWidget.disposed.connect(cleanup);
|
|
277
|
+
};
|
|
278
|
+
|
|
90
279
|
// ---- Preview -> Editor -------------------------------------------------
|
|
280
|
+
// Labelled "Show Markdown Editor" to replace JupyterLab core's identically
|
|
281
|
+
// named command (`markdownviewer:edit`), which always opens the editor at
|
|
282
|
+
// line 0. The core context-menu item is disabled in `schema/plugin.json`,
|
|
283
|
+
// so this position-aware command is the only one shown.
|
|
91
284
|
app.commands.addCommand(CMD_EDIT_AT, {
|
|
92
|
-
label: '
|
|
285
|
+
label: 'Show Markdown Editor',
|
|
93
286
|
execute: async () => {
|
|
94
287
|
const target = lastPreviewTarget;
|
|
95
288
|
if (!target) {
|
|
@@ -124,9 +317,14 @@ const plugin: JupyterFrontEndPlugin<void> = {
|
|
|
124
317
|
return;
|
|
125
318
|
}
|
|
126
319
|
|
|
320
|
+
// Match core's `markdownviewer:edit`: open the editor split-right when
|
|
321
|
+
// it is not already open. When it is open (the side-by-side case), this
|
|
322
|
+
// just reveals the existing editor and we scroll it below.
|
|
127
323
|
const editorWidget: any = docManager.openOrReveal(
|
|
128
324
|
widget.context.path,
|
|
129
|
-
EDITOR_FACTORY
|
|
325
|
+
EDITOR_FACTORY,
|
|
326
|
+
undefined,
|
|
327
|
+
{ mode: 'split-right' }
|
|
130
328
|
);
|
|
131
329
|
if (!editorWidget) {
|
|
132
330
|
return;
|
|
@@ -136,10 +334,16 @@ const plugin: JupyterFrontEndPlugin<void> = {
|
|
|
136
334
|
const editor = editorWidget.content.editor;
|
|
137
335
|
const clamped = Math.min(line, editor.lineCount - 1);
|
|
138
336
|
editor.setCursorPosition({ line: clamped, column: 0 });
|
|
139
|
-
// Focus so the cursor is live (you asked to edit here)
|
|
140
|
-
// line
|
|
337
|
+
// Focus so the cursor is live (you asked to edit here), then scroll the
|
|
338
|
+
// line to the TOP of the viewport. Near the end of the document the
|
|
339
|
+
// browser clamps scrollTop, so the line sits as high as it can.
|
|
141
340
|
editor.focus();
|
|
142
|
-
editor
|
|
341
|
+
scrollEditorToTop(editor, clamped);
|
|
342
|
+
|
|
343
|
+
// Keep the two panes scrolled together from here on.
|
|
344
|
+
if (trackEnabled) {
|
|
345
|
+
establishSync(widget, editorWidget);
|
|
346
|
+
}
|
|
143
347
|
}
|
|
144
348
|
});
|
|
145
349
|
app.contextMenu.addItem({
|
|
@@ -165,11 +369,6 @@ const plugin: JupyterFrontEndPlugin<void> = {
|
|
|
165
369
|
const editor = widget.content.editor;
|
|
166
370
|
const line = editor.getCursorPosition().line;
|
|
167
371
|
const source: string = widget.context.model.toString();
|
|
168
|
-
const {
|
|
169
|
-
ordinal,
|
|
170
|
-
headingSlug: slug,
|
|
171
|
-
headingNth
|
|
172
|
-
} = lineToBlock(source, line);
|
|
173
372
|
|
|
174
373
|
const previewWidget: any = docManager.openOrReveal(
|
|
175
374
|
widget.context.path,
|
|
@@ -185,40 +384,7 @@ const plugin: JupyterFrontEndPlugin<void> = {
|
|
|
185
384
|
console.warn(`${LOG} rendered preview host not found`);
|
|
186
385
|
return;
|
|
187
386
|
}
|
|
188
|
-
|
|
189
|
-
const children = Array.from(host.children);
|
|
190
|
-
const expected = buildBlockMap(source).blocks.length;
|
|
191
|
-
if (children.length === expected && ordinal >= 0) {
|
|
192
|
-
children[ordinal].scrollIntoView({ block: 'start' });
|
|
193
|
-
return;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
// Fallback (AC #8): ordinal alignment is unreliable (math, sanitizer,
|
|
197
|
-
// injected nodes). Re-derive heading ids from the live headings and
|
|
198
|
-
// scroll to the headingNth-th match.
|
|
199
|
-
if (slug) {
|
|
200
|
-
const headings = Array.from(
|
|
201
|
-
host.querySelectorAll('h1, h2, h3, h4, h5, h6')
|
|
202
|
-
);
|
|
203
|
-
let seen = 0;
|
|
204
|
-
for (const h of headings) {
|
|
205
|
-
// rendermime stores createHeaderId in `id` (trusted) or
|
|
206
|
-
// `data-jupyter-id` (untrusted); the live textContent also contains
|
|
207
|
-
// the appended '¶' anchor, so prefer the stored attribute.
|
|
208
|
-
const id =
|
|
209
|
-
(h as HTMLElement).id ||
|
|
210
|
-
h.getAttribute('data-jupyter-id') ||
|
|
211
|
-
headingSlug(h.textContent ?? '');
|
|
212
|
-
if (id === slug) {
|
|
213
|
-
seen += 1;
|
|
214
|
-
if (seen === (headingNth ?? 1)) {
|
|
215
|
-
h.scrollIntoView({ block: 'start' });
|
|
216
|
-
return;
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
console.warn(`${LOG} could not align preview to line ${line}`);
|
|
387
|
+
revealLineInPreview(host, source, line);
|
|
222
388
|
}
|
|
223
389
|
});
|
|
224
390
|
app.contextMenu.addItem({
|