Skip to content

Commit 6ecb558

Browse files
Scroll to cell by ID based on hash fragment (#13285)
* Scroll to cell by ID based on hash fragment * Preserve the old behaviour for unmatched headings * Add test for scroll to heading/cell, remove `CSS.escape`. `CSS.escape` was no longer correct since the rewrite to manual iteration over cells to fetch headings, since we no longer use `querySelector` and instead compare to actual heading IDs. * Document scrolling to notebook headings/cells, change cursor * Update Playwright Snapshots * Shorten section title, fix image address and code highlight * Prevent kernel flicker on documentation screenshots * Adjust documentation snapshots to new cursor, add `positionMouseOver` utility * Export added interface, set to empty object by default * Update Playwright Snapshots * Remove debug log * Set right sidebar width * Update Playwright Snapshots * Revert bot update to debugger-variables Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
1 parent ebe33ad commit 6ecb558

20 files changed

+561
-79
lines changed

docs/source/user/urls.rst

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,41 @@ Entering the above URL will show the workspace root directory instead of the ``/
3535
directory in the file browser.
3636

3737

38+
Linking Notebook Sections
39+
-------------------------
40+
41+
To create an URL which will scroll to a specific heading in the notebook append
42+
a hash (``#``) followed by the heading text with spaces replaced by minus
43+
characters (``-``), for example:
44+
45+
.. code-block:: none
46+
47+
/lab/tree/path/to/notebook.ipynb?#my-heading
48+
49+
To get a link for a specific heading, hover over it in a rendered markdown cell
50+
until you see a pilcrow mark (````) which will contain the desired anchor link:
51+
52+
.. image:: ../images/notebook-heading-anchor-link.png
53+
:alt: A markdown cell with pilcrow mark (¶) which serves as an anchor link and is placed after a heading
54+
:class: jp-screenshot
55+
56+
57+
.. note::
58+
59+
Currently disambiguation of headings with identical text is not supported.
60+
61+
JupyterLab experimentally supports scrolling to a specified cell by identifier
62+
using ``#cell-id=<cell-id>`` Fragment Identification Syntax.
63+
64+
.. code-block:: none
65+
66+
/lab/tree/path/to/notebook.ipynb?#cell-id=my-cell-id
67+
68+
.. note::
69+
70+
The ``cell-id`` fragment locator is not part of a formal Jupyter standard and subject to change.
71+
To leave feedback, please comment in the discussion: `nbformat#317 <https://github.com/jupyter/nbformat/issues/317>`_.
72+
3873
.. _url-workspaces-ui:
3974

4075
Managing Workspaces (UI)

galata/test/documentation/customization.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,10 @@ test.describe('Default', () => {
4848

4949
await page.waitForSelector('div[role="main"] >> text=Lorenz.ipynb');
5050

51-
await page.waitForSelector('text=Python 3 (ipykernel) | Idle');
51+
// Wait for kernel to settle on idle
52+
await page.waitForSelector('#jp-main-statusbar >> text=Idle');
53+
await page.waitForSelector('#jp-main-statusbar >> text=Busy');
54+
await page.waitForSelector('#jp-main-statusbar >> text=Idle');
5255

5356
expect(
5457
await page

galata/test/documentation/debugger.test.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
IJupyterLabPageFixture,
88
test
99
} from '@jupyterlab/galata';
10-
import { positionMouse, setSidebarWidth } from './utils';
10+
import { positionMouseOver, setSidebarWidth } from './utils';
1111

1212
test.use({
1313
autoGoto: false,
@@ -73,16 +73,21 @@ test.describe('Debugger', () => {
7373

7474
await createNotebook(page);
7575

76+
const runButton = await page.waitForSelector(
77+
'.jp-Toolbar-item >> [data-command="runmenu:run"]'
78+
);
79+
await runButton.hover();
80+
7681
// Inject mouse pointer
7782
await page.evaluate(
7883
([mouse]) => {
7984
document.body.insertAdjacentHTML('beforeend', mouse);
8085
},
81-
[positionMouse({ x: 446, y: 80 })]
86+
[await positionMouseOver(runButton)]
8287
);
8388

8489
expect(
85-
await page.screenshot({ clip: { y: 62, x: 400, width: 190, height: 80 } })
90+
await page.screenshot({ clip: { y: 62, x: 400, width: 190, height: 60 } })
8691
).toMatchSnapshot('debugger_run.png');
8792
});
8893

@@ -117,15 +122,18 @@ test.describe('Debugger', () => {
117122

118123
await createNotebook(page);
119124

120-
await page.click('[data-id="jp-debugger-sidebar"]');
125+
const sidebar = await page.waitForSelector(
126+
'[data-id="jp-debugger-sidebar"]'
127+
);
128+
await sidebar.click();
121129
await setSidebarWidth(page, 251, 'right');
122130

123131
// Inject mouse pointer
124132
await page.evaluate(
125133
([mouse]) => {
126134
document.body.insertAdjacentHTML('beforeend', mouse);
127135
},
128-
[positionMouse({ x: 1240, y: 115 })]
136+
[await positionMouseOver(sidebar, { left: 0.25 })]
129137
);
130138

131139
expect(
Loading
Loading
Loading

galata/test/documentation/general.test.ts

Lines changed: 58 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@
22
// Distributed under the terms of the Modified BSD License.
33

44
import { expect, galata, test } from '@jupyterlab/galata';
5-
import { generateArrow, positionMouse, setSidebarWidth } from './utils';
5+
import {
6+
generateArrow,
7+
positionMouse,
8+
positionMouseOver,
9+
setSidebarWidth
10+
} from './utils';
611

712
test.use({
813
autoGoto: false,
@@ -108,10 +113,9 @@ test.describe('General', () => {
108113
}`
109114
});
110115

111-
await setSidebarWidth(page);
112-
113116
await page.notebook.createNew();
114117
await page.click('[title="Property Inspector"]');
118+
await setSidebarWidth(page, 251, 'right');
115119

116120
expect(
117121
await page.screenshot({
@@ -178,14 +182,24 @@ test.describe('General', () => {
178182

179183
await page.click('text=File');
180184
await page.mouse.move(70, 40);
181-
await page.click('ul[role="menu"] >> text=New');
185+
const fileMenuNewItem = await page.waitForSelector(
186+
'ul[role="menu"] >> text=New'
187+
);
188+
await fileMenuNewItem.click();
182189

183190
// Inject mouse
184191
await page.evaluate(
185192
([mouse]) => {
186193
document.body.insertAdjacentHTML('beforeend', mouse);
187194
},
188-
[positionMouse({ x: 35, y: 35 })]
195+
[
196+
await positionMouseOver(fileMenuNewItem, {
197+
left: 0,
198+
// small negative offset to place the cursor before "New"
199+
offsetLeft: -17,
200+
top: 0.5
201+
})
202+
]
189203
);
190204

191205
expect(
@@ -211,14 +225,13 @@ test.describe('General', () => {
211225
await page.hover('text=Copy Shareable Link');
212226

213227
const itemHandle = await page.$('text=Copy Shareable Link');
214-
const itemBBox = await itemHandle.boundingBox();
215228

216229
// Inject mouse
217230
await page.evaluate(
218231
([mouse]) => {
219232
document.body.insertAdjacentHTML('beforeend', mouse);
220233
},
221-
[positionMouse({ x: 260, y: itemBBox.y + itemBBox.height * 0.5 })]
234+
[await positionMouseOver(itemHandle, { top: 0.5, left: 0.55 })]
222235
);
223236

224237
expect(
@@ -339,6 +352,44 @@ test.describe('General', () => {
339352
});
340353
});
341354

355+
test('Heading anchor', async ({ page }, testInfo) => {
356+
await page.goto();
357+
await setSidebarWidth(page);
358+
359+
// Open Data.ipynb
360+
await page.dblclick(
361+
'[aria-label="File Browser Section"] >> text=notebooks'
362+
);
363+
await page.dblclick('text=Data.ipynb');
364+
365+
const heading = await page.waitForSelector(
366+
'h2[id="Open-a-CSV-file-using-Pandas"]'
367+
);
368+
const anchor = await heading.$('text=¶');
369+
await heading.hover();
370+
371+
// Get parent cell which includes the heading
372+
const cell = await heading.evaluateHandle(node => node.closest('.jp-Cell'));
373+
374+
// Inject mouse
375+
await page.evaluate(
376+
([mouse]) => {
377+
document.body.insertAdjacentHTML('beforeend', mouse);
378+
},
379+
[
380+
await positionMouseOver(anchor, {
381+
left: 1,
382+
offsetLeft: 5,
383+
top: 0.25
384+
})
385+
]
386+
);
387+
388+
expect(await cell.screenshot()).toMatchSnapshot(
389+
'notebook_heading_anchor_link.png'
390+
);
391+
});
392+
342393
test('Terminals', async ({ page }) => {
343394
await galata.Mock.freezeContentLastModified(page);
344395
await page.goto();
Loading
Loading

galata/test/documentation/utils.ts

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
* Distributed under the terms of the Modified BSD License.
44
*/
55

6-
import { Page } from '@playwright/test';
6+
import { ElementHandle, Page } from '@playwright/test';
77
import fs from 'fs';
88
import path from 'path';
99

@@ -37,12 +37,62 @@ export function generateArrow(
3737
* @returns The svg to inject in the page
3838
*/
3939
export function positionMouse(position: { x: number; y: number }): string {
40-
return `<svg style="pointer-events: none; position: absolute;top: ${position.y}px;left: ${position.x}px;z-index: 100000" width="64" height="64" version="1.1" viewBox="0 0 16.933 16.933" xmlns="http://www.w3.org/2000/svg">
41-
<path d="m3.6043 1.0103 0.28628 12.757 2.7215-3.3091 2.5607 5.7514 2.0005-0.89067-2.5607-5.7514 4.2802 0.19174z"
42-
stroke="#ffffff" stroke-width=".54745" style="paint-order:markers fill stroke" />
40+
// The cursor is CC-0 1.0 from https://github.com/sevmeyer/mocu-xcursor
41+
return `<svg style="pointer-events: none; position: absolute;top: ${position.y}px;left: ${position.x}px;z-index: 100000" width="40" height="40" version="1.1" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
42+
<defs>
43+
<path id="c" class="left(-1,22)" d="m1 1v13.75l3.94-1.63 1.72 4.16 1.84-0.78-1.71-4.15 3.94-1.63z"/>
44+
</defs>
45+
<use xlink:href="#c" style="fill:#0a0b0c;stroke:#0a0b0c;stroke-width:2;stroke-linejoin:round;opacity:.1" x="1" y="1"/>
46+
<use xlink:href="#c" style="fill:#1a1b1c;stroke:#1a1b1c;stroke-width:2;stroke-linejoin:round"/>
47+
<use xlink:href="#c" style="fill:#fafbfc"/>
48+
<circle id="hot" class="left(-1,22)" cx="1" cy="1" r="1" style="fill:#f00;opacity:.5"/>
4349
</svg>`;
4450
}
4551

52+
/**
53+
* Position of an injected sprint in a DOM element.
54+
*/
55+
export interface IPositionInElement {
56+
/**
57+
* X-coordinate multiplier for the element's width.
58+
*/
59+
top?: number;
60+
/**
61+
* Y-coordinate multiplier for the element's height.
62+
*/
63+
left?: number;
64+
/**
65+
* Offset added to x-coordinate after calculating position with multipliers.
66+
*/
67+
offsetLeft?: number;
68+
/**
69+
* Offset added to y-coordinate after calculating position with multipliers.
70+
*/
71+
offsetTop?: number;
72+
}
73+
74+
/**
75+
* Generate a SVG mouse pointer to inject in a HTML document over a DOM element.
76+
*
77+
* @param element A playwright handle for the target DOM element
78+
* @param position A position within the target element (default: bottom right quarter).
79+
* @returns The svg to inject in the page
80+
*/
81+
export async function positionMouseOver(
82+
element: ElementHandle,
83+
position: IPositionInElement = {}
84+
): Promise<string> {
85+
const top = position.top ?? 0.75;
86+
const left = position.left ?? 0.75;
87+
const offsetTop = position.offsetTop ?? 0;
88+
const offsetLeft = position.offsetLeft ?? 0;
89+
const bBox = await element.boundingBox();
90+
return positionMouse({
91+
x: bBox.x + bBox.width * left + offsetLeft,
92+
y: bBox.y + bBox.height * top + offsetTop
93+
});
94+
}
95+
4696
/**
4797
* Set the sidebar width
4898
*
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
// Copyright (c) Jupyter Development Team.
2+
// Distributed under the terms of the Modified BSD License.
3+
4+
import { test } from '@jupyterlab/galata';
5+
import { expect } from '@playwright/test';
6+
import * as path from 'path';
7+
8+
const fileName = 'scroll.ipynb';
9+
10+
test.describe('Notebook Scroll', () => {
11+
test.beforeEach(async ({ page, tmpPath }) => {
12+
await page.contents.uploadFile(
13+
path.resolve(__dirname, `./notebooks/${fileName}`),
14+
`${tmpPath}/${fileName}`
15+
);
16+
17+
await page.notebook.openByPath(`${tmpPath}/${fileName}`);
18+
await page.notebook.activate(fileName);
19+
});
20+
21+
test.afterEach(async ({ page, tmpPath }) => {
22+
await page.contents.deleteDirectory(tmpPath);
23+
});
24+
25+
const cellLinks = {
26+
'penultimate cell using heading, legacy format': 18,
27+
'penultimate cell using heading, explicit fragment': 18,
28+
'last cell using heading, legacy format': 19,
29+
'last cell using heading, explicit fragment': 19,
30+
'last cell using cell identifier': 19
31+
};
32+
for (const [link, cellIdx] of Object.entries(cellLinks)) {
33+
test(`Scroll to ${link}`, async ({ page }) => {
34+
const firstCell = await page.notebook.getCell(0);
35+
await firstCell.scrollIntoViewIfNeeded();
36+
expect(await firstCell.boundingBox()).toBeTruthy();
37+
38+
await page.click(`a:has-text("${link}")`);
39+
40+
await firstCell.waitForElementState('hidden');
41+
expect(await firstCell.boundingBox()).toBeFalsy();
42+
43+
const lastCell = await page.notebook.getCell(cellIdx);
44+
await lastCell.waitForElementState('visible');
45+
expect(await lastCell.boundingBox()).toBeTruthy();
46+
});
47+
}
48+
});

0 commit comments

Comments
 (0)