jupyterlab_advanced_image_viewer_extension 1.0.9 → 1.1.0
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 +2 -1
- package/lib/controller.d.ts +5 -0
- package/lib/controller.js +80 -6
- package/lib/index.js +12 -0
- package/package.json +2 -1
- package/src/__tests__/controller.spec.ts +228 -3
- package/src/controller.ts +88 -6
- package/src/index.ts +16 -0
- package/style/base.css +9 -0
package/README.md
CHANGED
|
@@ -18,10 +18,11 @@ The viewer toolbar adds zoom in / out / fit controls and a `help` link that list
|
|
|
18
18
|
|
|
19
19
|
- **Fit to screen** - every image opens scaled to fit the panel (same result as the stock viewer), and reset returns to that fit
|
|
20
20
|
- **Wheel zoom** - scroll to zoom in and out, anchored at the cursor; zoom out as far as you like
|
|
21
|
-
- **Drag to pan** - hold and drag to move the image at any zoom
|
|
21
|
+
- **Drag to pan** - hold and drag to move the image at any zoom; the image can touch a viewport border but never leave it, the grab cursor is always available, and Fit recenters
|
|
22
22
|
- **Toolbar controls** - zoom in, zoom out, and reset-to-fit buttons on the viewer toolbar
|
|
23
23
|
- **Arrow-key navigation** - Left and Right open the previous and next image in the same folder, advancing within one viewer instead of opening new tabs
|
|
24
24
|
- **Copy to clipboard** - right-click a raster image (PNG/JPG/…) and choose "Copy to Clipboard" to copy it as PNG to the system clipboard
|
|
25
|
+
- **Refresh** - a toolbar refresh button reloads the image from disk, the same control the HTML viewer provides
|
|
25
26
|
|
|
26
27
|
## Requirements
|
|
27
28
|
|
package/lib/controller.d.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
export declare class ViewerController {
|
|
2
2
|
private host;
|
|
3
|
+
private img;
|
|
3
4
|
private layer;
|
|
5
|
+
private sizeObserver;
|
|
6
|
+
private styleObserver;
|
|
4
7
|
private s;
|
|
5
8
|
private tx;
|
|
6
9
|
private ty;
|
|
@@ -21,6 +24,8 @@ export declare class ViewerController {
|
|
|
21
24
|
private onMove;
|
|
22
25
|
private onUp;
|
|
23
26
|
private updateCursor;
|
|
27
|
+
private onRenderChange;
|
|
28
|
+
private write;
|
|
24
29
|
private apply;
|
|
25
30
|
dispose(): void;
|
|
26
31
|
}
|
package/lib/controller.js
CHANGED
|
@@ -3,11 +3,41 @@
|
|
|
3
3
|
// scale there for its keybindings (], [, h, v, 0). If we also wrote the <img>
|
|
4
4
|
// transform we would clobber that (and it would clobber ours). Instead we wrap
|
|
5
5
|
// the <img> in a pan layer and apply our translate+scale to the LAYER; the two
|
|
6
|
-
// transforms then compose.
|
|
7
|
-
//
|
|
8
|
-
//
|
|
6
|
+
// transforms then compose. Stock JupyterLab ships only two rules here,
|
|
7
|
+
// .jp-ImageViewer { overflow: auto } and .jp-ImageViewer > img { box-sizing:
|
|
8
|
+
// border-box; transform-origin: top left }. The contain fit comes from the
|
|
9
|
+
// separately installed jupyterlab_fit_image_size_extension, whose
|
|
10
|
+
// .jp-ImageViewer img rule is a descendant selector and so still matches
|
|
11
|
+
// through the pan layer. With that fit in place the identity layer transform
|
|
12
|
+
// equals the fitted view, and stock rotate/flip keeps working.
|
|
9
13
|
const MIN_SCALE = 0.05;
|
|
10
14
|
const MAX_SCALE = 40;
|
|
15
|
+
// Per-axis pan correction, in rendered pixels. An image not larger than the
|
|
16
|
+
// host must stay inside it, so an edge that hangs over a border is pushed
|
|
17
|
+
// back; an image larger than the host must keep covering it, so a gap between
|
|
18
|
+
// an image edge and a border is closed. Differences below half a pixel are
|
|
19
|
+
// dropped: rects are fractional and the baseline transform must stay exactly
|
|
20
|
+
// translate(0px, 0px) scale(1).
|
|
21
|
+
function correction(lo, hi, hostLo, hostHi) {
|
|
22
|
+
let d = 0;
|
|
23
|
+
if (hi - lo <= hostHi - hostLo) {
|
|
24
|
+
if (lo < hostLo) {
|
|
25
|
+
d = hostLo - lo;
|
|
26
|
+
}
|
|
27
|
+
else if (hi > hostHi) {
|
|
28
|
+
d = hostHi - hi;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
if (lo > hostLo) {
|
|
33
|
+
d = hostLo - lo;
|
|
34
|
+
}
|
|
35
|
+
else if (hi < hostHi) {
|
|
36
|
+
d = hostHi - hi;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return Math.abs(d) < 0.5 ? 0 : d;
|
|
40
|
+
}
|
|
11
41
|
export class ViewerController {
|
|
12
42
|
constructor(host, img, zoomStep) {
|
|
13
43
|
this.s = 1;
|
|
@@ -44,13 +74,17 @@ export class ViewerController {
|
|
|
44
74
|
window.removeEventListener('mousemove', this.onMove);
|
|
45
75
|
window.removeEventListener('mouseup', this.onUp);
|
|
46
76
|
};
|
|
77
|
+
this.onRenderChange = () => {
|
|
78
|
+
this.apply();
|
|
79
|
+
};
|
|
47
80
|
this.host = host;
|
|
81
|
+
this.img = img;
|
|
48
82
|
this.zoomStep = zoomStep;
|
|
49
83
|
this.host.style.overflow = 'hidden';
|
|
50
84
|
// Wrap the image so our zoom/pan compose with the stock rotate/flip
|
|
51
85
|
// transform that the viewer writes onto the <img> element itself. The
|
|
52
|
-
// layer fills the host (which is a positioned widget), so the
|
|
53
|
-
//
|
|
86
|
+
// layer fills the host (which is a positioned widget), so the fit CSS
|
|
87
|
+
// sizes the image exactly as before.
|
|
54
88
|
const layer = document.createElement('div');
|
|
55
89
|
layer.className = 'jp-AdvancedImageViewer-panlayer';
|
|
56
90
|
const parent = img.parentNode;
|
|
@@ -61,6 +95,19 @@ export class ViewerController {
|
|
|
61
95
|
this.layer = layer;
|
|
62
96
|
this.host.addEventListener('wheel', this.onWheel, { passive: false });
|
|
63
97
|
this.host.addEventListener('mousedown', this.onDown);
|
|
98
|
+
// Three things move the rendered image without any pan or zoom of ours,
|
|
99
|
+
// and each one changes the range the clamp allows: the panel is resized,
|
|
100
|
+
// the refresh button re-renders a different source into the same <img>,
|
|
101
|
+
// and the stock viewer writes a rotate or flip onto img.style. Only an
|
|
102
|
+
// attribute change reports the last one, since it moves no layout box.
|
|
103
|
+
this.img.addEventListener('load', this.onRenderChange);
|
|
104
|
+
this.styleObserver = new MutationObserver(this.onRenderChange);
|
|
105
|
+
this.styleObserver.observe(this.img, {
|
|
106
|
+
attributes: true,
|
|
107
|
+
attributeFilter: ['style']
|
|
108
|
+
});
|
|
109
|
+
this.sizeObserver = new ResizeObserver(this.onRenderChange);
|
|
110
|
+
this.sizeObserver.observe(this.host);
|
|
64
111
|
this.apply();
|
|
65
112
|
}
|
|
66
113
|
setZoomStep(zoomStep) {
|
|
@@ -108,9 +155,33 @@ export class ViewerController {
|
|
|
108
155
|
updateCursor() {
|
|
109
156
|
this.host.style.cursor = this.panning ? 'grabbing' : 'grab';
|
|
110
157
|
}
|
|
111
|
-
|
|
158
|
+
write() {
|
|
112
159
|
this.layer.style.transformOrigin = 'center center';
|
|
113
160
|
this.layer.style.transform = `translate(${this.tx}px, ${this.ty}px) scale(${this.s})`;
|
|
161
|
+
}
|
|
162
|
+
// The pan clamp is measured, not derived. The layer scales about its centre
|
|
163
|
+
// and the stock viewer writes its own rotate / flip onto the <img>, so the
|
|
164
|
+
// rendered bounds do not follow any offset formula over the natural size.
|
|
165
|
+
// Write the transform, read both boxes, then move the layer by the one
|
|
166
|
+
// correction the measurement asks for. A translate shifts the box 1:1 in
|
|
167
|
+
// host pixels and does not resize it, so a single pass lands on the edge.
|
|
168
|
+
// Doing this in apply() makes the clamp an invariant of every state change:
|
|
169
|
+
// drag, zoom in, zoom out and reset. Zero-sized boxes are skipped, because
|
|
170
|
+
// the widget is hidden or the image has no source yet and any correction
|
|
171
|
+
// measured against them would pin a false offset.
|
|
172
|
+
apply() {
|
|
173
|
+
this.write();
|
|
174
|
+
const h = this.host.getBoundingClientRect();
|
|
175
|
+
const i = this.img.getBoundingClientRect();
|
|
176
|
+
if (h.width > 0 && h.height > 0 && i.width > 0 && i.height > 0) {
|
|
177
|
+
const dx = correction(i.left, i.right, h.left, h.right);
|
|
178
|
+
const dy = correction(i.top, i.bottom, h.top, h.bottom);
|
|
179
|
+
if (dx !== 0 || dy !== 0) {
|
|
180
|
+
this.tx += dx;
|
|
181
|
+
this.ty += dy;
|
|
182
|
+
this.write();
|
|
183
|
+
}
|
|
184
|
+
}
|
|
114
185
|
this.updateCursor();
|
|
115
186
|
}
|
|
116
187
|
dispose() {
|
|
@@ -122,6 +193,9 @@ export class ViewerController {
|
|
|
122
193
|
this.host.removeEventListener('mousedown', this.onDown);
|
|
123
194
|
window.removeEventListener('mousemove', this.onMove);
|
|
124
195
|
window.removeEventListener('mouseup', this.onUp);
|
|
196
|
+
this.img.removeEventListener('load', this.onRenderChange);
|
|
197
|
+
this.styleObserver.disconnect();
|
|
198
|
+
this.sizeObserver.disconnect();
|
|
125
199
|
// Unwrap: restore the <img> as a direct child and drop the layer.
|
|
126
200
|
const img = this.layer.querySelector('img');
|
|
127
201
|
const parent = this.layer.parentNode;
|
package/lib/index.js
CHANGED
|
@@ -2,6 +2,7 @@ import { Dialog, showDialog, Toolbar, ToolbarButton } from '@jupyterlab/apputils
|
|
|
2
2
|
import { PathExt } from '@jupyterlab/coreutils';
|
|
3
3
|
import { IImageTracker } from '@jupyterlab/imageviewer';
|
|
4
4
|
import { ISettingRegistry } from '@jupyterlab/settingregistry';
|
|
5
|
+
import { refreshIcon } from '@jupyterlab/ui-components';
|
|
5
6
|
import { Widget } from '@lumino/widgets';
|
|
6
7
|
import { ViewerController } from './controller';
|
|
7
8
|
const PLUGIN_ID = 'jupyterlab_advanced_image_viewer_extension:plugin';
|
|
@@ -90,6 +91,17 @@ const plugin = {
|
|
|
90
91
|
controller.dispose();
|
|
91
92
|
controllers.delete(viewer);
|
|
92
93
|
});
|
|
94
|
+
// Reload from disk, like the HTML viewer's refresh button. That button
|
|
95
|
+
// skips the reload while the model is dirty; an image model is never
|
|
96
|
+
// edited, so there is nothing here to discard.
|
|
97
|
+
widget.toolbar.addItem('advanced-refresh', new ToolbarButton({
|
|
98
|
+
icon: refreshIcon,
|
|
99
|
+
tooltip: 'Reload image from disk',
|
|
100
|
+
onClick: async () => {
|
|
101
|
+
await widget.context.revert();
|
|
102
|
+
widget.update();
|
|
103
|
+
}
|
|
104
|
+
}));
|
|
93
105
|
widget.toolbar.addItem('advanced-zoom-out', new ToolbarButton({
|
|
94
106
|
label: '-',
|
|
95
107
|
tooltip: 'Zoom out',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jupyterlab_advanced_image_viewer_extension",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "JupyterLab extension for advanced image viewing: fit-to-screen, cursor-anchored wheel zoom, drag-to-pan, and Left/Right arrow keys to move to the previous and next image in a folder",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"jupyter",
|
|
@@ -62,6 +62,7 @@
|
|
|
62
62
|
"@jupyterlab/docregistry": "^4.0.0",
|
|
63
63
|
"@jupyterlab/imageviewer": "^4.0.0",
|
|
64
64
|
"@jupyterlab/settingregistry": "^4.0.0",
|
|
65
|
+
"@jupyterlab/ui-components": "^4.0.0",
|
|
65
66
|
"@lumino/disposable": "^2.0.0",
|
|
66
67
|
"@lumino/widgets": "^2.0.0"
|
|
67
68
|
},
|
|
@@ -2,20 +2,96 @@ import { ViewerController } from '../controller';
|
|
|
2
2
|
|
|
3
3
|
// jsdom has no ResizeObserver; provide a no-op stub.
|
|
4
4
|
class MockResizeObserver {
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
static instances: MockResizeObserver[] = [];
|
|
6
|
+
callback: () => void;
|
|
7
|
+
disconnected = false;
|
|
8
|
+
targets: Element[] = [];
|
|
9
|
+
|
|
10
|
+
constructor(callback: () => void) {
|
|
11
|
+
this.callback = callback;
|
|
12
|
+
MockResizeObserver.instances.push(this);
|
|
13
|
+
}
|
|
14
|
+
observe(t: Element): void {
|
|
15
|
+
this.targets.push(t);
|
|
7
16
|
}
|
|
8
17
|
unobserve(): void {
|
|
9
18
|
/* no-op */
|
|
10
19
|
}
|
|
11
20
|
disconnect(): void {
|
|
12
|
-
|
|
21
|
+
this.disconnected = true;
|
|
13
22
|
}
|
|
14
23
|
}
|
|
15
24
|
|
|
25
|
+
const HOST_X = 50;
|
|
26
|
+
const HOST_Y = 30;
|
|
27
|
+
const HOST_W = 200;
|
|
28
|
+
const HOST_H = 100;
|
|
29
|
+
|
|
30
|
+
function rect(left: number, top: number, w: number, h: number): DOMRect {
|
|
31
|
+
return {
|
|
32
|
+
x: left,
|
|
33
|
+
y: top,
|
|
34
|
+
left,
|
|
35
|
+
top,
|
|
36
|
+
right: left + w,
|
|
37
|
+
bottom: top + h,
|
|
38
|
+
width: w,
|
|
39
|
+
height: h,
|
|
40
|
+
toJSON: () => ({})
|
|
41
|
+
} as DOMRect;
|
|
42
|
+
}
|
|
43
|
+
|
|
16
44
|
describe('ViewerController', () => {
|
|
17
45
|
let host: HTMLElement;
|
|
18
46
|
let img: HTMLImageElement;
|
|
47
|
+
let baseW = 0;
|
|
48
|
+
let baseH = 0;
|
|
49
|
+
|
|
50
|
+
// jsdom runs no layout, so model the geometry the clamp measures: the host
|
|
51
|
+
// is a fixed box at (HOST_X, HOST_Y), away from the viewport origin, and the
|
|
52
|
+
// image is a baseW x baseH box at the layer top-left, moved by the layer
|
|
53
|
+
// transform the controller writes (translate in host pixels, scale about the
|
|
54
|
+
// layer centre). Call it again to change the rendered size, as a reload or a
|
|
55
|
+
// stock rotate does.
|
|
56
|
+
function stubGeometry(w: number, h: number): void {
|
|
57
|
+
baseW = w;
|
|
58
|
+
baseH = h;
|
|
59
|
+
host.getBoundingClientRect = () => rect(HOST_X, HOST_Y, HOST_W, HOST_H);
|
|
60
|
+
img.getBoundingClientRect = () => {
|
|
61
|
+
const layer = host.querySelector(
|
|
62
|
+
'.jp-AdvancedImageViewer-panlayer'
|
|
63
|
+
) as HTMLElement;
|
|
64
|
+
const m =
|
|
65
|
+
/translate\((-?[\d.]+)px, (-?[\d.]+)px\) scale\(([\d.]+)\)/.exec(
|
|
66
|
+
layer.style.transform
|
|
67
|
+
)!;
|
|
68
|
+
const s = parseFloat(m[3]);
|
|
69
|
+
return rect(
|
|
70
|
+
HOST_X + (1 - s) * (HOST_W / 2) + parseFloat(m[1]),
|
|
71
|
+
HOST_Y + (1 - s) * (HOST_H / 2) + parseFloat(m[2]),
|
|
72
|
+
baseW * s,
|
|
73
|
+
baseH * s
|
|
74
|
+
);
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function drag(dx: number, dy: number): void {
|
|
79
|
+
host.dispatchEvent(new MouseEvent('mousedown', { clientX: 0, clientY: 0 }));
|
|
80
|
+
window.dispatchEvent(
|
|
81
|
+
new MouseEvent('mousemove', { clientX: dx, clientY: dy })
|
|
82
|
+
);
|
|
83
|
+
window.dispatchEvent(new MouseEvent('mouseup'));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function offset(): { tx: number; ty: number } {
|
|
87
|
+
const layer = host.querySelector(
|
|
88
|
+
'.jp-AdvancedImageViewer-panlayer'
|
|
89
|
+
) as HTMLElement;
|
|
90
|
+
const m = /translate\((-?[\d.]+)px, (-?[\d.]+)px\)/.exec(
|
|
91
|
+
layer.style.transform
|
|
92
|
+
)!;
|
|
93
|
+
return { tx: parseFloat(m[1]), ty: parseFloat(m[2]) };
|
|
94
|
+
}
|
|
19
95
|
|
|
20
96
|
beforeAll(() => {
|
|
21
97
|
(window as any).ResizeObserver = MockResizeObserver;
|
|
@@ -26,6 +102,7 @@ describe('ViewerController', () => {
|
|
|
26
102
|
img = document.createElement('img');
|
|
27
103
|
host.appendChild(img);
|
|
28
104
|
document.body.appendChild(host);
|
|
105
|
+
MockResizeObserver.instances = [];
|
|
29
106
|
});
|
|
30
107
|
|
|
31
108
|
afterEach(() => {
|
|
@@ -81,4 +158,152 @@ describe('ViewerController', () => {
|
|
|
81
158
|
expect(host.querySelector('.jp-AdvancedImageViewer-panlayer')).toBeNull();
|
|
82
159
|
expect(img.parentElement).toBe(host);
|
|
83
160
|
});
|
|
161
|
+
|
|
162
|
+
// ACC-PAN-38: an image smaller than the host stops with its edge flush.
|
|
163
|
+
it('keeps an image smaller than the host inside the viewport', () => {
|
|
164
|
+
stubGeometry(80, 60);
|
|
165
|
+
const c = new ViewerController(host, img, 0.1);
|
|
166
|
+
expect(offset()).toEqual({ tx: 0, ty: 0 });
|
|
167
|
+
|
|
168
|
+
// Both drags stay inside the legal range (x in [0, 120], y in [0, 40]),
|
|
169
|
+
// so the offset follows the drag delta with the clamp active.
|
|
170
|
+
drag(10, 10);
|
|
171
|
+
drag(10, 10);
|
|
172
|
+
expect(offset()).toEqual({ tx: 20, ty: 20 });
|
|
173
|
+
|
|
174
|
+
drag(500, 0);
|
|
175
|
+
expect(offset().tx).toBe(120);
|
|
176
|
+
expect(img.getBoundingClientRect().right).toBe(HOST_X + HOST_W);
|
|
177
|
+
expect(img.getBoundingClientRect().left).toBeGreaterThanOrEqual(HOST_X);
|
|
178
|
+
|
|
179
|
+
drag(-500, -500);
|
|
180
|
+
expect(offset()).toEqual({ tx: 0, ty: 0 });
|
|
181
|
+
expect(img.getBoundingClientRect().left).toBe(HOST_X);
|
|
182
|
+
expect(img.getBoundingClientRect().top).toBe(HOST_Y);
|
|
183
|
+
c.dispose();
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// ACC-PAN-39: an image larger than the host never uncovers it.
|
|
187
|
+
it('keeps an image larger than the host covering the viewport', () => {
|
|
188
|
+
stubGeometry(400, 300);
|
|
189
|
+
const c = new ViewerController(host, img, 0.1);
|
|
190
|
+
expect(offset()).toEqual({ tx: 0, ty: 0 });
|
|
191
|
+
|
|
192
|
+
drag(-500, 0);
|
|
193
|
+
expect(offset().tx).toBe(-200);
|
|
194
|
+
expect(img.getBoundingClientRect().right).toBe(HOST_X + HOST_W);
|
|
195
|
+
expect(img.getBoundingClientRect().left).toBeLessThanOrEqual(HOST_X);
|
|
196
|
+
|
|
197
|
+
drag(500, 0);
|
|
198
|
+
expect(offset().tx).toBe(0);
|
|
199
|
+
expect(img.getBoundingClientRect().left).toBe(HOST_X);
|
|
200
|
+
|
|
201
|
+
drag(0, -500);
|
|
202
|
+
expect(offset().ty).toBe(-200);
|
|
203
|
+
expect(img.getBoundingClientRect().bottom).toBe(HOST_Y + HOST_H);
|
|
204
|
+
c.dispose();
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// ACC-PAN-40: the clamp is an invariant, so shrinking the legal range by
|
|
208
|
+
// zooming out pulls an offset that was legal at the larger scale back in.
|
|
209
|
+
it('snaps an out-of-range offset back on zoom-out', () => {
|
|
210
|
+
stubGeometry(400, 300);
|
|
211
|
+
const c = new ViewerController(host, img, 1);
|
|
212
|
+
c.zoomIn();
|
|
213
|
+
drag(-1000, 0);
|
|
214
|
+
expect(offset().tx).toBe(-500);
|
|
215
|
+
|
|
216
|
+
c.zoomOut();
|
|
217
|
+
// Undoing the zoom alone would leave tx at -250, which is outside the
|
|
218
|
+
// range [-200, 0] that a 400px-wide image has over a 200px host.
|
|
219
|
+
expect(offset().tx).toBe(-200);
|
|
220
|
+
expect(img.getBoundingClientRect().right).toBe(HOST_X + HOST_W);
|
|
221
|
+
c.dispose();
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// ACC-PAN-41: the panel resize changes the range, the ResizeObserver reports
|
|
225
|
+
// it and the stored offset is measured against the new one.
|
|
226
|
+
it('re-clamps when the host is resized', () => {
|
|
227
|
+
stubGeometry(80, 60);
|
|
228
|
+
const c = new ViewerController(host, img, 0.1);
|
|
229
|
+
drag(500, 0);
|
|
230
|
+
expect(offset().tx).toBe(120);
|
|
231
|
+
|
|
232
|
+
host.getBoundingClientRect = () => rect(HOST_X, HOST_Y, 100, HOST_H);
|
|
233
|
+
expect(MockResizeObserver.instances[0].targets).toContain(host);
|
|
234
|
+
MockResizeObserver.instances[0].callback();
|
|
235
|
+
expect(offset().tx).toBe(20);
|
|
236
|
+
expect(img.getBoundingClientRect().right).toBe(HOST_X + 100);
|
|
237
|
+
c.dispose();
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
// ACC-PAN-42: a refresh renders a differently sized source into the same
|
|
241
|
+
// <img>, and the load event re-clamps the stored offset against it.
|
|
242
|
+
it('re-clamps when a differently sized image loads', () => {
|
|
243
|
+
stubGeometry(80, 60);
|
|
244
|
+
const c = new ViewerController(host, img, 0.1);
|
|
245
|
+
drag(500, 0);
|
|
246
|
+
expect(offset().tx).toBe(120);
|
|
247
|
+
|
|
248
|
+
stubGeometry(150, 60);
|
|
249
|
+
img.dispatchEvent(new Event('load'));
|
|
250
|
+
expect(offset().tx).toBe(50);
|
|
251
|
+
expect(img.getBoundingClientRect().right).toBe(HOST_X + HOST_W);
|
|
252
|
+
c.dispose();
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
// ACC-PAN-43: a stock rotate or flip changes the rendered bounds through
|
|
256
|
+
// img.style alone, so the clamp is recomputed from the measured box.
|
|
257
|
+
it('re-clamps on a stock rotate written to the image style', async () => {
|
|
258
|
+
stubGeometry(80, 60);
|
|
259
|
+
const c = new ViewerController(host, img, 0.1);
|
|
260
|
+
drag(500, 0);
|
|
261
|
+
expect(offset().tx).toBe(120);
|
|
262
|
+
|
|
263
|
+
stubGeometry(160, 60);
|
|
264
|
+
img.style.transform = 'rotate(90deg)';
|
|
265
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
266
|
+
expect(offset().tx).toBe(40);
|
|
267
|
+
expect(img.getBoundingClientRect().right).toBe(HOST_X + HOST_W);
|
|
268
|
+
c.dispose();
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
// A hidden widget and an image with no source yet both measure zero, and a
|
|
272
|
+
// correction taken against them would pin a false offset.
|
|
273
|
+
it('does not clamp against zero-sized boxes', () => {
|
|
274
|
+
host.getBoundingClientRect = () => rect(50, 30, HOST_W, HOST_H);
|
|
275
|
+
img.getBoundingClientRect = () => rect(0, 0, 0, 0);
|
|
276
|
+
const c = new ViewerController(host, img, 0.1);
|
|
277
|
+
const layer = host.querySelector(
|
|
278
|
+
'.jp-AdvancedImageViewer-panlayer'
|
|
279
|
+
) as HTMLElement;
|
|
280
|
+
expect(layer.style.transform).toBe('translate(0px, 0px) scale(1)');
|
|
281
|
+
|
|
282
|
+
drag(40, 20);
|
|
283
|
+
expect(layer.style.transform).toBe('translate(40px, 20px) scale(1)');
|
|
284
|
+
c.dispose();
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
// A rendered box overhanging a border by less than half a pixel is inside
|
|
288
|
+
// the deadband, so the baseline transform stays exactly at the identity.
|
|
289
|
+
it('leaves a sub-pixel overhang uncorrected', () => {
|
|
290
|
+
host.getBoundingClientRect = () => rect(HOST_X, HOST_Y, HOST_W, HOST_H);
|
|
291
|
+
img.getBoundingClientRect = () => rect(HOST_X - 0.4, HOST_Y, 80, 60);
|
|
292
|
+
const c = new ViewerController(host, img, 0.1);
|
|
293
|
+
const layer = host.querySelector(
|
|
294
|
+
'.jp-AdvancedImageViewer-panlayer'
|
|
295
|
+
) as HTMLElement;
|
|
296
|
+
expect(layer.style.transform).toBe('translate(0px, 0px) scale(1)');
|
|
297
|
+
c.dispose();
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it('stops observing the host and the image on dispose', () => {
|
|
301
|
+
stubGeometry(80, 60);
|
|
302
|
+
const c = new ViewerController(host, img, 0.1);
|
|
303
|
+
c.dispose();
|
|
304
|
+
expect(MockResizeObserver.instances[0].disconnected).toBe(true);
|
|
305
|
+
// The geometry stub needs the layer, which dispose removed, so a surviving
|
|
306
|
+
// load listener would throw here.
|
|
307
|
+
expect(() => img.dispatchEvent(new Event('load'))).not.toThrow();
|
|
308
|
+
});
|
|
84
309
|
});
|
package/src/controller.ts
CHANGED
|
@@ -3,16 +3,52 @@
|
|
|
3
3
|
// scale there for its keybindings (], [, h, v, 0). If we also wrote the <img>
|
|
4
4
|
// transform we would clobber that (and it would clobber ours). Instead we wrap
|
|
5
5
|
// the <img> in a pan layer and apply our translate+scale to the LAYER; the two
|
|
6
|
-
// transforms then compose.
|
|
7
|
-
//
|
|
8
|
-
//
|
|
6
|
+
// transforms then compose. Stock JupyterLab ships only two rules here,
|
|
7
|
+
// .jp-ImageViewer { overflow: auto } and .jp-ImageViewer > img { box-sizing:
|
|
8
|
+
// border-box; transform-origin: top left }. The contain fit comes from the
|
|
9
|
+
// separately installed jupyterlab_fit_image_size_extension, whose
|
|
10
|
+
// .jp-ImageViewer img rule is a descendant selector and so still matches
|
|
11
|
+
// through the pan layer. With that fit in place the identity layer transform
|
|
12
|
+
// equals the fitted view, and stock rotate/flip keeps working.
|
|
9
13
|
|
|
10
14
|
const MIN_SCALE = 0.05;
|
|
11
15
|
const MAX_SCALE = 40;
|
|
12
16
|
|
|
17
|
+
// Per-axis pan correction, in rendered pixels. An image not larger than the
|
|
18
|
+
// host must stay inside it, so an edge that hangs over a border is pushed
|
|
19
|
+
// back; an image larger than the host must keep covering it, so a gap between
|
|
20
|
+
// an image edge and a border is closed. Differences below half a pixel are
|
|
21
|
+
// dropped: rects are fractional and the baseline transform must stay exactly
|
|
22
|
+
// translate(0px, 0px) scale(1).
|
|
23
|
+
function correction(
|
|
24
|
+
lo: number,
|
|
25
|
+
hi: number,
|
|
26
|
+
hostLo: number,
|
|
27
|
+
hostHi: number
|
|
28
|
+
): number {
|
|
29
|
+
let d = 0;
|
|
30
|
+
if (hi - lo <= hostHi - hostLo) {
|
|
31
|
+
if (lo < hostLo) {
|
|
32
|
+
d = hostLo - lo;
|
|
33
|
+
} else if (hi > hostHi) {
|
|
34
|
+
d = hostHi - hi;
|
|
35
|
+
}
|
|
36
|
+
} else {
|
|
37
|
+
if (lo > hostLo) {
|
|
38
|
+
d = hostLo - lo;
|
|
39
|
+
} else if (hi < hostHi) {
|
|
40
|
+
d = hostHi - hi;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return Math.abs(d) < 0.5 ? 0 : d;
|
|
44
|
+
}
|
|
45
|
+
|
|
13
46
|
export class ViewerController {
|
|
14
47
|
private host: HTMLElement;
|
|
48
|
+
private img: HTMLImageElement;
|
|
15
49
|
private layer: HTMLElement;
|
|
50
|
+
private sizeObserver: ResizeObserver;
|
|
51
|
+
private styleObserver: MutationObserver;
|
|
16
52
|
private s = 1;
|
|
17
53
|
private tx = 0;
|
|
18
54
|
private ty = 0;
|
|
@@ -23,12 +59,13 @@ export class ViewerController {
|
|
|
23
59
|
|
|
24
60
|
constructor(host: HTMLElement, img: HTMLImageElement, zoomStep: number) {
|
|
25
61
|
this.host = host;
|
|
62
|
+
this.img = img;
|
|
26
63
|
this.zoomStep = zoomStep;
|
|
27
64
|
this.host.style.overflow = 'hidden';
|
|
28
65
|
// Wrap the image so our zoom/pan compose with the stock rotate/flip
|
|
29
66
|
// transform that the viewer writes onto the <img> element itself. The
|
|
30
|
-
// layer fills the host (which is a positioned widget), so the
|
|
31
|
-
//
|
|
67
|
+
// layer fills the host (which is a positioned widget), so the fit CSS
|
|
68
|
+
// sizes the image exactly as before.
|
|
32
69
|
const layer = document.createElement('div');
|
|
33
70
|
layer.className = 'jp-AdvancedImageViewer-panlayer';
|
|
34
71
|
const parent = img.parentNode;
|
|
@@ -39,6 +76,19 @@ export class ViewerController {
|
|
|
39
76
|
this.layer = layer;
|
|
40
77
|
this.host.addEventListener('wheel', this.onWheel, { passive: false });
|
|
41
78
|
this.host.addEventListener('mousedown', this.onDown);
|
|
79
|
+
// Three things move the rendered image without any pan or zoom of ours,
|
|
80
|
+
// and each one changes the range the clamp allows: the panel is resized,
|
|
81
|
+
// the refresh button re-renders a different source into the same <img>,
|
|
82
|
+
// and the stock viewer writes a rotate or flip onto img.style. Only an
|
|
83
|
+
// attribute change reports the last one, since it moves no layout box.
|
|
84
|
+
this.img.addEventListener('load', this.onRenderChange);
|
|
85
|
+
this.styleObserver = new MutationObserver(this.onRenderChange);
|
|
86
|
+
this.styleObserver.observe(this.img, {
|
|
87
|
+
attributes: true,
|
|
88
|
+
attributeFilter: ['style']
|
|
89
|
+
});
|
|
90
|
+
this.sizeObserver = new ResizeObserver(this.onRenderChange);
|
|
91
|
+
this.sizeObserver.observe(this.host);
|
|
42
92
|
this.apply();
|
|
43
93
|
}
|
|
44
94
|
|
|
@@ -127,9 +177,38 @@ export class ViewerController {
|
|
|
127
177
|
this.host.style.cursor = this.panning ? 'grabbing' : 'grab';
|
|
128
178
|
}
|
|
129
179
|
|
|
130
|
-
private
|
|
180
|
+
private onRenderChange = (): void => {
|
|
181
|
+
this.apply();
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
private write(): void {
|
|
131
185
|
this.layer.style.transformOrigin = 'center center';
|
|
132
186
|
this.layer.style.transform = `translate(${this.tx}px, ${this.ty}px) scale(${this.s})`;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// The pan clamp is measured, not derived. The layer scales about its centre
|
|
190
|
+
// and the stock viewer writes its own rotate / flip onto the <img>, so the
|
|
191
|
+
// rendered bounds do not follow any offset formula over the natural size.
|
|
192
|
+
// Write the transform, read both boxes, then move the layer by the one
|
|
193
|
+
// correction the measurement asks for. A translate shifts the box 1:1 in
|
|
194
|
+
// host pixels and does not resize it, so a single pass lands on the edge.
|
|
195
|
+
// Doing this in apply() makes the clamp an invariant of every state change:
|
|
196
|
+
// drag, zoom in, zoom out and reset. Zero-sized boxes are skipped, because
|
|
197
|
+
// the widget is hidden or the image has no source yet and any correction
|
|
198
|
+
// measured against them would pin a false offset.
|
|
199
|
+
private apply(): void {
|
|
200
|
+
this.write();
|
|
201
|
+
const h = this.host.getBoundingClientRect();
|
|
202
|
+
const i = this.img.getBoundingClientRect();
|
|
203
|
+
if (h.width > 0 && h.height > 0 && i.width > 0 && i.height > 0) {
|
|
204
|
+
const dx = correction(i.left, i.right, h.left, h.right);
|
|
205
|
+
const dy = correction(i.top, i.bottom, h.top, h.bottom);
|
|
206
|
+
if (dx !== 0 || dy !== 0) {
|
|
207
|
+
this.tx += dx;
|
|
208
|
+
this.ty += dy;
|
|
209
|
+
this.write();
|
|
210
|
+
}
|
|
211
|
+
}
|
|
133
212
|
this.updateCursor();
|
|
134
213
|
}
|
|
135
214
|
|
|
@@ -142,6 +221,9 @@ export class ViewerController {
|
|
|
142
221
|
this.host.removeEventListener('mousedown', this.onDown);
|
|
143
222
|
window.removeEventListener('mousemove', this.onMove);
|
|
144
223
|
window.removeEventListener('mouseup', this.onUp);
|
|
224
|
+
this.img.removeEventListener('load', this.onRenderChange);
|
|
225
|
+
this.styleObserver.disconnect();
|
|
226
|
+
this.sizeObserver.disconnect();
|
|
145
227
|
// Unwrap: restore the <img> as a direct child and drop the layer.
|
|
146
228
|
const img = this.layer.querySelector('img');
|
|
147
229
|
const parent = this.layer.parentNode;
|
package/src/index.ts
CHANGED
|
@@ -18,6 +18,8 @@ import { IImageTracker, ImageViewer } from '@jupyterlab/imageviewer';
|
|
|
18
18
|
|
|
19
19
|
import { ISettingRegistry } from '@jupyterlab/settingregistry';
|
|
20
20
|
|
|
21
|
+
import { refreshIcon } from '@jupyterlab/ui-components';
|
|
22
|
+
|
|
21
23
|
import { IDisposable } from '@lumino/disposable';
|
|
22
24
|
|
|
23
25
|
import { Widget } from '@lumino/widgets';
|
|
@@ -134,6 +136,20 @@ const plugin: JupyterFrontEndPlugin<void> = {
|
|
|
134
136
|
controllers.delete(viewer);
|
|
135
137
|
});
|
|
136
138
|
|
|
139
|
+
// Reload from disk, like the HTML viewer's refresh button. That button
|
|
140
|
+
// skips the reload while the model is dirty; an image model is never
|
|
141
|
+
// edited, so there is nothing here to discard.
|
|
142
|
+
widget.toolbar.addItem(
|
|
143
|
+
'advanced-refresh',
|
|
144
|
+
new ToolbarButton({
|
|
145
|
+
icon: refreshIcon,
|
|
146
|
+
tooltip: 'Reload image from disk',
|
|
147
|
+
onClick: async () => {
|
|
148
|
+
await widget.context.revert();
|
|
149
|
+
widget.update();
|
|
150
|
+
}
|
|
151
|
+
})
|
|
152
|
+
);
|
|
137
153
|
widget.toolbar.addItem(
|
|
138
154
|
'advanced-zoom-out',
|
|
139
155
|
new ToolbarButton({
|
package/style/base.css
CHANGED
|
@@ -12,6 +12,15 @@
|
|
|
12
12
|
transform-origin: center center;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
/* The stock rule that gives these two values is `.jp-ImageViewer > img`, a
|
|
16
|
+
direct-child selector the pan layer breaks. The stock rotate/flip transform
|
|
17
|
+
is written for a top-left origin, so without this the image jumps sideways
|
|
18
|
+
on rotate and the pan clamp then drags it to a border. */
|
|
19
|
+
.jp-AdvancedImageViewer-panlayer > img {
|
|
20
|
+
box-sizing: border-box;
|
|
21
|
+
transform-origin: top left;
|
|
22
|
+
}
|
|
23
|
+
|
|
15
24
|
/* Help shown as an accent-coloured link at the right end of the toolbar.
|
|
16
25
|
The selector is scoped under .jp-Toolbar to beat JupyterLab's toolbar `a`
|
|
17
26
|
rule (otherwise the link renders in the default muted toolbar colour). */
|