Skip to content

Commit

Permalink
Scroll to cell by ID based on hash fragment (#13285)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
krassowski and github-actions[bot] authored Nov 14, 2022
1 parent ebe33ad commit 6ecb558
Show file tree
Hide file tree
Showing 20 changed files with 561 additions and 79 deletions.
35 changes: 35 additions & 0 deletions docs/source/user/urls.rst
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,41 @@ Entering the above URL will show the workspace root directory instead of the ``/
directory in the file browser.


Linking Notebook Sections
-------------------------

To create an URL which will scroll to a specific heading in the notebook append
a hash (``#``) followed by the heading text with spaces replaced by minus
characters (``-``), for example:

.. code-block:: none
/lab/tree/path/to/notebook.ipynb?#my-heading
To get a link for a specific heading, hover over it in a rendered markdown cell
until you see a pilcrow mark (````) which will contain the desired anchor link:

.. image:: ../images/notebook-heading-anchor-link.png
:alt: A markdown cell with pilcrow mark (¶) which serves as an anchor link and is placed after a heading
:class: jp-screenshot


.. note::

Currently disambiguation of headings with identical text is not supported.

JupyterLab experimentally supports scrolling to a specified cell by identifier
using ``#cell-id=<cell-id>`` Fragment Identification Syntax.

.. code-block:: none
/lab/tree/path/to/notebook.ipynb?#cell-id=my-cell-id
.. note::

The ``cell-id`` fragment locator is not part of a formal Jupyter standard and subject to change.
To leave feedback, please comment in the discussion: `nbformat#317 <https://github.com/jupyter/nbformat/issues/317>`_.

.. _url-workspaces-ui:

Managing Workspaces (UI)
Expand Down
5 changes: 4 additions & 1 deletion galata/test/documentation/customization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,10 @@ test.describe('Default', () => {

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

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

expect(
await page
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
18 changes: 13 additions & 5 deletions galata/test/documentation/debugger.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
IJupyterLabPageFixture,
test
} from '@jupyterlab/galata';
import { positionMouse, setSidebarWidth } from './utils';
import { positionMouseOver, setSidebarWidth } from './utils';

test.use({
autoGoto: false,
Expand Down Expand Up @@ -73,16 +73,21 @@ test.describe('Debugger', () => {

await createNotebook(page);

const runButton = await page.waitForSelector(
'.jp-Toolbar-item >> [data-command="runmenu:run"]'
);
await runButton.hover();

// Inject mouse pointer
await page.evaluate(
([mouse]) => {
document.body.insertAdjacentHTML('beforeend', mouse);
},
[positionMouse({ x: 446, y: 80 })]
[await positionMouseOver(runButton)]
);

expect(
await page.screenshot({ clip: { y: 62, x: 400, width: 190, height: 80 } })
await page.screenshot({ clip: { y: 62, x: 400, width: 190, height: 60 } })
).toMatchSnapshot('debugger_run.png');
});

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

await createNotebook(page);

await page.click('[data-id="jp-debugger-sidebar"]');
const sidebar = await page.waitForSelector(
'[data-id="jp-debugger-sidebar"]'
);
await sidebar.click();
await setSidebarWidth(page, 251, 'right');

// Inject mouse pointer
await page.evaluate(
([mouse]) => {
document.body.insertAdjacentHTML('beforeend', mouse);
},
[positionMouse({ x: 1240, y: 115 })]
[await positionMouseOver(sidebar, { left: 0.25 })]
);

expect(
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
65 changes: 58 additions & 7 deletions galata/test/documentation/general.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@
// Distributed under the terms of the Modified BSD License.

import { expect, galata, test } from '@jupyterlab/galata';
import { generateArrow, positionMouse, setSidebarWidth } from './utils';
import {
generateArrow,
positionMouse,
positionMouseOver,
setSidebarWidth
} from './utils';

test.use({
autoGoto: false,
Expand Down Expand Up @@ -108,10 +113,9 @@ test.describe('General', () => {
}`
});

await setSidebarWidth(page);

await page.notebook.createNew();
await page.click('[title="Property Inspector"]');
await setSidebarWidth(page, 251, 'right');

expect(
await page.screenshot({
Expand Down Expand Up @@ -178,14 +182,24 @@ test.describe('General', () => {

await page.click('text=File');
await page.mouse.move(70, 40);
await page.click('ul[role="menu"] >> text=New');
const fileMenuNewItem = await page.waitForSelector(
'ul[role="menu"] >> text=New'
);
await fileMenuNewItem.click();

// Inject mouse
await page.evaluate(
([mouse]) => {
document.body.insertAdjacentHTML('beforeend', mouse);
},
[positionMouse({ x: 35, y: 35 })]
[
await positionMouseOver(fileMenuNewItem, {
left: 0,
// small negative offset to place the cursor before "New"
offsetLeft: -17,
top: 0.5
})
]
);

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

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

// Inject mouse
await page.evaluate(
([mouse]) => {
document.body.insertAdjacentHTML('beforeend', mouse);
},
[positionMouse({ x: 260, y: itemBBox.y + itemBBox.height * 0.5 })]
[await positionMouseOver(itemHandle, { top: 0.5, left: 0.55 })]
);

expect(
Expand Down Expand Up @@ -339,6 +352,44 @@ test.describe('General', () => {
});
});

test('Heading anchor', async ({ page }, testInfo) => {
await page.goto();
await setSidebarWidth(page);

// Open Data.ipynb
await page.dblclick(
'[aria-label="File Browser Section"] >> text=notebooks'
);
await page.dblclick('text=Data.ipynb');

const heading = await page.waitForSelector(
'h2[id="Open-a-CSV-file-using-Pandas"]'
);
const anchor = await heading.$('text=¶');
await heading.hover();

// Get parent cell which includes the heading
const cell = await heading.evaluateHandle(node => node.closest('.jp-Cell'));

// Inject mouse
await page.evaluate(
([mouse]) => {
document.body.insertAdjacentHTML('beforeend', mouse);
},
[
await positionMouseOver(anchor, {
left: 1,
offsetLeft: 5,
top: 0.25
})
]
);

expect(await cell.screenshot()).toMatchSnapshot(
'notebook_heading_anchor_link.png'
);
});

test('Terminals', async ({ page }) => {
await galata.Mock.freezeContentLastModified(page);
await page.goto();
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
58 changes: 54 additions & 4 deletions galata/test/documentation/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* Distributed under the terms of the Modified BSD License.
*/

import { Page } from '@playwright/test';
import { ElementHandle, Page } from '@playwright/test';
import fs from 'fs';
import path from 'path';

Expand Down Expand Up @@ -37,12 +37,62 @@ export function generateArrow(
* @returns The svg to inject in the page
*/
export function positionMouse(position: { x: number; y: number }): string {
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">
<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"
stroke="#ffffff" stroke-width=".54745" style="paint-order:markers fill stroke" />
// The cursor is CC-0 1.0 from https://github.com/sevmeyer/mocu-xcursor
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">
<defs>
<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"/>
</defs>
<use xlink:href="#c" style="fill:#0a0b0c;stroke:#0a0b0c;stroke-width:2;stroke-linejoin:round;opacity:.1" x="1" y="1"/>
<use xlink:href="#c" style="fill:#1a1b1c;stroke:#1a1b1c;stroke-width:2;stroke-linejoin:round"/>
<use xlink:href="#c" style="fill:#fafbfc"/>
<circle id="hot" class="left(-1,22)" cx="1" cy="1" r="1" style="fill:#f00;opacity:.5"/>
</svg>`;
}

/**
* Position of an injected sprint in a DOM element.
*/
export interface IPositionInElement {
/**
* X-coordinate multiplier for the element's width.
*/
top?: number;
/**
* Y-coordinate multiplier for the element's height.
*/
left?: number;
/**
* Offset added to x-coordinate after calculating position with multipliers.
*/
offsetLeft?: number;
/**
* Offset added to y-coordinate after calculating position with multipliers.
*/
offsetTop?: number;
}

/**
* Generate a SVG mouse pointer to inject in a HTML document over a DOM element.
*
* @param element A playwright handle for the target DOM element
* @param position A position within the target element (default: bottom right quarter).
* @returns The svg to inject in the page
*/
export async function positionMouseOver(
element: ElementHandle,
position: IPositionInElement = {}
): Promise<string> {
const top = position.top ?? 0.75;
const left = position.left ?? 0.75;
const offsetTop = position.offsetTop ?? 0;
const offsetLeft = position.offsetLeft ?? 0;
const bBox = await element.boundingBox();
return positionMouse({
x: bBox.x + bBox.width * left + offsetLeft,
y: bBox.y + bBox.height * top + offsetTop
});
}

/**
* Set the sidebar width
*
Expand Down
48 changes: 48 additions & 0 deletions galata/test/jupyterlab/notebook-scroll.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.

import { test } from '@jupyterlab/galata';
import { expect } from '@playwright/test';
import * as path from 'path';

const fileName = 'scroll.ipynb';

test.describe('Notebook Scroll', () => {
test.beforeEach(async ({ page, tmpPath }) => {
await page.contents.uploadFile(
path.resolve(__dirname, `./notebooks/${fileName}`),
`${tmpPath}/${fileName}`
);

await page.notebook.openByPath(`${tmpPath}/${fileName}`);
await page.notebook.activate(fileName);
});

test.afterEach(async ({ page, tmpPath }) => {
await page.contents.deleteDirectory(tmpPath);
});

const cellLinks = {
'penultimate cell using heading, legacy format': 18,
'penultimate cell using heading, explicit fragment': 18,
'last cell using heading, legacy format': 19,
'last cell using heading, explicit fragment': 19,
'last cell using cell identifier': 19
};
for (const [link, cellIdx] of Object.entries(cellLinks)) {
test(`Scroll to ${link}`, async ({ page }) => {
const firstCell = await page.notebook.getCell(0);
await firstCell.scrollIntoViewIfNeeded();
expect(await firstCell.boundingBox()).toBeTruthy();

await page.click(`a:has-text("${link}")`);

await firstCell.waitForElementState('hidden');
expect(await firstCell.boundingBox()).toBeFalsy();

const lastCell = await page.notebook.getCell(cellIdx);
await lastCell.waitForElementState('visible');
expect(await lastCell.boundingBox()).toBeTruthy();
});
}
});
Loading

0 comments on commit 6ecb558

Please sign in to comment.