jupyterlab_edit_markdown_at_content_extension 0.4.3
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/LICENSE +29 -0
- package/README.md +33 -0
- package/lib/index.d.ts +6 -0
- package/lib/index.js +178 -0
- package/lib/mapping.d.ts +53 -0
- package/lib/mapping.js +143 -0
- package/package.json +210 -0
- package/src/__tests__/index.spec.ts +32 -0
- package/src/__tests__/mapping.spec.ts +122 -0
- package/src/index.ts +232 -0
- package/src/mapping.ts +190 -0
- package/style/base.css +5 -0
- package/style/index.css +1 -0
- package/style/index.js +1 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, Stellars Henson
|
|
4
|
+
All rights reserved.
|
|
5
|
+
|
|
6
|
+
Redistribution and use in source and binary forms, with or without
|
|
7
|
+
modification, are permitted provided that the following conditions are met:
|
|
8
|
+
|
|
9
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
10
|
+
list of conditions and the following disclaimer.
|
|
11
|
+
|
|
12
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
13
|
+
this list of conditions and the following disclaimer in the documentation
|
|
14
|
+
and/or other materials provided with the distribution.
|
|
15
|
+
|
|
16
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
17
|
+
contributors may be used to endorse or promote products derived from
|
|
18
|
+
this software without specific prior written permission.
|
|
19
|
+
|
|
20
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
21
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
22
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
23
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
24
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
25
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
26
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
27
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
28
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
29
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
package/README.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# jupyterlab_edit_markdown_at_content_extension
|
|
2
|
+
|
|
3
|
+
[](https://github.com/stellarshenson/jupyterlab_edit_markdown_at_content_extension/actions/workflows/build.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/jupyterlab_edit_markdown_at_content_extension)
|
|
5
|
+
[](https://pypi.org/project/jupyterlab-edit-markdown-at-content-extension/)
|
|
6
|
+
[](https://pepy.tech/project/jupyterlab-edit-markdown-at-content-extension)
|
|
7
|
+
[](https://jupyterlab.readthedocs.io/en/stable/)
|
|
8
|
+
[](https://kolomolo.com)
|
|
9
|
+
[](https://www.paypal.com/donate/?hosted_button_id=B4KPBJDLLXTSA)
|
|
10
|
+
|
|
11
|
+
Jump straight from a rendered markdown file into the editor at the exact line you were reading. No more opening the editor and scrolling to find the content again - this extension opens the editor positioned right where the content is.
|
|
12
|
+
|
|
13
|
+
## Features
|
|
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
|
|
18
|
+
|
|
19
|
+
## Requirements
|
|
20
|
+
|
|
21
|
+
- JupyterLab >= 4.0.0
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install jupyterlab_edit_markdown_at_content_extension
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Uninstall
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip uninstall jupyterlab_edit_markdown_at_content_extension
|
|
33
|
+
```
|
package/lib/index.d.ts
ADDED
package/lib/index.js
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { IDocumentManager } from '@jupyterlab/docmanager';
|
|
2
|
+
import { IEditorTracker } from '@jupyterlab/fileeditor';
|
|
3
|
+
import { IMarkdownViewerTracker } from '@jupyterlab/markdownviewer';
|
|
4
|
+
import { buildBlockMap, blockToLine, lineToBlock, headingSlug } from './mapping';
|
|
5
|
+
const PLUGIN_ID = 'jupyterlab_edit_markdown_at_content_extension:plugin';
|
|
6
|
+
const CMD_EDIT_AT = 'editmarkdownatcontent:edit-at-location';
|
|
7
|
+
const CMD_REVEAL_IN = 'editmarkdownatcontent:reveal-in-preview';
|
|
8
|
+
const PREVIEW_SELECTOR = '.jp-MarkdownViewer .jp-RenderedMarkdown';
|
|
9
|
+
const EDITOR_SELECTOR = '.jp-FileEditor';
|
|
10
|
+
const EDITOR_FACTORY = 'Editor';
|
|
11
|
+
const PREVIEW_FACTORY = 'Markdown Preview';
|
|
12
|
+
const LOG = '[edit-markdown-at-content]';
|
|
13
|
+
/**
|
|
14
|
+
* Initialization data for the jupyterlab_edit_markdown_at_content_extension extension.
|
|
15
|
+
*/
|
|
16
|
+
const plugin = {
|
|
17
|
+
id: PLUGIN_ID,
|
|
18
|
+
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
|
+
autoStart: true,
|
|
20
|
+
requires: [IDocumentManager, IEditorTracker, IMarkdownViewerTracker],
|
|
21
|
+
activate: (app, docManager, editorTracker, markdownTracker) => {
|
|
22
|
+
console.log('JupyterLab extension jupyterlab_edit_markdown_at_content_extension is activated!');
|
|
23
|
+
// Lumino commands receive no DOM target, so a single capture-phase
|
|
24
|
+
// listener stashes the right-clicked node for both directions.
|
|
25
|
+
let lastPreviewTarget = null;
|
|
26
|
+
let lastEditorTarget = null;
|
|
27
|
+
document.addEventListener('contextmenu', (event) => {
|
|
28
|
+
var _a, _b;
|
|
29
|
+
const target = event.target;
|
|
30
|
+
lastPreviewTarget = ((_a = target === null || target === void 0 ? void 0 : target.closest) === null || _a === void 0 ? void 0 : _a.call(target, PREVIEW_SELECTOR)) ? target : null;
|
|
31
|
+
lastEditorTarget = ((_b = target === null || target === void 0 ? void 0 : target.closest) === null || _b === void 0 ? void 0 : _b.call(target, EDITOR_SELECTOR)) ? target : null;
|
|
32
|
+
}, true);
|
|
33
|
+
/** The MarkdownDocument widget whose rendered host contains `target`. */
|
|
34
|
+
const findPreviewWidget = (target) => {
|
|
35
|
+
let found = null;
|
|
36
|
+
markdownTracker.forEach(widget => {
|
|
37
|
+
if (!found && widget.node.contains(target)) {
|
|
38
|
+
found = widget;
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
return found;
|
|
42
|
+
};
|
|
43
|
+
/** The FileEditor document widget whose node contains `target`. */
|
|
44
|
+
const findEditorWidget = (target) => {
|
|
45
|
+
let found = null;
|
|
46
|
+
editorTracker.forEach(widget => {
|
|
47
|
+
if (!found && widget.node.contains(target)) {
|
|
48
|
+
found = widget;
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
return found !== null && found !== void 0 ? found : editorTracker.currentWidget;
|
|
52
|
+
};
|
|
53
|
+
/** The `.jp-RenderedMarkdown` host element inside a preview/editor widget. */
|
|
54
|
+
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; };
|
|
55
|
+
// ---- Preview -> Editor -------------------------------------------------
|
|
56
|
+
app.commands.addCommand(CMD_EDIT_AT, {
|
|
57
|
+
label: 'Edit at this location',
|
|
58
|
+
execute: async () => {
|
|
59
|
+
const target = lastPreviewTarget;
|
|
60
|
+
if (!target) {
|
|
61
|
+
console.warn(`${LOG} no markdown preview target under the cursor`);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const widget = findPreviewWidget(target);
|
|
65
|
+
if (!widget) {
|
|
66
|
+
console.warn(`${LOG} could not resolve the owning Markdown Preview`);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const host = renderedHost(widget);
|
|
70
|
+
if (!host) {
|
|
71
|
+
console.warn(`${LOG} rendered host not found`);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
// Walk up to the top-level block (direct child of the host).
|
|
75
|
+
let block = target;
|
|
76
|
+
while (block && block.parentElement !== host) {
|
|
77
|
+
block = block.parentElement;
|
|
78
|
+
}
|
|
79
|
+
if (!block) {
|
|
80
|
+
console.warn(`${LOG} clicked content is not a rendered block`);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const ordinal = Array.from(host.children).indexOf(block);
|
|
84
|
+
const source = widget.context.model.toString();
|
|
85
|
+
const line = blockToLine(source, ordinal);
|
|
86
|
+
if (line < 0) {
|
|
87
|
+
console.warn(`${LOG} block ordinal ${ordinal} is not mappable`);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const editorWidget = docManager.openOrReveal(widget.context.path, EDITOR_FACTORY);
|
|
91
|
+
if (!editorWidget) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
await editorWidget.context.ready;
|
|
95
|
+
await editorWidget.revealed;
|
|
96
|
+
const editor = editorWidget.content.editor;
|
|
97
|
+
const clamped = Math.min(line, editor.lineCount - 1);
|
|
98
|
+
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.
|
|
101
|
+
editor.focus();
|
|
102
|
+
editor.revealPosition({ line: clamped, column: 0 });
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
app.contextMenu.addItem({
|
|
106
|
+
command: CMD_EDIT_AT,
|
|
107
|
+
selector: PREVIEW_SELECTOR,
|
|
108
|
+
rank: 0
|
|
109
|
+
});
|
|
110
|
+
// ---- Editor -> Preview -------------------------------------------------
|
|
111
|
+
app.commands.addCommand(CMD_REVEAL_IN, {
|
|
112
|
+
label: 'Reveal in Markdown Preview',
|
|
113
|
+
execute: async () => {
|
|
114
|
+
var _a;
|
|
115
|
+
const target = lastEditorTarget;
|
|
116
|
+
if (!target) {
|
|
117
|
+
console.warn(`${LOG} no editor target under the cursor`);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const widget = findEditorWidget(target);
|
|
121
|
+
if (!widget) {
|
|
122
|
+
console.warn(`${LOG} could not resolve the owning file editor`);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const editor = widget.content.editor;
|
|
126
|
+
const line = editor.getCursorPosition().line;
|
|
127
|
+
const source = widget.context.model.toString();
|
|
128
|
+
const { ordinal, headingSlug: slug, headingNth } = lineToBlock(source, line);
|
|
129
|
+
const previewWidget = docManager.openOrReveal(widget.context.path, PREVIEW_FACTORY);
|
|
130
|
+
if (!previewWidget) {
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
await previewWidget.context.ready;
|
|
134
|
+
await previewWidget.revealed;
|
|
135
|
+
const host = renderedHost(previewWidget);
|
|
136
|
+
if (!host) {
|
|
137
|
+
console.warn(`${LOG} rendered preview host not found`);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
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}`);
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
app.contextMenu.addItem({
|
|
172
|
+
command: CMD_REVEAL_IN,
|
|
173
|
+
selector: EDITOR_SELECTOR,
|
|
174
|
+
rank: 0
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
export default plugin;
|
package/lib/mapping.d.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/** One rendered top-level block, ordinal-aligned to the rendered host's children. */
|
|
2
|
+
export interface IBlockDescriptor {
|
|
3
|
+
/** Index into the DOM-correlated block list == rendered host child index. */
|
|
4
|
+
ordinal: number;
|
|
5
|
+
/** 0-based first source line of the block. */
|
|
6
|
+
startLine: number;
|
|
7
|
+
/** 0-based last source line of the block (inclusive of trailing blanks). */
|
|
8
|
+
endLine: number;
|
|
9
|
+
/** marked token type (heading, paragraph, code, list, table, ...). */
|
|
10
|
+
type: string;
|
|
11
|
+
/** Normalized block text, for the empty-block guard. */
|
|
12
|
+
text: string;
|
|
13
|
+
/** Present only for heading tokens: createHeaderId form of the heading text. */
|
|
14
|
+
headingSlug?: string;
|
|
15
|
+
}
|
|
16
|
+
export interface IBlockMap {
|
|
17
|
+
/** Rendered (non-space/def/comment) tokens, in DOM order. */
|
|
18
|
+
blocks: IBlockDescriptor[];
|
|
19
|
+
/** Heading blocks, for the nearest-preceding-heading fallback. */
|
|
20
|
+
headings: {
|
|
21
|
+
line: number;
|
|
22
|
+
slug: string;
|
|
23
|
+
ordinal: number;
|
|
24
|
+
}[];
|
|
25
|
+
}
|
|
26
|
+
/** Reproduce rendermime `createHeaderId`: spaces -> hyphens on the plain text. */
|
|
27
|
+
export declare function headingSlug(headingText: string): string;
|
|
28
|
+
/**
|
|
29
|
+
* Re-lex `source` and build the ordinal<->line map.
|
|
30
|
+
*
|
|
31
|
+
* Uses `marked.lexer(source, { gfm: true })` to match core's parser config.
|
|
32
|
+
* The line walk includes EVERY top-level token (space/def advance the
|
|
33
|
+
* counter); the DOM-aligned block list excludes space, def and comment-only
|
|
34
|
+
* html tokens.
|
|
35
|
+
*/
|
|
36
|
+
export declare function buildBlockMap(source: string): IBlockMap;
|
|
37
|
+
/**
|
|
38
|
+
* Preview -> Editor. Returns the 0-based start line for the block at DOM
|
|
39
|
+
* ordinal `ordinal`, or -1 when the ordinal is out of range or the block has
|
|
40
|
+
* no textual content (caller no-ops + warns, AC #5).
|
|
41
|
+
*/
|
|
42
|
+
export declare function blockToLine(source: string, ordinal: number): number;
|
|
43
|
+
/**
|
|
44
|
+
* Editor -> Preview. Returns the DOM ordinal of the block containing `line`
|
|
45
|
+
* (the last block whose startLine <= line), plus the nearest-preceding
|
|
46
|
+
* heading slug and its 1-based occurrence index for the fallback.
|
|
47
|
+
* `ordinal` is -1 when no block precedes the line.
|
|
48
|
+
*/
|
|
49
|
+
export declare function lineToBlock(source: string, line: number): {
|
|
50
|
+
ordinal: number;
|
|
51
|
+
headingSlug?: string;
|
|
52
|
+
headingNth?: number;
|
|
53
|
+
};
|
package/lib/mapping.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure, DOM-free source<->rendered-block mapping for Markdown.
|
|
3
|
+
*
|
|
4
|
+
* The Markdown Preview renders with `marked` and emits no source-line
|
|
5
|
+
* attributes, so we re-lex the source ourselves and correlate rendered
|
|
6
|
+
* top-level blocks to source lines by ORDINAL INDEX: the nth rendered
|
|
7
|
+
* top-level child corresponds to the nth non-space/def/comment block token.
|
|
8
|
+
*
|
|
9
|
+
* The line accumulator replicates JupyterLab core's `getHeadingTokens`
|
|
10
|
+
* walk (`@jupyterlab/markedparser-extension`): `currentLine` is a cumulative
|
|
11
|
+
* newline count advanced by EVERY top-level token, recorded BEFORE advancing.
|
|
12
|
+
*
|
|
13
|
+
* This module imports only `marked` - no `@jupyterlab` / `@lumino` / DOM - so
|
|
14
|
+
* it is unit-testable offline with Jest.
|
|
15
|
+
*/
|
|
16
|
+
import { marked } from 'marked';
|
|
17
|
+
/** marked token types that are NOT rendered as a top-level DOM child. */
|
|
18
|
+
const NON_DOM_TYPES = new Set(['space', 'def']);
|
|
19
|
+
/** True for an html token that renders nothing (HTML comment only). */
|
|
20
|
+
function isCommentOnlyHtml(token) {
|
|
21
|
+
var _a;
|
|
22
|
+
if (token.type !== 'html') {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
const raw = ((_a = token.raw) !== null && _a !== void 0 ? _a : '').trim();
|
|
26
|
+
return /^<!--[\s\S]*-->$/.test(raw);
|
|
27
|
+
}
|
|
28
|
+
/** Reproduce rendermime `createHeaderId`: spaces -> hyphens on the plain text. */
|
|
29
|
+
export function headingSlug(headingText) {
|
|
30
|
+
return plainText(headingText).replace(/ /g, '-');
|
|
31
|
+
}
|
|
32
|
+
/** Best-effort strip of inline markdown so a heading token's text matches the
|
|
33
|
+
* rendered element's textContent (used only for the heading fallback). */
|
|
34
|
+
function plainText(text) {
|
|
35
|
+
return text
|
|
36
|
+
.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1') // images -> alt
|
|
37
|
+
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') // links -> label
|
|
38
|
+
.replace(/[*_~`]/g, '') // emphasis / code markers
|
|
39
|
+
.trim();
|
|
40
|
+
}
|
|
41
|
+
/** Normalized text used for the empty-block guard. */
|
|
42
|
+
function blockText(token) {
|
|
43
|
+
var _a, _b;
|
|
44
|
+
if (token.type === 'hr') {
|
|
45
|
+
return '';
|
|
46
|
+
}
|
|
47
|
+
return ((_b = (_a = token.text) !== null && _a !== void 0 ? _a : token.raw) !== null && _b !== void 0 ? _b : '').trim();
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Re-lex `source` and build the ordinal<->line map.
|
|
51
|
+
*
|
|
52
|
+
* Uses `marked.lexer(source, { gfm: true })` to match core's parser config.
|
|
53
|
+
* The line walk includes EVERY top-level token (space/def advance the
|
|
54
|
+
* counter); the DOM-aligned block list excludes space, def and comment-only
|
|
55
|
+
* html tokens.
|
|
56
|
+
*/
|
|
57
|
+
export function buildBlockMap(source) {
|
|
58
|
+
var _a;
|
|
59
|
+
const tokens = marked.lexer(source, { gfm: true });
|
|
60
|
+
const blocks = [];
|
|
61
|
+
const headings = [];
|
|
62
|
+
let currentLine = 0;
|
|
63
|
+
for (const token of tokens) {
|
|
64
|
+
const startLine = currentLine;
|
|
65
|
+
const spanned = token.raw.split('\n').length - 1;
|
|
66
|
+
currentLine += spanned;
|
|
67
|
+
if (NON_DOM_TYPES.has(token.type) || isCommentOnlyHtml(token)) {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
const ordinal = blocks.length;
|
|
71
|
+
const descriptor = {
|
|
72
|
+
ordinal,
|
|
73
|
+
startLine,
|
|
74
|
+
endLine: startLine + Math.max(spanned - 1, 0),
|
|
75
|
+
type: token.type,
|
|
76
|
+
text: blockText(token)
|
|
77
|
+
};
|
|
78
|
+
if (token.type === 'heading') {
|
|
79
|
+
descriptor.headingSlug = headingSlug((_a = token.text) !== null && _a !== void 0 ? _a : '');
|
|
80
|
+
headings.push({
|
|
81
|
+
line: startLine,
|
|
82
|
+
slug: descriptor.headingSlug,
|
|
83
|
+
ordinal
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
blocks.push(descriptor);
|
|
87
|
+
}
|
|
88
|
+
return { blocks, headings };
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Preview -> Editor. Returns the 0-based start line for the block at DOM
|
|
92
|
+
* ordinal `ordinal`, or -1 when the ordinal is out of range or the block has
|
|
93
|
+
* no textual content (caller no-ops + warns, AC #5).
|
|
94
|
+
*/
|
|
95
|
+
export function blockToLine(source, ordinal) {
|
|
96
|
+
const { blocks } = buildBlockMap(source);
|
|
97
|
+
if (ordinal < 0 || ordinal >= blocks.length) {
|
|
98
|
+
return -1;
|
|
99
|
+
}
|
|
100
|
+
const block = blocks[ordinal];
|
|
101
|
+
if (block.text.length === 0) {
|
|
102
|
+
return -1;
|
|
103
|
+
}
|
|
104
|
+
return block.startLine;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Editor -> Preview. Returns the DOM ordinal of the block containing `line`
|
|
108
|
+
* (the last block whose startLine <= line), plus the nearest-preceding
|
|
109
|
+
* heading slug and its 1-based occurrence index for the fallback.
|
|
110
|
+
* `ordinal` is -1 when no block precedes the line.
|
|
111
|
+
*/
|
|
112
|
+
export function lineToBlock(source, line) {
|
|
113
|
+
var _a;
|
|
114
|
+
const { blocks } = buildBlockMap(source);
|
|
115
|
+
let ordinal = -1;
|
|
116
|
+
for (const block of blocks) {
|
|
117
|
+
if (block.startLine <= line) {
|
|
118
|
+
ordinal = block.ordinal;
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
// Nearest preceding heading + its occurrence index among same-slug headings.
|
|
125
|
+
let headingSlugValue;
|
|
126
|
+
let headingNth;
|
|
127
|
+
const slugCounts = new Map();
|
|
128
|
+
for (const block of blocks) {
|
|
129
|
+
if (block.type !== 'heading' || block.headingSlug === undefined) {
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
const nth = ((_a = slugCounts.get(block.headingSlug)) !== null && _a !== void 0 ? _a : 0) + 1;
|
|
133
|
+
slugCounts.set(block.headingSlug, nth);
|
|
134
|
+
if (block.startLine <= line) {
|
|
135
|
+
headingSlugValue = block.headingSlug;
|
|
136
|
+
headingNth = nth;
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return { ordinal, headingSlug: headingSlugValue, headingNth };
|
|
143
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "jupyterlab_edit_markdown_at_content_extension",
|
|
3
|
+
"version": "0.4.3",
|
|
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
|
+
"keywords": [
|
|
6
|
+
"jupyter",
|
|
7
|
+
"jupyterlab",
|
|
8
|
+
"jupyterlab-extension"
|
|
9
|
+
],
|
|
10
|
+
"homepage": "https://github.com/stellarshenson/jupyterlab_edit_markdown_at_content_extension",
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/stellarshenson/jupyterlab_edit_markdown_at_content_extension/issues"
|
|
13
|
+
},
|
|
14
|
+
"license": "BSD-3-Clause",
|
|
15
|
+
"author": {
|
|
16
|
+
"name": "Stellars Henson",
|
|
17
|
+
"email": "konrad.jelen+github@gmail.com"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"lib/**/*.{d.ts,eot,gif,html,jpg,js,js.map,json,png,svg,woff2,ttf}",
|
|
21
|
+
"style/**/*.{css,js,eot,gif,html,jpg,json,png,svg,woff2,ttf}",
|
|
22
|
+
"src/**/*.{ts,tsx}"
|
|
23
|
+
],
|
|
24
|
+
"main": "lib/index.js",
|
|
25
|
+
"types": "lib/index.d.ts",
|
|
26
|
+
"style": "style/index.css",
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "https://github.com/stellarshenson/jupyterlab_edit_markdown_at_content_extension.git"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"build": "jlpm build:lib && jlpm build:labextension:dev",
|
|
33
|
+
"build:prod": "jlpm clean && jlpm build:lib:prod && jlpm build:labextension",
|
|
34
|
+
"build:labextension": "jupyter labextension build .",
|
|
35
|
+
"build:labextension:dev": "jupyter labextension build --development True .",
|
|
36
|
+
"build:lib": "tsc --sourceMap",
|
|
37
|
+
"build:lib:prod": "tsc",
|
|
38
|
+
"clean": "jlpm clean:lib",
|
|
39
|
+
"clean:lib": "rimraf lib tsconfig.tsbuildinfo",
|
|
40
|
+
"clean:lintcache": "rimraf .eslintcache .stylelintcache",
|
|
41
|
+
"clean:labextension": "rimraf jupyterlab_edit_markdown_at_content_extension/labextension jupyterlab_edit_markdown_at_content_extension/_version.py",
|
|
42
|
+
"clean:all": "jlpm clean:lib && jlpm clean:labextension && jlpm clean:lintcache",
|
|
43
|
+
"eslint": "jlpm eslint:check --fix",
|
|
44
|
+
"eslint:check": "eslint . --cache --ext .ts,.tsx",
|
|
45
|
+
"install:extension": "jlpm build",
|
|
46
|
+
"lint": "jlpm stylelint && jlpm prettier && jlpm eslint",
|
|
47
|
+
"lint:check": "jlpm stylelint:check && jlpm prettier:check && jlpm eslint:check",
|
|
48
|
+
"prettier": "jlpm prettier:base --write --list-different",
|
|
49
|
+
"prettier:base": "prettier \"**/*{.ts,.tsx,.js,.jsx,.css,.json,.md}\"",
|
|
50
|
+
"prettier:check": "jlpm prettier:base --check",
|
|
51
|
+
"stylelint": "jlpm stylelint:check --fix",
|
|
52
|
+
"stylelint:check": "stylelint --cache \"style/**/*.css\"",
|
|
53
|
+
"test": "jest --coverage",
|
|
54
|
+
"watch": "run-p watch:src watch:labextension",
|
|
55
|
+
"watch:src": "tsc -w --sourceMap",
|
|
56
|
+
"watch:labextension": "jupyter labextension watch ."
|
|
57
|
+
},
|
|
58
|
+
"dependencies": {
|
|
59
|
+
"@jupyterlab/application": "^4.5.0",
|
|
60
|
+
"@jupyterlab/codeeditor": "^4.5.0",
|
|
61
|
+
"@jupyterlab/docmanager": "^4.5.0",
|
|
62
|
+
"@jupyterlab/docregistry": "^4.5.0",
|
|
63
|
+
"@jupyterlab/fileeditor": "^4.5.0",
|
|
64
|
+
"@jupyterlab/markdownviewer": "^4.5.0",
|
|
65
|
+
"marked": "^17.0.0"
|
|
66
|
+
},
|
|
67
|
+
"devDependencies": {
|
|
68
|
+
"@jupyterlab/builder": "^4.0.0",
|
|
69
|
+
"@jupyterlab/testutils": "^4.0.0",
|
|
70
|
+
"@types/jest": "^29.2.0",
|
|
71
|
+
"@types/json-schema": "^7.0.11",
|
|
72
|
+
"@types/react": "^18.0.26",
|
|
73
|
+
"@types/react-addons-linked-state-mixin": "^0.14.22",
|
|
74
|
+
"@typescript-eslint/eslint-plugin": "^6.1.0",
|
|
75
|
+
"@typescript-eslint/parser": "^6.1.0",
|
|
76
|
+
"css-loader": "^6.7.1",
|
|
77
|
+
"eslint": "^8.36.0",
|
|
78
|
+
"eslint-config-prettier": "^8.8.0",
|
|
79
|
+
"eslint-plugin-prettier": "^5.0.0",
|
|
80
|
+
"jest": "^29.2.0",
|
|
81
|
+
"mkdirp": "^1.0.3",
|
|
82
|
+
"npm-run-all2": "^7.0.1",
|
|
83
|
+
"prettier": "^3.0.0",
|
|
84
|
+
"rimraf": "^5.0.1",
|
|
85
|
+
"source-map-loader": "^1.0.2",
|
|
86
|
+
"style-loader": "^3.3.1",
|
|
87
|
+
"stylelint": "^15.10.1",
|
|
88
|
+
"stylelint-config-recommended": "^13.0.0",
|
|
89
|
+
"stylelint-config-standard": "^34.0.0",
|
|
90
|
+
"stylelint-csstree-validator": "^3.0.0",
|
|
91
|
+
"stylelint-prettier": "^4.0.0",
|
|
92
|
+
"typescript": "~5.8.0",
|
|
93
|
+
"yjs": "^13.5.0"
|
|
94
|
+
},
|
|
95
|
+
"resolutions": {
|
|
96
|
+
"lib0": "0.2.111",
|
|
97
|
+
"webpack": "5.106.0",
|
|
98
|
+
"chalk": "4.1.2"
|
|
99
|
+
},
|
|
100
|
+
"overrides": {
|
|
101
|
+
"webpack": "5.106.0",
|
|
102
|
+
"chalk": "4.1.2"
|
|
103
|
+
},
|
|
104
|
+
"sideEffects": [
|
|
105
|
+
"style/*.css",
|
|
106
|
+
"style/index.js"
|
|
107
|
+
],
|
|
108
|
+
"styleModule": "style/index.js",
|
|
109
|
+
"publishConfig": {
|
|
110
|
+
"access": "public"
|
|
111
|
+
},
|
|
112
|
+
"jupyterlab": {
|
|
113
|
+
"extension": true,
|
|
114
|
+
"outputDir": "jupyterlab_edit_markdown_at_content_extension/labextension"
|
|
115
|
+
},
|
|
116
|
+
"eslintIgnore": [
|
|
117
|
+
"node_modules",
|
|
118
|
+
"dist",
|
|
119
|
+
"coverage",
|
|
120
|
+
"**/*.d.ts",
|
|
121
|
+
"tests",
|
|
122
|
+
"**/__tests__",
|
|
123
|
+
"ui-tests"
|
|
124
|
+
],
|
|
125
|
+
"eslintConfig": {
|
|
126
|
+
"extends": [
|
|
127
|
+
"eslint:recommended",
|
|
128
|
+
"plugin:@typescript-eslint/eslint-recommended",
|
|
129
|
+
"plugin:@typescript-eslint/recommended",
|
|
130
|
+
"plugin:prettier/recommended"
|
|
131
|
+
],
|
|
132
|
+
"parser": "@typescript-eslint/parser",
|
|
133
|
+
"parserOptions": {
|
|
134
|
+
"project": "tsconfig.json",
|
|
135
|
+
"sourceType": "module"
|
|
136
|
+
},
|
|
137
|
+
"plugins": [
|
|
138
|
+
"@typescript-eslint"
|
|
139
|
+
],
|
|
140
|
+
"rules": {
|
|
141
|
+
"@typescript-eslint/naming-convention": [
|
|
142
|
+
"error",
|
|
143
|
+
{
|
|
144
|
+
"selector": "interface",
|
|
145
|
+
"format": [
|
|
146
|
+
"PascalCase"
|
|
147
|
+
],
|
|
148
|
+
"custom": {
|
|
149
|
+
"regex": "^I[A-Z]",
|
|
150
|
+
"match": true
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
],
|
|
154
|
+
"@typescript-eslint/no-unused-vars": [
|
|
155
|
+
"warn",
|
|
156
|
+
{
|
|
157
|
+
"args": "none"
|
|
158
|
+
}
|
|
159
|
+
],
|
|
160
|
+
"@typescript-eslint/no-explicit-any": "off",
|
|
161
|
+
"@typescript-eslint/no-namespace": "off",
|
|
162
|
+
"@typescript-eslint/no-use-before-define": "off",
|
|
163
|
+
"@typescript-eslint/quotes": [
|
|
164
|
+
"error",
|
|
165
|
+
"single",
|
|
166
|
+
{
|
|
167
|
+
"avoidEscape": true,
|
|
168
|
+
"allowTemplateLiterals": false
|
|
169
|
+
}
|
|
170
|
+
],
|
|
171
|
+
"curly": [
|
|
172
|
+
"error",
|
|
173
|
+
"all"
|
|
174
|
+
],
|
|
175
|
+
"eqeqeq": "error",
|
|
176
|
+
"prefer-arrow-callback": "error"
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
"prettier": {
|
|
180
|
+
"singleQuote": true,
|
|
181
|
+
"trailingComma": "none",
|
|
182
|
+
"arrowParens": "avoid",
|
|
183
|
+
"endOfLine": "auto",
|
|
184
|
+
"overrides": [
|
|
185
|
+
{
|
|
186
|
+
"files": "package.json",
|
|
187
|
+
"options": {
|
|
188
|
+
"tabWidth": 4
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
]
|
|
192
|
+
},
|
|
193
|
+
"stylelint": {
|
|
194
|
+
"extends": [
|
|
195
|
+
"stylelint-config-recommended",
|
|
196
|
+
"stylelint-config-standard",
|
|
197
|
+
"stylelint-prettier/recommended"
|
|
198
|
+
],
|
|
199
|
+
"plugins": [
|
|
200
|
+
"stylelint-csstree-validator"
|
|
201
|
+
],
|
|
202
|
+
"rules": {
|
|
203
|
+
"csstree/validator": true,
|
|
204
|
+
"property-no-vendor-prefix": null,
|
|
205
|
+
"selector-class-pattern": "^([a-z][A-z\\d]*)(-[A-z\\d]+)*$",
|
|
206
|
+
"selector-no-vendor-prefix": null,
|
|
207
|
+
"value-no-vendor-prefix": null
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
declare const require: (module: string) => any;
|
|
2
|
+
declare const __dirname: string;
|
|
3
|
+
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
|
|
7
|
+
/** index.ts source with comments stripped, for static regression checks. */
|
|
8
|
+
function indexSourceNoComments(): string {
|
|
9
|
+
const source = fs.readFileSync(
|
|
10
|
+
path.resolve(__dirname, '..', 'index.ts'),
|
|
11
|
+
'utf-8'
|
|
12
|
+
);
|
|
13
|
+
return source.replace(/\/\/.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, '');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('index - registry hygiene (AC #10)', () => {
|
|
17
|
+
it('does not call docRegistry.addFileType (must not override icons)', () => {
|
|
18
|
+
expect(indexSourceNoComments()).not.toMatch(/addFileType\s*\(/);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('does not depend on IDocumentRegistry', () => {
|
|
22
|
+
expect(indexSourceNoComments()).not.toMatch(/IDocumentRegistry/);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe('index - activation message (AC #9)', () => {
|
|
27
|
+
it('logs the exact activation message the UI test expects', () => {
|
|
28
|
+
expect(indexSourceNoComments()).toContain(
|
|
29
|
+
'JupyterLab extension jupyterlab_edit_markdown_at_content_extension is activated!'
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildBlockMap,
|
|
3
|
+
blockToLine,
|
|
4
|
+
lineToBlock,
|
|
5
|
+
headingSlug
|
|
6
|
+
} from '../mapping';
|
|
7
|
+
|
|
8
|
+
/** Return the 0-based source line, for content-based assertions. */
|
|
9
|
+
function lineAt(src: string, n: number): string {
|
|
10
|
+
return src.split('\n')[n];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe('mapping - buildBlockMap', () => {
|
|
14
|
+
it('orders blocks and starts the first block at line 0', () => {
|
|
15
|
+
const src = '# Title\n\nFirst paragraph.\n';
|
|
16
|
+
const { blocks } = buildBlockMap(src);
|
|
17
|
+
expect(blocks[0].ordinal).toBe(0);
|
|
18
|
+
expect(blocks[0].startLine).toBe(0);
|
|
19
|
+
expect(blocks.map(b => b.ordinal)).toEqual([0, 1]);
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe('mapping - cumulative line arithmetic across a fenced code block', () => {
|
|
24
|
+
const src = [
|
|
25
|
+
'# Title', // 0
|
|
26
|
+
'', // 1
|
|
27
|
+
'First paragraph.', // 2
|
|
28
|
+
'', // 3
|
|
29
|
+
'## Section', // 4
|
|
30
|
+
'', // 5
|
|
31
|
+
'```js', // 6
|
|
32
|
+
'const a = 1;', // 7
|
|
33
|
+
'const b = 2;', // 8
|
|
34
|
+
'```', // 9
|
|
35
|
+
'', // 10
|
|
36
|
+
'After code paragraph.' // 11
|
|
37
|
+
].join('\n');
|
|
38
|
+
|
|
39
|
+
it('lands each block on its true source line (verifies the accumulator)', () => {
|
|
40
|
+
const { blocks } = buildBlockMap(src);
|
|
41
|
+
const byType = (t: string) => blocks.filter(b => b.type === t);
|
|
42
|
+
|
|
43
|
+
expect(blocks[0].startLine).toBe(0); // heading
|
|
44
|
+
expect(lineAt(src, blockToLine(src, 1))).toBe('First paragraph.');
|
|
45
|
+
// The paragraph AFTER the 4-line code fence must resolve to line 11,
|
|
46
|
+
// not be off-by-N from naive +1-per-token counting.
|
|
47
|
+
const lastPara = byType('paragraph').slice(-1)[0];
|
|
48
|
+
expect(blockToLine(src, lastPara.ordinal)).toBe(11);
|
|
49
|
+
expect(lineAt(src, 11)).toBe('After code paragraph.');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('round-trips line -> block -> line for the trailing paragraph', () => {
|
|
53
|
+
const { blocks } = buildBlockMap(src);
|
|
54
|
+
const lastPara = blocks.filter(b => b.type === 'paragraph').slice(-1)[0];
|
|
55
|
+
const { ordinal } = lineToBlock(src, 11);
|
|
56
|
+
expect(ordinal).toBe(lastPara.ordinal);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('maps a line inside the code fence to the code block + preceding heading', () => {
|
|
60
|
+
const { blocks } = buildBlockMap(src);
|
|
61
|
+
const code = blocks.find(b => b.type === 'code')!;
|
|
62
|
+
const res = lineToBlock(src, 8); // 'const b = 2;'
|
|
63
|
+
expect(res.ordinal).toBe(code.ordinal);
|
|
64
|
+
expect(res.headingSlug).toBe('Section');
|
|
65
|
+
expect(res.headingNth).toBe(1);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe('mapping - duplicate adjacent paragraphs (position, not content)', () => {
|
|
70
|
+
const src = 'Repeat me.\n\nRepeat me.\n';
|
|
71
|
+
it('resolves identical paragraphs to distinct ordinals and lines', () => {
|
|
72
|
+
const { blocks } = buildBlockMap(src);
|
|
73
|
+
expect(blocks).toHaveLength(2);
|
|
74
|
+
expect(blockToLine(src, 0)).toBe(0);
|
|
75
|
+
expect(blockToLine(src, 1)).toBe(2);
|
|
76
|
+
expect(lineAt(src, 2)).toBe('Repeat me.');
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe('mapping - empty/marker-only blocks guard (AC #5)', () => {
|
|
81
|
+
it('returns -1 for a horizontal rule block', () => {
|
|
82
|
+
const src = 'Text\n\n---\n\nMore\n';
|
|
83
|
+
const { blocks } = buildBlockMap(src);
|
|
84
|
+
const hr = blocks.find(b => b.type === 'hr')!;
|
|
85
|
+
expect(hr).toBeDefined();
|
|
86
|
+
expect(blockToLine(src, hr.ordinal)).toBe(-1);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('returns -1 for an out-of-range ordinal', () => {
|
|
90
|
+
expect(blockToLine('# Only\n', 999)).toBe(-1);
|
|
91
|
+
expect(blockToLine('# Only\n', -1)).toBe(-1);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('mapping - nested list is a single top-level block', () => {
|
|
96
|
+
const src = '- a\n - b\n- c\n';
|
|
97
|
+
it('treats the whole list as one ordinal', () => {
|
|
98
|
+
const { blocks } = buildBlockMap(src);
|
|
99
|
+
expect(blocks).toHaveLength(1);
|
|
100
|
+
expect(blocks[0].type).toBe('list');
|
|
101
|
+
expect(lineToBlock(src, 1).ordinal).toBe(0);
|
|
102
|
+
expect(blockToLine(src, 0)).toBe(0);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe('mapping - GFM table is a single top-level block', () => {
|
|
107
|
+
const src = '| H | I |\n|---|---|\n| 1 | 2 |\n';
|
|
108
|
+
it('treats the table as one ordinal at line 0', () => {
|
|
109
|
+
const { blocks } = buildBlockMap(src);
|
|
110
|
+
expect(blocks[0].type).toBe('table');
|
|
111
|
+
expect(blockToLine(src, 0)).toBe(0);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe('mapping - headingSlug', () => {
|
|
116
|
+
it('replaces spaces with hyphens on plain text', () => {
|
|
117
|
+
expect(headingSlug('Hello World')).toBe('Hello-World');
|
|
118
|
+
});
|
|
119
|
+
it('strips inline markdown before slugging', () => {
|
|
120
|
+
expect(headingSlug('A **bold** title')).toBe('A-bold-title');
|
|
121
|
+
});
|
|
122
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import {
|
|
2
|
+
JupyterFrontEnd,
|
|
3
|
+
JupyterFrontEndPlugin
|
|
4
|
+
} from '@jupyterlab/application';
|
|
5
|
+
|
|
6
|
+
import { IDocumentManager } from '@jupyterlab/docmanager';
|
|
7
|
+
|
|
8
|
+
import { IEditorTracker } from '@jupyterlab/fileeditor';
|
|
9
|
+
|
|
10
|
+
import { IMarkdownViewerTracker } from '@jupyterlab/markdownviewer';
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
buildBlockMap,
|
|
14
|
+
blockToLine,
|
|
15
|
+
lineToBlock,
|
|
16
|
+
headingSlug
|
|
17
|
+
} from './mapping';
|
|
18
|
+
|
|
19
|
+
const PLUGIN_ID = 'jupyterlab_edit_markdown_at_content_extension:plugin';
|
|
20
|
+
const CMD_EDIT_AT = 'editmarkdownatcontent:edit-at-location';
|
|
21
|
+
const CMD_REVEAL_IN = 'editmarkdownatcontent:reveal-in-preview';
|
|
22
|
+
|
|
23
|
+
const PREVIEW_SELECTOR = '.jp-MarkdownViewer .jp-RenderedMarkdown';
|
|
24
|
+
const EDITOR_SELECTOR = '.jp-FileEditor';
|
|
25
|
+
const EDITOR_FACTORY = 'Editor';
|
|
26
|
+
const PREVIEW_FACTORY = 'Markdown Preview';
|
|
27
|
+
|
|
28
|
+
const LOG = '[edit-markdown-at-content]';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Initialization data for the jupyterlab_edit_markdown_at_content_extension extension.
|
|
32
|
+
*/
|
|
33
|
+
const plugin: JupyterFrontEndPlugin<void> = {
|
|
34
|
+
id: PLUGIN_ID,
|
|
35
|
+
description:
|
|
36
|
+
'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
|
+
autoStart: true,
|
|
38
|
+
requires: [IDocumentManager, IEditorTracker, IMarkdownViewerTracker],
|
|
39
|
+
activate: (
|
|
40
|
+
app: JupyterFrontEnd,
|
|
41
|
+
docManager: IDocumentManager,
|
|
42
|
+
editorTracker: IEditorTracker,
|
|
43
|
+
markdownTracker: IMarkdownViewerTracker
|
|
44
|
+
) => {
|
|
45
|
+
console.log(
|
|
46
|
+
'JupyterLab extension jupyterlab_edit_markdown_at_content_extension is activated!'
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
// Lumino commands receive no DOM target, so a single capture-phase
|
|
50
|
+
// listener stashes the right-clicked node for both directions.
|
|
51
|
+
let lastPreviewTarget: Element | null = null;
|
|
52
|
+
let lastEditorTarget: Element | null = null;
|
|
53
|
+
|
|
54
|
+
document.addEventListener(
|
|
55
|
+
'contextmenu',
|
|
56
|
+
(event: MouseEvent) => {
|
|
57
|
+
const target = event.target as Element | null;
|
|
58
|
+
lastPreviewTarget = target?.closest?.(PREVIEW_SELECTOR) ? target : null;
|
|
59
|
+
lastEditorTarget = target?.closest?.(EDITOR_SELECTOR) ? target : null;
|
|
60
|
+
},
|
|
61
|
+
true
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
/** The MarkdownDocument widget whose rendered host contains `target`. */
|
|
65
|
+
const findPreviewWidget = (target: Element): any | null => {
|
|
66
|
+
let found: any | null = null;
|
|
67
|
+
markdownTracker.forEach(widget => {
|
|
68
|
+
if (!found && widget.node.contains(target)) {
|
|
69
|
+
found = widget;
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
return found;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/** The FileEditor document widget whose node contains `target`. */
|
|
76
|
+
const findEditorWidget = (target: Element): any | null => {
|
|
77
|
+
let found: any | null = null;
|
|
78
|
+
editorTracker.forEach(widget => {
|
|
79
|
+
if (!found && widget.node.contains(target)) {
|
|
80
|
+
found = widget;
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
return found ?? editorTracker.currentWidget;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/** The `.jp-RenderedMarkdown` host element inside a preview/editor widget. */
|
|
87
|
+
const renderedHost = (widget: any): HTMLElement | null =>
|
|
88
|
+
widget?.node?.querySelector('.jp-RenderedMarkdown') ?? null;
|
|
89
|
+
|
|
90
|
+
// ---- Preview -> Editor -------------------------------------------------
|
|
91
|
+
app.commands.addCommand(CMD_EDIT_AT, {
|
|
92
|
+
label: 'Edit at this location',
|
|
93
|
+
execute: async () => {
|
|
94
|
+
const target = lastPreviewTarget;
|
|
95
|
+
if (!target) {
|
|
96
|
+
console.warn(`${LOG} no markdown preview target under the cursor`);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const widget = findPreviewWidget(target);
|
|
100
|
+
if (!widget) {
|
|
101
|
+
console.warn(`${LOG} could not resolve the owning Markdown Preview`);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const host = renderedHost(widget);
|
|
105
|
+
if (!host) {
|
|
106
|
+
console.warn(`${LOG} rendered host not found`);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Walk up to the top-level block (direct child of the host).
|
|
111
|
+
let block: Element | null = target;
|
|
112
|
+
while (block && block.parentElement !== host) {
|
|
113
|
+
block = block.parentElement;
|
|
114
|
+
}
|
|
115
|
+
if (!block) {
|
|
116
|
+
console.warn(`${LOG} clicked content is not a rendered block`);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const ordinal = Array.from(host.children).indexOf(block);
|
|
120
|
+
const source: string = widget.context.model.toString();
|
|
121
|
+
const line = blockToLine(source, ordinal);
|
|
122
|
+
if (line < 0) {
|
|
123
|
+
console.warn(`${LOG} block ordinal ${ordinal} is not mappable`);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const editorWidget: any = docManager.openOrReveal(
|
|
128
|
+
widget.context.path,
|
|
129
|
+
EDITOR_FACTORY
|
|
130
|
+
);
|
|
131
|
+
if (!editorWidget) {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
await editorWidget.context.ready;
|
|
135
|
+
await editorWidget.revealed;
|
|
136
|
+
const editor = editorWidget.content.editor;
|
|
137
|
+
const clamped = Math.min(line, editor.lineCount - 1);
|
|
138
|
+
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.
|
|
141
|
+
editor.focus();
|
|
142
|
+
editor.revealPosition({ line: clamped, column: 0 });
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
app.contextMenu.addItem({
|
|
146
|
+
command: CMD_EDIT_AT,
|
|
147
|
+
selector: PREVIEW_SELECTOR,
|
|
148
|
+
rank: 0
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// ---- Editor -> Preview -------------------------------------------------
|
|
152
|
+
app.commands.addCommand(CMD_REVEAL_IN, {
|
|
153
|
+
label: 'Reveal in Markdown Preview',
|
|
154
|
+
execute: async () => {
|
|
155
|
+
const target = lastEditorTarget;
|
|
156
|
+
if (!target) {
|
|
157
|
+
console.warn(`${LOG} no editor target under the cursor`);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
const widget = findEditorWidget(target);
|
|
161
|
+
if (!widget) {
|
|
162
|
+
console.warn(`${LOG} could not resolve the owning file editor`);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
const editor = widget.content.editor;
|
|
166
|
+
const line = editor.getCursorPosition().line;
|
|
167
|
+
const source: string = widget.context.model.toString();
|
|
168
|
+
const {
|
|
169
|
+
ordinal,
|
|
170
|
+
headingSlug: slug,
|
|
171
|
+
headingNth
|
|
172
|
+
} = lineToBlock(source, line);
|
|
173
|
+
|
|
174
|
+
const previewWidget: any = docManager.openOrReveal(
|
|
175
|
+
widget.context.path,
|
|
176
|
+
PREVIEW_FACTORY
|
|
177
|
+
);
|
|
178
|
+
if (!previewWidget) {
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
await previewWidget.context.ready;
|
|
182
|
+
await previewWidget.revealed;
|
|
183
|
+
const host = renderedHost(previewWidget);
|
|
184
|
+
if (!host) {
|
|
185
|
+
console.warn(`${LOG} rendered preview host not found`);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
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}`);
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
app.contextMenu.addItem({
|
|
225
|
+
command: CMD_REVEAL_IN,
|
|
226
|
+
selector: EDITOR_SELECTOR,
|
|
227
|
+
rank: 0
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
export default plugin;
|
package/src/mapping.ts
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure, DOM-free source<->rendered-block mapping for Markdown.
|
|
3
|
+
*
|
|
4
|
+
* The Markdown Preview renders with `marked` and emits no source-line
|
|
5
|
+
* attributes, so we re-lex the source ourselves and correlate rendered
|
|
6
|
+
* top-level blocks to source lines by ORDINAL INDEX: the nth rendered
|
|
7
|
+
* top-level child corresponds to the nth non-space/def/comment block token.
|
|
8
|
+
*
|
|
9
|
+
* The line accumulator replicates JupyterLab core's `getHeadingTokens`
|
|
10
|
+
* walk (`@jupyterlab/markedparser-extension`): `currentLine` is a cumulative
|
|
11
|
+
* newline count advanced by EVERY top-level token, recorded BEFORE advancing.
|
|
12
|
+
*
|
|
13
|
+
* This module imports only `marked` - no `@jupyterlab` / `@lumino` / DOM - so
|
|
14
|
+
* it is unit-testable offline with Jest.
|
|
15
|
+
*/
|
|
16
|
+
import { marked } from 'marked';
|
|
17
|
+
|
|
18
|
+
/** One rendered top-level block, ordinal-aligned to the rendered host's children. */
|
|
19
|
+
export interface IBlockDescriptor {
|
|
20
|
+
/** Index into the DOM-correlated block list == rendered host child index. */
|
|
21
|
+
ordinal: number;
|
|
22
|
+
/** 0-based first source line of the block. */
|
|
23
|
+
startLine: number;
|
|
24
|
+
/** 0-based last source line of the block (inclusive of trailing blanks). */
|
|
25
|
+
endLine: number;
|
|
26
|
+
/** marked token type (heading, paragraph, code, list, table, ...). */
|
|
27
|
+
type: string;
|
|
28
|
+
/** Normalized block text, for the empty-block guard. */
|
|
29
|
+
text: string;
|
|
30
|
+
/** Present only for heading tokens: createHeaderId form of the heading text. */
|
|
31
|
+
headingSlug?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface IBlockMap {
|
|
35
|
+
/** Rendered (non-space/def/comment) tokens, in DOM order. */
|
|
36
|
+
blocks: IBlockDescriptor[];
|
|
37
|
+
/** Heading blocks, for the nearest-preceding-heading fallback. */
|
|
38
|
+
headings: { line: number; slug: string; ordinal: number }[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** marked token types that are NOT rendered as a top-level DOM child. */
|
|
42
|
+
const NON_DOM_TYPES = new Set(['space', 'def']);
|
|
43
|
+
|
|
44
|
+
/** True for an html token that renders nothing (HTML comment only). */
|
|
45
|
+
function isCommentOnlyHtml(token: { type: string; raw?: string }): boolean {
|
|
46
|
+
if (token.type !== 'html') {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
const raw = (token.raw ?? '').trim();
|
|
50
|
+
return /^<!--[\s\S]*-->$/.test(raw);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Reproduce rendermime `createHeaderId`: spaces -> hyphens on the plain text. */
|
|
54
|
+
export function headingSlug(headingText: string): string {
|
|
55
|
+
return plainText(headingText).replace(/ /g, '-');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Best-effort strip of inline markdown so a heading token's text matches the
|
|
59
|
+
* rendered element's textContent (used only for the heading fallback). */
|
|
60
|
+
function plainText(text: string): string {
|
|
61
|
+
return text
|
|
62
|
+
.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1') // images -> alt
|
|
63
|
+
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') // links -> label
|
|
64
|
+
.replace(/[*_~`]/g, '') // emphasis / code markers
|
|
65
|
+
.trim();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Normalized text used for the empty-block guard. */
|
|
69
|
+
function blockText(token: {
|
|
70
|
+
type: string;
|
|
71
|
+
text?: string;
|
|
72
|
+
raw?: string;
|
|
73
|
+
}): string {
|
|
74
|
+
if (token.type === 'hr') {
|
|
75
|
+
return '';
|
|
76
|
+
}
|
|
77
|
+
return (token.text ?? token.raw ?? '').trim();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Re-lex `source` and build the ordinal<->line map.
|
|
82
|
+
*
|
|
83
|
+
* Uses `marked.lexer(source, { gfm: true })` to match core's parser config.
|
|
84
|
+
* The line walk includes EVERY top-level token (space/def advance the
|
|
85
|
+
* counter); the DOM-aligned block list excludes space, def and comment-only
|
|
86
|
+
* html tokens.
|
|
87
|
+
*/
|
|
88
|
+
export function buildBlockMap(source: string): IBlockMap {
|
|
89
|
+
const tokens = marked.lexer(source, { gfm: true }) as Array<{
|
|
90
|
+
type: string;
|
|
91
|
+
raw: string;
|
|
92
|
+
text?: string;
|
|
93
|
+
depth?: number;
|
|
94
|
+
}>;
|
|
95
|
+
|
|
96
|
+
const blocks: IBlockDescriptor[] = [];
|
|
97
|
+
const headings: { line: number; slug: string; ordinal: number }[] = [];
|
|
98
|
+
|
|
99
|
+
let currentLine = 0;
|
|
100
|
+
for (const token of tokens) {
|
|
101
|
+
const startLine = currentLine;
|
|
102
|
+
const spanned = token.raw.split('\n').length - 1;
|
|
103
|
+
currentLine += spanned;
|
|
104
|
+
|
|
105
|
+
if (NON_DOM_TYPES.has(token.type) || isCommentOnlyHtml(token)) {
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const ordinal = blocks.length;
|
|
110
|
+
const descriptor: IBlockDescriptor = {
|
|
111
|
+
ordinal,
|
|
112
|
+
startLine,
|
|
113
|
+
endLine: startLine + Math.max(spanned - 1, 0),
|
|
114
|
+
type: token.type,
|
|
115
|
+
text: blockText(token)
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
if (token.type === 'heading') {
|
|
119
|
+
descriptor.headingSlug = headingSlug(token.text ?? '');
|
|
120
|
+
headings.push({
|
|
121
|
+
line: startLine,
|
|
122
|
+
slug: descriptor.headingSlug,
|
|
123
|
+
ordinal
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
blocks.push(descriptor);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return { blocks, headings };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Preview -> Editor. Returns the 0-based start line for the block at DOM
|
|
135
|
+
* ordinal `ordinal`, or -1 when the ordinal is out of range or the block has
|
|
136
|
+
* no textual content (caller no-ops + warns, AC #5).
|
|
137
|
+
*/
|
|
138
|
+
export function blockToLine(source: string, ordinal: number): number {
|
|
139
|
+
const { blocks } = buildBlockMap(source);
|
|
140
|
+
if (ordinal < 0 || ordinal >= blocks.length) {
|
|
141
|
+
return -1;
|
|
142
|
+
}
|
|
143
|
+
const block = blocks[ordinal];
|
|
144
|
+
if (block.text.length === 0) {
|
|
145
|
+
return -1;
|
|
146
|
+
}
|
|
147
|
+
return block.startLine;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Editor -> Preview. Returns the DOM ordinal of the block containing `line`
|
|
152
|
+
* (the last block whose startLine <= line), plus the nearest-preceding
|
|
153
|
+
* heading slug and its 1-based occurrence index for the fallback.
|
|
154
|
+
* `ordinal` is -1 when no block precedes the line.
|
|
155
|
+
*/
|
|
156
|
+
export function lineToBlock(
|
|
157
|
+
source: string,
|
|
158
|
+
line: number
|
|
159
|
+
): { ordinal: number; headingSlug?: string; headingNth?: number } {
|
|
160
|
+
const { blocks } = buildBlockMap(source);
|
|
161
|
+
|
|
162
|
+
let ordinal = -1;
|
|
163
|
+
for (const block of blocks) {
|
|
164
|
+
if (block.startLine <= line) {
|
|
165
|
+
ordinal = block.ordinal;
|
|
166
|
+
} else {
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Nearest preceding heading + its occurrence index among same-slug headings.
|
|
172
|
+
let headingSlugValue: string | undefined;
|
|
173
|
+
let headingNth: number | undefined;
|
|
174
|
+
const slugCounts = new Map<string, number>();
|
|
175
|
+
for (const block of blocks) {
|
|
176
|
+
if (block.type !== 'heading' || block.headingSlug === undefined) {
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
const nth = (slugCounts.get(block.headingSlug) ?? 0) + 1;
|
|
180
|
+
slugCounts.set(block.headingSlug, nth);
|
|
181
|
+
if (block.startLine <= line) {
|
|
182
|
+
headingSlugValue = block.headingSlug;
|
|
183
|
+
headingNth = nth;
|
|
184
|
+
} else {
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return { ordinal, headingSlug: headingSlugValue, headingNth };
|
|
190
|
+
}
|
package/style/base.css
ADDED
package/style/index.css
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
@import url('base.css');
|
package/style/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import './base.css';
|