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 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
- - **Edit at content location** - open the editor scrolled to the line matching the rendered content you are viewing
16
- - **No-scroll workflow** - skips the manual hunt for the right line after switching from preview to editor
17
- - **Server-side support** - a Python `jupyter_server` extension backs the frontend with the routes it needs
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
- activate: (app, docManager, editorTracker, markdownTracker) => {
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: 'Edit at this location',
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
- const editorWidget = docManager.openOrReveal(widget.context.path, EDITOR_FACTORY);
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) and the active
100
- // line is rendered, then scroll it into view.
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.revealPosition({ line: clamped, column: 0 });
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
- const children = Array.from(host.children);
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.4.3",
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: 'Edit at this location',
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) and the active
140
- // line is rendered, then scroll it into view.
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.revealPosition({ line: clamped, column: 0 });
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({