jupyterlab_advanced_image_viewer_extension 1.0.2 → 1.0.4

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.
@@ -1,6 +1,6 @@
1
1
  export declare class ViewerController {
2
2
  private host;
3
- private img;
3
+ private layer;
4
4
  private s;
5
5
  private tx;
6
6
  private ty;
package/lib/controller.js CHANGED
@@ -1,10 +1,11 @@
1
1
  // ViewerController adds zoom + pan ON TOP of JupyterLab's stock image viewer.
2
- // The stock viewer already fits the image with CSS (object-fit: contain,
3
- // max-width/height: 100%), so the identity transform IS the correct
4
- // fit-to-screen view for both raster and SVG. We never read naturalWidth /
5
- // naturalHeight (an SVG with only a viewBox reports a bogus intrinsic size).
6
- // Zoom is a single relative factor s (s = 1 is fit); pan is a translate.
7
- // transform-origin is the host centre, matching the centred object-fit layout.
2
+ // The stock viewer OWNS the <img> transform - it writes rotate / flip / its own
3
+ // scale there for its keybindings (], [, h, v, 0). If we also wrote the <img>
4
+ // transform we would clobber that (and it would clobber ours). Instead we wrap
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.
8
9
  const MIN_SCALE = 0.05;
9
10
  const MAX_SCALE = 40;
10
11
  export class ViewerController {
@@ -48,10 +49,20 @@ export class ViewerController {
48
49
  window.removeEventListener('mouseup', this.onUp);
49
50
  };
50
51
  this.host = host;
51
- this.img = img;
52
52
  this.zoomStep = zoomStep;
53
53
  this.host.style.overflow = 'hidden';
54
- this.img.style.transformOrigin = 'center center';
54
+ // Wrap the image so our zoom/pan compose with the stock rotate/flip
55
+ // transform that the viewer writes onto the <img> element itself. The
56
+ // layer fills the host (which is a positioned widget), so the stock
57
+ // object-fit/max-width CSS fits the image exactly as before.
58
+ const layer = document.createElement('div');
59
+ layer.className = 'jp-AdvancedImageViewer-panlayer';
60
+ const parent = img.parentNode;
61
+ if (parent) {
62
+ parent.insertBefore(layer, img);
63
+ }
64
+ layer.appendChild(img);
65
+ this.layer = layer;
55
66
  this.host.addEventListener('wheel', this.onWheel, { passive: false });
56
67
  this.host.addEventListener('mousedown', this.onDown);
57
68
  this.ro = new ResizeObserver(() => this.onResize());
@@ -119,8 +130,8 @@ export class ViewerController {
119
130
  this.host.style.cursor = this.canPan() ? 'grab' : 'default';
120
131
  }
121
132
  apply() {
122
- this.img.style.transformOrigin = 'center center';
123
- this.img.style.transform = `translate(${this.tx}px, ${this.ty}px) scale(${this.s})`;
133
+ this.layer.style.transformOrigin = 'center center';
134
+ this.layer.style.transform = `translate(${this.tx}px, ${this.ty}px) scale(${this.s})`;
124
135
  this.updateCursor();
125
136
  }
126
137
  dispose() {
@@ -133,5 +144,12 @@ export class ViewerController {
133
144
  this.host.removeEventListener('mousedown', this.onDown);
134
145
  window.removeEventListener('mousemove', this.onMove);
135
146
  window.removeEventListener('mouseup', this.onUp);
147
+ // Unwrap: restore the <img> as a direct child and drop the layer.
148
+ const img = this.layer.querySelector('img');
149
+ const parent = this.layer.parentNode;
150
+ if (img && parent) {
151
+ parent.insertBefore(img, this.layer);
152
+ }
153
+ this.layer.remove();
136
154
  }
137
155
  }
package/lib/index.js CHANGED
@@ -1,7 +1,8 @@
1
- import { ToolbarButton } from '@jupyterlab/apputils';
1
+ 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 { Widget } from '@lumino/widgets';
5
6
  import { ViewerController } from './controller';
6
7
  const PLUGIN_ID = 'jupyterlab_advanced_image_viewer_extension:plugin';
7
8
  const CommandIDs = {
@@ -43,6 +44,32 @@ const plugin = {
43
44
  }
44
45
  return (_a = controllers.get(widget.content)) !== null && _a !== void 0 ? _a : null;
45
46
  };
47
+ const showHelp = () => {
48
+ const body = new Widget();
49
+ body.addClass('jp-AdvancedImageViewer-help');
50
+ body.node.innerHTML = [
51
+ '<p>Interactive viewing layered on the standard image viewer.</p>',
52
+ '<p class="jp-AdvancedImageViewer-help-section">This extension</p>',
53
+ '<ul>',
54
+ '<li><b>Wheel up / down</b> - zoom in / out at the cursor</li>',
55
+ '<li><b>Click and drag</b> - pan a zoomed-in image</li>',
56
+ '<li><b>+ / - / Fit</b> - zoom in, zoom out, reset to fit</li>',
57
+ '<li><b>Left / Right arrows</b> - previous / next image in the folder</li>',
58
+ '</ul>',
59
+ '<p class="jp-AdvancedImageViewer-help-section">Standard viewer keys (compose with the above)</p>',
60
+ '<ul>',
61
+ '<li><b>] / [</b> - rotate clockwise / counter-clockwise</li>',
62
+ '<li><b>H / V</b> - flip horizontal / vertical</li>',
63
+ '<li><b>I</b> - invert colours</li>',
64
+ '<li><b>= / - / 0</b> - zoom in / out / reset</li>',
65
+ '</ul>'
66
+ ].join('');
67
+ void showDialog({
68
+ title: 'Advanced Image Viewer',
69
+ body,
70
+ buttons: [Dialog.okButton({ label: 'Close' })]
71
+ });
72
+ };
46
73
  const attach = (widget) => {
47
74
  const viewer = widget.content;
48
75
  if (controllers.has(viewer)) {
@@ -74,6 +101,18 @@ const plugin = {
74
101
  tooltip: 'Reset to fit',
75
102
  onClick: () => app.commands.execute(CommandIDs.resetFit)
76
103
  }));
104
+ widget.toolbar.addItem('advanced-spacer', Toolbar.createSpacerItem());
105
+ const helpLink = new Widget({ node: document.createElement('a') });
106
+ const helpAnchor = helpLink.node;
107
+ helpAnchor.textContent = 'help';
108
+ helpAnchor.href = '#';
109
+ helpAnchor.title = 'Keybindings and help';
110
+ helpAnchor.className = 'jp-AdvancedImageViewer-help-link';
111
+ helpAnchor.addEventListener('click', event => {
112
+ event.preventDefault();
113
+ showHelp();
114
+ });
115
+ widget.toolbar.addItem('advanced-help', helpLink);
77
116
  };
78
117
  tracker.forEach(widget => attach(widget));
79
118
  tracker.widgetAdded.connect((_, widget) => attach(widget));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jupyterlab_advanced_image_viewer_extension",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
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,7 +62,8 @@
62
62
  "@jupyterlab/docregistry": "^4.0.0",
63
63
  "@jupyterlab/imageviewer": "^4.0.0",
64
64
  "@jupyterlab/settingregistry": "^4.0.0",
65
- "@lumino/disposable": "^2.0.0"
65
+ "@lumino/disposable": "^2.0.0",
66
+ "@lumino/widgets": "^2.0.0"
66
67
  },
67
68
  "devDependencies": {
68
69
  "@jupyterlab/builder": "^4.0.0",
@@ -0,0 +1,84 @@
1
+ import { ViewerController } from '../controller';
2
+
3
+ // jsdom has no ResizeObserver; provide a no-op stub.
4
+ class MockResizeObserver {
5
+ observe(): void {
6
+ /* no-op */
7
+ }
8
+ unobserve(): void {
9
+ /* no-op */
10
+ }
11
+ disconnect(): void {
12
+ /* no-op */
13
+ }
14
+ }
15
+
16
+ describe('ViewerController', () => {
17
+ let host: HTMLElement;
18
+ let img: HTMLImageElement;
19
+
20
+ beforeAll(() => {
21
+ (window as any).ResizeObserver = MockResizeObserver;
22
+ });
23
+
24
+ beforeEach(() => {
25
+ host = document.createElement('div');
26
+ img = document.createElement('img');
27
+ host.appendChild(img);
28
+ document.body.appendChild(host);
29
+ });
30
+
31
+ afterEach(() => {
32
+ host.remove();
33
+ });
34
+
35
+ it('wraps the image in a pan layer so the stock viewer keeps the img transform', () => {
36
+ const c = new ViewerController(host, img, 0.1);
37
+ const layer = host.querySelector('.jp-AdvancedImageViewer-panlayer');
38
+ expect(layer).not.toBeNull();
39
+ expect(img.parentElement).toBe(layer);
40
+ c.dispose();
41
+ });
42
+
43
+ it('applies the transform to the layer, never to the image', () => {
44
+ const c = new ViewerController(host, img, 0.1);
45
+ const layer = host.querySelector(
46
+ '.jp-AdvancedImageViewer-panlayer'
47
+ ) as HTMLElement;
48
+ expect(layer.style.transform).toBe('translate(0px, 0px) scale(1)');
49
+ expect(img.style.transform).toBe('');
50
+ c.dispose();
51
+ });
52
+
53
+ it('zooms the layer in, allows zoom-out below fit, and resets to identity', () => {
54
+ const c = new ViewerController(host, img, 0.1);
55
+ const layer = host.querySelector(
56
+ '.jp-AdvancedImageViewer-panlayer'
57
+ ) as HTMLElement;
58
+ c.zoomIn();
59
+ expect(layer.style.transform).toContain('scale(1.1)');
60
+ c.reset();
61
+ expect(layer.style.transform).toBe('translate(0px, 0px) scale(1)');
62
+ c.zoomOut();
63
+ const scale = parseFloat(
64
+ /scale\(([0-9.]+)\)/.exec(layer.style.transform)![1]
65
+ );
66
+ expect(scale).toBeLessThan(1);
67
+ c.dispose();
68
+ });
69
+
70
+ it('never writes the image transform even after zooming (no clobber of rotate/flip)', () => {
71
+ const c = new ViewerController(host, img, 0.1);
72
+ c.zoomIn();
73
+ c.zoomIn();
74
+ expect(img.style.transform).toBe('');
75
+ c.dispose();
76
+ });
77
+
78
+ it('unwraps the image on dispose', () => {
79
+ const c = new ViewerController(host, img, 0.1);
80
+ c.dispose();
81
+ expect(host.querySelector('.jp-AdvancedImageViewer-panlayer')).toBeNull();
82
+ expect(img.parentElement).toBe(host);
83
+ });
84
+ });
package/src/controller.ts CHANGED
@@ -1,17 +1,18 @@
1
1
  // ViewerController adds zoom + pan ON TOP of JupyterLab's stock image viewer.
2
- // The stock viewer already fits the image with CSS (object-fit: contain,
3
- // max-width/height: 100%), so the identity transform IS the correct
4
- // fit-to-screen view for both raster and SVG. We never read naturalWidth /
5
- // naturalHeight (an SVG with only a viewBox reports a bogus intrinsic size).
6
- // Zoom is a single relative factor s (s = 1 is fit); pan is a translate.
7
- // transform-origin is the host centre, matching the centred object-fit layout.
2
+ // The stock viewer OWNS the <img> transform - it writes rotate / flip / its own
3
+ // scale there for its keybindings (], [, h, v, 0). If we also wrote the <img>
4
+ // transform we would clobber that (and it would clobber ours). Instead we wrap
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.
8
9
 
9
10
  const MIN_SCALE = 0.05;
10
11
  const MAX_SCALE = 40;
11
12
 
12
13
  export class ViewerController {
13
14
  private host: HTMLElement;
14
- private img: HTMLImageElement;
15
+ private layer: HTMLElement;
15
16
  private s = 1;
16
17
  private tx = 0;
17
18
  private ty = 0;
@@ -23,10 +24,20 @@ export class ViewerController {
23
24
 
24
25
  constructor(host: HTMLElement, img: HTMLImageElement, zoomStep: number) {
25
26
  this.host = host;
26
- this.img = img;
27
27
  this.zoomStep = zoomStep;
28
28
  this.host.style.overflow = 'hidden';
29
- this.img.style.transformOrigin = 'center center';
29
+ // Wrap the image so our zoom/pan compose with the stock rotate/flip
30
+ // transform that the viewer writes onto the <img> element itself. The
31
+ // layer fills the host (which is a positioned widget), so the stock
32
+ // object-fit/max-width CSS fits the image exactly as before.
33
+ const layer = document.createElement('div');
34
+ layer.className = 'jp-AdvancedImageViewer-panlayer';
35
+ const parent = img.parentNode;
36
+ if (parent) {
37
+ parent.insertBefore(layer, img);
38
+ }
39
+ layer.appendChild(img);
40
+ this.layer = layer;
30
41
  this.host.addEventListener('wheel', this.onWheel, { passive: false });
31
42
  this.host.addEventListener('mousedown', this.onDown);
32
43
  this.ro = new ResizeObserver(() => this.onResize());
@@ -142,8 +153,8 @@ export class ViewerController {
142
153
  }
143
154
 
144
155
  private apply(): void {
145
- this.img.style.transformOrigin = 'center center';
146
- this.img.style.transform = `translate(${this.tx}px, ${this.ty}px) scale(${this.s})`;
156
+ this.layer.style.transformOrigin = 'center center';
157
+ this.layer.style.transform = `translate(${this.tx}px, ${this.ty}px) scale(${this.s})`;
147
158
  this.updateCursor();
148
159
  }
149
160
 
@@ -157,5 +168,12 @@ export class ViewerController {
157
168
  this.host.removeEventListener('mousedown', this.onDown);
158
169
  window.removeEventListener('mousemove', this.onMove);
159
170
  window.removeEventListener('mouseup', this.onUp);
171
+ // Unwrap: restore the <img> as a direct child and drop the layer.
172
+ const img = this.layer.querySelector('img');
173
+ const parent = this.layer.parentNode;
174
+ if (img && parent) {
175
+ parent.insertBefore(img, this.layer);
176
+ }
177
+ this.layer.remove();
160
178
  }
161
179
  }
package/src/index.ts CHANGED
@@ -3,7 +3,12 @@ import {
3
3
  JupyterFrontEndPlugin
4
4
  } from '@jupyterlab/application';
5
5
 
6
- import { ToolbarButton } from '@jupyterlab/apputils';
6
+ import {
7
+ Dialog,
8
+ showDialog,
9
+ Toolbar,
10
+ ToolbarButton
11
+ } from '@jupyterlab/apputils';
7
12
 
8
13
  import { PathExt } from '@jupyterlab/coreutils';
9
14
 
@@ -15,6 +20,8 @@ import { ISettingRegistry } from '@jupyterlab/settingregistry';
15
20
 
16
21
  import { IDisposable } from '@lumino/disposable';
17
22
 
23
+ import { Widget } from '@lumino/widgets';
24
+
18
25
  import { ViewerController } from './controller';
19
26
 
20
27
  const PLUGIN_ID = 'jupyterlab_advanced_image_viewer_extension:plugin';
@@ -74,6 +81,33 @@ const plugin: JupyterFrontEndPlugin<void> = {
74
81
  return controllers.get(widget.content) ?? null;
75
82
  };
76
83
 
84
+ const showHelp = (): void => {
85
+ const body = new Widget();
86
+ body.addClass('jp-AdvancedImageViewer-help');
87
+ body.node.innerHTML = [
88
+ '<p>Interactive viewing layered on the standard image viewer.</p>',
89
+ '<p class="jp-AdvancedImageViewer-help-section">This extension</p>',
90
+ '<ul>',
91
+ '<li><b>Wheel up / down</b> - zoom in / out at the cursor</li>',
92
+ '<li><b>Click and drag</b> - pan a zoomed-in image</li>',
93
+ '<li><b>+ / - / Fit</b> - zoom in, zoom out, reset to fit</li>',
94
+ '<li><b>Left / Right arrows</b> - previous / next image in the folder</li>',
95
+ '</ul>',
96
+ '<p class="jp-AdvancedImageViewer-help-section">Standard viewer keys (compose with the above)</p>',
97
+ '<ul>',
98
+ '<li><b>] / [</b> - rotate clockwise / counter-clockwise</li>',
99
+ '<li><b>H / V</b> - flip horizontal / vertical</li>',
100
+ '<li><b>I</b> - invert colours</li>',
101
+ '<li><b>= / - / 0</b> - zoom in / out / reset</li>',
102
+ '</ul>'
103
+ ].join('');
104
+ void showDialog({
105
+ title: 'Advanced Image Viewer',
106
+ body,
107
+ buttons: [Dialog.okButton({ label: 'Close' })]
108
+ });
109
+ };
110
+
77
111
  const attach = (widget: IDocumentWidget<ImageViewer>): void => {
78
112
  const viewer = widget.content;
79
113
  if (controllers.has(viewer)) {
@@ -119,6 +153,18 @@ const plugin: JupyterFrontEndPlugin<void> = {
119
153
  onClick: () => app.commands.execute(CommandIDs.resetFit)
120
154
  })
121
155
  );
156
+ widget.toolbar.addItem('advanced-spacer', Toolbar.createSpacerItem());
157
+ const helpLink = new Widget({ node: document.createElement('a') });
158
+ const helpAnchor = helpLink.node as HTMLAnchorElement;
159
+ helpAnchor.textContent = 'help';
160
+ helpAnchor.href = '#';
161
+ helpAnchor.title = 'Keybindings and help';
162
+ helpAnchor.className = 'jp-AdvancedImageViewer-help-link';
163
+ helpAnchor.addEventListener('click', event => {
164
+ event.preventDefault();
165
+ showHelp();
166
+ });
167
+ widget.toolbar.addItem('advanced-help', helpLink);
122
168
  };
123
169
 
124
170
  tracker.forEach(widget => attach(widget));
package/style/base.css CHANGED
@@ -3,3 +3,45 @@
3
3
 
4
4
  https://jupyterlab.readthedocs.io/en/stable/developer/css.html
5
5
  */
6
+
7
+ /* Pan/zoom layer wrapping the <img> so our transform composes with the stock
8
+ viewer's rotate/flip transform on the image itself. Fills the host. */
9
+ .jp-AdvancedImageViewer-panlayer {
10
+ position: absolute;
11
+ inset: 0;
12
+ transform-origin: center center;
13
+ }
14
+
15
+ /* Help shown as an accent-coloured link at the right end of the toolbar.
16
+ The selector is scoped under .jp-Toolbar to beat JupyterLab's toolbar `a`
17
+ rule (otherwise the link renders in the default muted toolbar colour). */
18
+ .jp-Toolbar a.jp-AdvancedImageViewer-help-link {
19
+ align-self: center;
20
+ margin: 0 8px;
21
+ color: var(--jp-brand-color1);
22
+ font-size: var(--jp-ui-font-size1);
23
+ text-decoration: underline;
24
+ cursor: pointer;
25
+ }
26
+
27
+ .jp-Toolbar a.jp-AdvancedImageViewer-help-link:hover {
28
+ color: var(--jp-brand-color0);
29
+ }
30
+
31
+ .jp-AdvancedImageViewer-help {
32
+ max-width: 380px;
33
+ }
34
+
35
+ .jp-AdvancedImageViewer-help-section {
36
+ margin: 12px 0 0;
37
+ font-weight: 600;
38
+ }
39
+
40
+ .jp-AdvancedImageViewer-help ul {
41
+ margin: 8px 0 0;
42
+ padding-left: 18px;
43
+ }
44
+
45
+ .jp-AdvancedImageViewer-help li {
46
+ margin: 2px 0;
47
+ }