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 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 (canvas-style); the grab cursor is always available and Fit recenters
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
 
@@ -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. The stock CSS (object-fit: contain, max-width/height:
7
- // 100%) still fits the image inside the layer, so the identity layer transform
8
- // equals the correct fit-to-screen view, and stock rotate/flip keeps working.
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 stock
53
- // object-fit/max-width CSS fits the image exactly as before.
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
- apply() {
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.9",
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
- observe(): void {
6
- /* no-op */
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
- /* no-op */
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. The stock CSS (object-fit: contain, max-width/height:
7
- // 100%) still fits the image inside the layer, so the identity layer transform
8
- // equals the correct fit-to-screen view, and stock rotate/flip keeps working.
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 stock
31
- // object-fit/max-width CSS fits the image exactly as before.
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 apply(): void {
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). */