jupyterlab_advanced_image_viewer_extension 0.4.7
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/LICENSE +29 -0
- package/README.md +39 -0
- package/lib/controller.d.ts +30 -0
- package/lib/controller.js +137 -0
- package/lib/index.d.ts +3 -0
- package/lib/index.js +203 -0
- package/package.json +212 -0
- package/src/__tests__/jupyterlab_advanced_image_viewer_extension.spec.ts +9 -0
- package/src/controller.ts +161 -0
- package/src/index.ts +268 -0
- package/style/base.css +5 -0
- package/style/index.css +1 -0
- package/style/index.js +1 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, Stellars Henson
|
|
4
|
+
All rights reserved.
|
|
5
|
+
|
|
6
|
+
Redistribution and use in source and binary forms, with or without
|
|
7
|
+
modification, are permitted provided that the following conditions are met:
|
|
8
|
+
|
|
9
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
10
|
+
list of conditions and the following disclaimer.
|
|
11
|
+
|
|
12
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
13
|
+
this list of conditions and the following disclaimer in the documentation
|
|
14
|
+
and/or other materials provided with the distribution.
|
|
15
|
+
|
|
16
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
17
|
+
contributors may be used to endorse or promote products derived from
|
|
18
|
+
this software without specific prior written permission.
|
|
19
|
+
|
|
20
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
21
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
22
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
23
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
24
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
25
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
26
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
27
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
28
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
29
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
package/README.md
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# jupyterlab_advanced_image_viewer_extension
|
|
2
|
+
|
|
3
|
+
[](https://github.com/stellarshenson/jupyterlab_advanced_image_viewer_extension/actions/workflows/build.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/jupyterlab_advanced_image_viewer_extension)
|
|
5
|
+
[](https://pypi.org/project/jupyterlab-advanced-image-viewer-extension/)
|
|
6
|
+
[](https://pepy.tech/project/jupyterlab-advanced-image-viewer-extension)
|
|
7
|
+
[](https://jupyterlab.readthedocs.io/en/stable/)
|
|
8
|
+
[](https://kolomolo.com)
|
|
9
|
+
[](https://www.paypal.com/donate/?hosted_button_id=B4KPBJDLLXTSA)
|
|
10
|
+
|
|
11
|
+
A JupyterLab 4 extension that makes the built-in image viewer interactive. Images open fitted to the panel just like the stock viewer, then you can zoom with the wheel, pan by dragging, and step through a folder of images with the arrow keys - all in a single viewer tab.
|
|
12
|
+
|
|
13
|
+
## Features
|
|
14
|
+
|
|
15
|
+
- **Fit to screen** - every image opens scaled to fit the panel (same result as the stock viewer), and reset returns to that fit
|
|
16
|
+
- **Wheel zoom** - scroll to zoom in and out, anchored at the cursor; zoom out as far as you like
|
|
17
|
+
- **Drag to pan** - hold and drag to move around a zoomed-in image
|
|
18
|
+
- **Toolbar controls** - zoom in, zoom out, and reset-to-fit buttons on the viewer toolbar
|
|
19
|
+
- **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
|
|
20
|
+
|
|
21
|
+
## Requirements
|
|
22
|
+
|
|
23
|
+
- JupyterLab >= 4.0.0
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
To install the extension, execute:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install jupyterlab_advanced_image_viewer_extension
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Uninstall
|
|
34
|
+
|
|
35
|
+
To remove the extension, execute:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip uninstall jupyterlab_advanced_image_viewer_extension
|
|
39
|
+
```
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export declare class ViewerController {
|
|
2
|
+
private host;
|
|
3
|
+
private img;
|
|
4
|
+
private s;
|
|
5
|
+
private tx;
|
|
6
|
+
private ty;
|
|
7
|
+
private panning;
|
|
8
|
+
private start;
|
|
9
|
+
private zoomStep;
|
|
10
|
+
private ro;
|
|
11
|
+
private disposed;
|
|
12
|
+
constructor(host: HTMLElement, img: HTMLImageElement, zoomStep: number);
|
|
13
|
+
setZoomStep(zoomStep: number): void;
|
|
14
|
+
reset(): void;
|
|
15
|
+
private viewport;
|
|
16
|
+
private clampScale;
|
|
17
|
+
private zoomAt;
|
|
18
|
+
private onWheel;
|
|
19
|
+
zoomIn(): void;
|
|
20
|
+
zoomOut(): void;
|
|
21
|
+
private canPan;
|
|
22
|
+
private clampPan;
|
|
23
|
+
private onDown;
|
|
24
|
+
private onMove;
|
|
25
|
+
private onUp;
|
|
26
|
+
private onResize;
|
|
27
|
+
private updateCursor;
|
|
28
|
+
private apply;
|
|
29
|
+
dispose(): void;
|
|
30
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
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.
|
|
8
|
+
const MIN_SCALE = 0.05;
|
|
9
|
+
const MAX_SCALE = 40;
|
|
10
|
+
export class ViewerController {
|
|
11
|
+
constructor(host, img, zoomStep) {
|
|
12
|
+
this.s = 1;
|
|
13
|
+
this.tx = 0;
|
|
14
|
+
this.ty = 0;
|
|
15
|
+
this.panning = false;
|
|
16
|
+
this.start = { x: 0, y: 0, tx: 0, ty: 0 };
|
|
17
|
+
this.disposed = false;
|
|
18
|
+
this.onWheel = (e) => {
|
|
19
|
+
e.preventDefault();
|
|
20
|
+
const r = this.host.getBoundingClientRect();
|
|
21
|
+
const factor = e.deltaY < 0 ? 1 + this.zoomStep : 1 / (1 + this.zoomStep);
|
|
22
|
+
this.zoomAt(factor, e.clientX - r.left, e.clientY - r.top);
|
|
23
|
+
};
|
|
24
|
+
this.onDown = (e) => {
|
|
25
|
+
if (!this.canPan()) {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
e.preventDefault();
|
|
29
|
+
this.panning = true;
|
|
30
|
+
this.start = { x: e.clientX, y: e.clientY, tx: this.tx, ty: this.ty };
|
|
31
|
+
this.host.style.cursor = 'grabbing';
|
|
32
|
+
window.addEventListener('mousemove', this.onMove);
|
|
33
|
+
window.addEventListener('mouseup', this.onUp);
|
|
34
|
+
};
|
|
35
|
+
this.onMove = (e) => {
|
|
36
|
+
if (!this.panning) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
this.tx = this.start.tx + (e.clientX - this.start.x);
|
|
40
|
+
this.ty = this.start.ty + (e.clientY - this.start.y);
|
|
41
|
+
this.clampPan();
|
|
42
|
+
this.apply();
|
|
43
|
+
};
|
|
44
|
+
this.onUp = () => {
|
|
45
|
+
this.panning = false;
|
|
46
|
+
this.updateCursor();
|
|
47
|
+
window.removeEventListener('mousemove', this.onMove);
|
|
48
|
+
window.removeEventListener('mouseup', this.onUp);
|
|
49
|
+
};
|
|
50
|
+
this.host = host;
|
|
51
|
+
this.img = img;
|
|
52
|
+
this.zoomStep = zoomStep;
|
|
53
|
+
this.host.style.overflow = 'hidden';
|
|
54
|
+
this.img.style.transformOrigin = 'center center';
|
|
55
|
+
this.host.addEventListener('wheel', this.onWheel, { passive: false });
|
|
56
|
+
this.host.addEventListener('mousedown', this.onDown);
|
|
57
|
+
this.ro = new ResizeObserver(() => this.onResize());
|
|
58
|
+
this.ro.observe(this.host);
|
|
59
|
+
this.apply();
|
|
60
|
+
}
|
|
61
|
+
setZoomStep(zoomStep) {
|
|
62
|
+
this.zoomStep = zoomStep;
|
|
63
|
+
}
|
|
64
|
+
reset() {
|
|
65
|
+
this.s = 1;
|
|
66
|
+
this.tx = 0;
|
|
67
|
+
this.ty = 0;
|
|
68
|
+
this.apply();
|
|
69
|
+
}
|
|
70
|
+
viewport() {
|
|
71
|
+
return { vw: this.host.clientWidth, vh: this.host.clientHeight };
|
|
72
|
+
}
|
|
73
|
+
clampScale(v) {
|
|
74
|
+
return Math.min(Math.max(v, MIN_SCALE), MAX_SCALE);
|
|
75
|
+
}
|
|
76
|
+
// Cursor-anchored zoom about the host centre. With transform
|
|
77
|
+
// M(p) = C + s*(p - C) + t, keeping the point under (ax, ay) fixed gives
|
|
78
|
+
// t' = a - C - (s'/s) * (a - C - t).
|
|
79
|
+
zoomAt(factor, ax, ay) {
|
|
80
|
+
const sNew = this.clampScale(this.s * factor);
|
|
81
|
+
if (sNew === this.s) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const { vw, vh } = this.viewport();
|
|
85
|
+
const cx = vw / 2;
|
|
86
|
+
const cy = vh / 2;
|
|
87
|
+
const k = sNew / this.s;
|
|
88
|
+
this.tx = ax - cx - k * (ax - cx - this.tx);
|
|
89
|
+
this.ty = ay - cy - k * (ay - cy - this.ty);
|
|
90
|
+
this.s = sNew;
|
|
91
|
+
this.clampPan();
|
|
92
|
+
this.apply();
|
|
93
|
+
}
|
|
94
|
+
zoomIn() {
|
|
95
|
+
const { vw, vh } = this.viewport();
|
|
96
|
+
this.zoomAt(1 + this.zoomStep, vw / 2, vh / 2);
|
|
97
|
+
}
|
|
98
|
+
zoomOut() {
|
|
99
|
+
const { vw, vh } = this.viewport();
|
|
100
|
+
this.zoomAt(1 / (1 + this.zoomStep), vw / 2, vh / 2);
|
|
101
|
+
}
|
|
102
|
+
canPan() {
|
|
103
|
+
return this.s > 1;
|
|
104
|
+
}
|
|
105
|
+
// Bound the translate to the overflow implied by the zoom so the image
|
|
106
|
+
// cannot be panned out of view. Range collapses to 0 as s approaches 1.
|
|
107
|
+
clampPan() {
|
|
108
|
+
const { vw, vh } = this.viewport();
|
|
109
|
+
const mx = (Math.max(this.s, 1) - 1) * vw * 0.5;
|
|
110
|
+
const my = (Math.max(this.s, 1) - 1) * vh * 0.5;
|
|
111
|
+
this.tx = Math.min(mx, Math.max(-mx, this.tx));
|
|
112
|
+
this.ty = Math.min(my, Math.max(-my, this.ty));
|
|
113
|
+
}
|
|
114
|
+
onResize() {
|
|
115
|
+
this.clampPan();
|
|
116
|
+
this.apply();
|
|
117
|
+
}
|
|
118
|
+
updateCursor() {
|
|
119
|
+
this.host.style.cursor = this.canPan() ? 'grab' : 'default';
|
|
120
|
+
}
|
|
121
|
+
apply() {
|
|
122
|
+
this.img.style.transformOrigin = 'center center';
|
|
123
|
+
this.img.style.transform = `translate(${this.tx}px, ${this.ty}px) scale(${this.s})`;
|
|
124
|
+
this.updateCursor();
|
|
125
|
+
}
|
|
126
|
+
dispose() {
|
|
127
|
+
if (this.disposed) {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
this.disposed = true;
|
|
131
|
+
this.ro.disconnect();
|
|
132
|
+
this.host.removeEventListener('wheel', this.onWheel);
|
|
133
|
+
this.host.removeEventListener('mousedown', this.onDown);
|
|
134
|
+
window.removeEventListener('mousemove', this.onMove);
|
|
135
|
+
window.removeEventListener('mouseup', this.onUp);
|
|
136
|
+
}
|
|
137
|
+
}
|
package/lib/index.d.ts
ADDED
package/lib/index.js
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { ToolbarButton } from '@jupyterlab/apputils';
|
|
2
|
+
import { PathExt } from '@jupyterlab/coreutils';
|
|
3
|
+
import { IImageTracker } from '@jupyterlab/imageviewer';
|
|
4
|
+
import { ISettingRegistry } from '@jupyterlab/settingregistry';
|
|
5
|
+
import { ViewerController } from './controller';
|
|
6
|
+
const PLUGIN_ID = 'jupyterlab_advanced_image_viewer_extension:plugin';
|
|
7
|
+
const CommandIDs = {
|
|
8
|
+
zoomIn: 'advanced-image-viewer:zoom-in',
|
|
9
|
+
zoomOut: 'advanced-image-viewer:zoom-out',
|
|
10
|
+
resetFit: 'advanced-image-viewer:reset-fit',
|
|
11
|
+
previous: 'advanced-image-viewer:previous-image',
|
|
12
|
+
next: 'advanced-image-viewer:next-image'
|
|
13
|
+
};
|
|
14
|
+
const IMAGE_EXTS = new Set([
|
|
15
|
+
'.png',
|
|
16
|
+
'.jpg',
|
|
17
|
+
'.jpeg',
|
|
18
|
+
'.gif',
|
|
19
|
+
'.bmp',
|
|
20
|
+
'.svg',
|
|
21
|
+
'.webp'
|
|
22
|
+
]);
|
|
23
|
+
const defaults = {
|
|
24
|
+
navEnabled: true,
|
|
25
|
+
zoomStep: 0.1
|
|
26
|
+
};
|
|
27
|
+
const plugin = {
|
|
28
|
+
id: PLUGIN_ID,
|
|
29
|
+
description: 'Advanced image viewer: cursor-anchored wheel zoom, drag-to-pan, fit-to-screen reset, and arrow-key folder navigation.',
|
|
30
|
+
autoStart: true,
|
|
31
|
+
requires: [IImageTracker],
|
|
32
|
+
optional: [ISettingRegistry],
|
|
33
|
+
activate: (app, tracker, settingRegistry) => {
|
|
34
|
+
console.log('JupyterLab extension jupyterlab_advanced_image_viewer_extension is activated!');
|
|
35
|
+
const controllers = new WeakMap();
|
|
36
|
+
const state = { ...defaults };
|
|
37
|
+
let keyBindings = [];
|
|
38
|
+
const currentController = () => {
|
|
39
|
+
var _a;
|
|
40
|
+
const widget = tracker.currentWidget;
|
|
41
|
+
if (!widget) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
return (_a = controllers.get(widget.content)) !== null && _a !== void 0 ? _a : null;
|
|
45
|
+
};
|
|
46
|
+
const attach = (widget) => {
|
|
47
|
+
const viewer = widget.content;
|
|
48
|
+
if (controllers.has(viewer)) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const host = viewer.node;
|
|
52
|
+
const img = host.querySelector('img');
|
|
53
|
+
if (!img) {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const controller = new ViewerController(host, img, state.zoomStep);
|
|
57
|
+
controllers.set(viewer, controller);
|
|
58
|
+
widget.disposed.connect(() => {
|
|
59
|
+
controller.dispose();
|
|
60
|
+
controllers.delete(viewer);
|
|
61
|
+
});
|
|
62
|
+
widget.toolbar.addItem('advanced-zoom-out', new ToolbarButton({
|
|
63
|
+
label: '-',
|
|
64
|
+
tooltip: 'Zoom out',
|
|
65
|
+
onClick: () => app.commands.execute(CommandIDs.zoomOut)
|
|
66
|
+
}));
|
|
67
|
+
widget.toolbar.addItem('advanced-zoom-in', new ToolbarButton({
|
|
68
|
+
label: '+',
|
|
69
|
+
tooltip: 'Zoom in',
|
|
70
|
+
onClick: () => app.commands.execute(CommandIDs.zoomIn)
|
|
71
|
+
}));
|
|
72
|
+
widget.toolbar.addItem('advanced-reset-fit', new ToolbarButton({
|
|
73
|
+
label: 'Fit',
|
|
74
|
+
tooltip: 'Reset to fit',
|
|
75
|
+
onClick: () => app.commands.execute(CommandIDs.resetFit)
|
|
76
|
+
}));
|
|
77
|
+
};
|
|
78
|
+
tracker.forEach(widget => attach(widget));
|
|
79
|
+
tracker.widgetAdded.connect((_, widget) => attach(widget));
|
|
80
|
+
app.commands.addCommand(CommandIDs.zoomIn, {
|
|
81
|
+
label: 'Zoom In (Advanced Image Viewer)',
|
|
82
|
+
isEnabled: () => currentController() !== null,
|
|
83
|
+
execute: () => { var _a; return (_a = currentController()) === null || _a === void 0 ? void 0 : _a.zoomIn(); }
|
|
84
|
+
});
|
|
85
|
+
app.commands.addCommand(CommandIDs.zoomOut, {
|
|
86
|
+
label: 'Zoom Out (Advanced Image Viewer)',
|
|
87
|
+
isEnabled: () => currentController() !== null,
|
|
88
|
+
execute: () => { var _a; return (_a = currentController()) === null || _a === void 0 ? void 0 : _a.zoomOut(); }
|
|
89
|
+
});
|
|
90
|
+
app.commands.addCommand(CommandIDs.resetFit, {
|
|
91
|
+
label: 'Reset to Fit (Advanced Image Viewer)',
|
|
92
|
+
isEnabled: () => currentController() !== null,
|
|
93
|
+
execute: () => { var _a; return (_a = currentController()) === null || _a === void 0 ? void 0 : _a.reset(); }
|
|
94
|
+
});
|
|
95
|
+
const navigate = async (delta) => {
|
|
96
|
+
const widget = tracker.currentWidget;
|
|
97
|
+
if (!widget) {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const path = widget.context.path;
|
|
101
|
+
const dirPath = PathExt.dirname(path);
|
|
102
|
+
const listing = await app.serviceManager.contents.get(dirPath, {
|
|
103
|
+
content: true
|
|
104
|
+
});
|
|
105
|
+
const content = listing.content;
|
|
106
|
+
if (!content) {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
const images = content
|
|
110
|
+
.filter(item => item.type === 'file' &&
|
|
111
|
+
IMAGE_EXTS.has(PathExt.extname(item.name).toLowerCase()))
|
|
112
|
+
.sort((a, b) => a.name.localeCompare(b.name, undefined, {
|
|
113
|
+
numeric: true,
|
|
114
|
+
sensitivity: 'base'
|
|
115
|
+
}));
|
|
116
|
+
if (images.length <= 1) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const idx = images.findIndex(item => item.path === path);
|
|
120
|
+
if (idx === -1) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const target = delta < 0 ? Math.max(0, idx - 1) : Math.min(images.length - 1, idx + 1);
|
|
124
|
+
if (target === idx) {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
// Advance within a single viewer: open the next image, then close the
|
|
128
|
+
// previous one. The stock image viewer binds each file to its own
|
|
129
|
+
// DocumentWidget, so an in-place file swap is not possible; opening and
|
|
130
|
+
// disposing the previous widget leaves one image tab that advances.
|
|
131
|
+
const previous = widget;
|
|
132
|
+
await app.commands.execute('docmanager:open', {
|
|
133
|
+
path: images[target].path,
|
|
134
|
+
factory: 'Image'
|
|
135
|
+
});
|
|
136
|
+
if (!previous.isDisposed && previous !== tracker.currentWidget) {
|
|
137
|
+
previous.dispose();
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
app.commands.addCommand(CommandIDs.previous, {
|
|
141
|
+
label: 'Previous Image In Folder',
|
|
142
|
+
isEnabled: () => state.navEnabled && tracker.currentWidget !== null,
|
|
143
|
+
execute: () => navigate(-1)
|
|
144
|
+
});
|
|
145
|
+
app.commands.addCommand(CommandIDs.next, {
|
|
146
|
+
label: 'Next Image In Folder',
|
|
147
|
+
isEnabled: () => state.navEnabled && tracker.currentWidget !== null,
|
|
148
|
+
execute: () => navigate(1)
|
|
149
|
+
});
|
|
150
|
+
const bindKeys = () => {
|
|
151
|
+
keyBindings.forEach(b => b.dispose());
|
|
152
|
+
keyBindings = [];
|
|
153
|
+
if (!state.navEnabled) {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
keyBindings.push(app.commands.addKeyBinding({
|
|
157
|
+
command: CommandIDs.previous,
|
|
158
|
+
keys: ['ArrowLeft'],
|
|
159
|
+
selector: '.jp-ImageViewer'
|
|
160
|
+
}));
|
|
161
|
+
keyBindings.push(app.commands.addKeyBinding({
|
|
162
|
+
command: CommandIDs.next,
|
|
163
|
+
keys: ['ArrowRight'],
|
|
164
|
+
selector: '.jp-ImageViewer'
|
|
165
|
+
}));
|
|
166
|
+
};
|
|
167
|
+
const applyState = () => {
|
|
168
|
+
tracker.forEach(widget => {
|
|
169
|
+
const controller = controllers.get(widget.content);
|
|
170
|
+
if (controller) {
|
|
171
|
+
controller.setZoomStep(state.zoomStep);
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
bindKeys();
|
|
175
|
+
};
|
|
176
|
+
if (settingRegistry) {
|
|
177
|
+
settingRegistry
|
|
178
|
+
.load(PLUGIN_ID)
|
|
179
|
+
.then(settings => {
|
|
180
|
+
const read = () => {
|
|
181
|
+
var _a, _b;
|
|
182
|
+
const c = settings.composite;
|
|
183
|
+
state.navEnabled = (_a = c.navEnabled) !== null && _a !== void 0 ? _a : defaults.navEnabled;
|
|
184
|
+
state.zoomStep = (_b = c.zoomStep) !== null && _b !== void 0 ? _b : defaults.zoomStep;
|
|
185
|
+
};
|
|
186
|
+
read();
|
|
187
|
+
applyState();
|
|
188
|
+
settings.changed.connect(() => {
|
|
189
|
+
read();
|
|
190
|
+
applyState();
|
|
191
|
+
});
|
|
192
|
+
})
|
|
193
|
+
.catch(reason => {
|
|
194
|
+
console.error(`Failed to load settings for ${PLUGIN_ID}.`, reason);
|
|
195
|
+
applyState();
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
else {
|
|
199
|
+
applyState();
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
export default plugin;
|
package/package.json
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "jupyterlab_advanced_image_viewer_extension",
|
|
3
|
+
"version": "0.4.7",
|
|
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
|
+
"keywords": [
|
|
6
|
+
"jupyter",
|
|
7
|
+
"jupyterlab",
|
|
8
|
+
"jupyterlab-extension"
|
|
9
|
+
],
|
|
10
|
+
"homepage": "https://github.com/stellarshenson/jupyterlab_advanced_image_viewer_extension",
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/stellarshenson/jupyterlab_advanced_image_viewer_extension/issues"
|
|
13
|
+
},
|
|
14
|
+
"license": "BSD-3-Clause",
|
|
15
|
+
"author": {
|
|
16
|
+
"name": "Stellars Henson",
|
|
17
|
+
"email": "konrad.jelen+github@gmail.com"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"lib/**/*.{d.ts,eot,gif,html,jpg,js,js.map,json,png,svg,woff2,ttf}",
|
|
21
|
+
"style/**/*.{css,js,eot,gif,html,jpg,json,png,svg,woff2,ttf}",
|
|
22
|
+
"src/**/*.{ts,tsx}"
|
|
23
|
+
],
|
|
24
|
+
"main": "lib/index.js",
|
|
25
|
+
"types": "lib/index.d.ts",
|
|
26
|
+
"style": "style/index.css",
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "https://github.com/stellarshenson/jupyterlab_advanced_image_viewer_extension.git"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"build": "jlpm build:lib && jlpm build:labextension:dev",
|
|
33
|
+
"build:prod": "jlpm clean && jlpm build:lib:prod && jlpm build:labextension",
|
|
34
|
+
"build:labextension": "jupyter labextension build .",
|
|
35
|
+
"build:labextension:dev": "jupyter labextension build --development True .",
|
|
36
|
+
"build:lib": "tsc --sourceMap",
|
|
37
|
+
"build:lib:prod": "tsc",
|
|
38
|
+
"clean": "jlpm clean:lib",
|
|
39
|
+
"clean:lib": "rimraf lib tsconfig.tsbuildinfo",
|
|
40
|
+
"clean:lintcache": "rimraf .eslintcache .stylelintcache",
|
|
41
|
+
"clean:labextension": "rimraf jupyterlab_advanced_image_viewer_extension/labextension jupyterlab_advanced_image_viewer_extension/_version.py",
|
|
42
|
+
"clean:all": "jlpm clean:lib && jlpm clean:labextension && jlpm clean:lintcache",
|
|
43
|
+
"eslint": "jlpm eslint:check --fix",
|
|
44
|
+
"eslint:check": "eslint . --cache --ext .ts,.tsx",
|
|
45
|
+
"install:extension": "jlpm build",
|
|
46
|
+
"lint": "jlpm stylelint && jlpm prettier && jlpm eslint",
|
|
47
|
+
"lint:check": "jlpm stylelint:check && jlpm prettier:check && jlpm eslint:check",
|
|
48
|
+
"prettier": "jlpm prettier:base --write --list-different",
|
|
49
|
+
"prettier:base": "prettier \"**/*{.ts,.tsx,.js,.jsx,.css,.json,.md}\"",
|
|
50
|
+
"prettier:check": "jlpm prettier:base --check",
|
|
51
|
+
"stylelint": "jlpm stylelint:check --fix",
|
|
52
|
+
"stylelint:check": "stylelint --cache \"style/**/*.css\"",
|
|
53
|
+
"test": "jest --coverage",
|
|
54
|
+
"watch": "run-p watch:src watch:labextension",
|
|
55
|
+
"watch:src": "tsc -w --sourceMap",
|
|
56
|
+
"watch:labextension": "jupyter labextension watch ."
|
|
57
|
+
},
|
|
58
|
+
"dependencies": {
|
|
59
|
+
"@jupyterlab/application": "^4.0.0",
|
|
60
|
+
"@jupyterlab/apputils": "^4.0.0",
|
|
61
|
+
"@jupyterlab/coreutils": "^6.0.0",
|
|
62
|
+
"@jupyterlab/docregistry": "^4.0.0",
|
|
63
|
+
"@jupyterlab/imageviewer": "^4.0.0",
|
|
64
|
+
"@jupyterlab/settingregistry": "^4.0.0",
|
|
65
|
+
"@lumino/disposable": "^2.0.0"
|
|
66
|
+
},
|
|
67
|
+
"devDependencies": {
|
|
68
|
+
"@jupyterlab/builder": "^4.0.0",
|
|
69
|
+
"@jupyterlab/testutils": "^4.0.0",
|
|
70
|
+
"@types/jest": "^29.2.0",
|
|
71
|
+
"@types/json-schema": "^7.0.11",
|
|
72
|
+
"@types/react": "^18.0.26",
|
|
73
|
+
"@types/react-addons-linked-state-mixin": "^0.14.22",
|
|
74
|
+
"@typescript-eslint/eslint-plugin": "^6.1.0",
|
|
75
|
+
"@typescript-eslint/parser": "^6.1.0",
|
|
76
|
+
"css-loader": "^6.7.1",
|
|
77
|
+
"eslint": "^8.36.0",
|
|
78
|
+
"eslint-config-prettier": "^8.8.0",
|
|
79
|
+
"eslint-plugin-prettier": "^5.0.0",
|
|
80
|
+
"jest": "^29.2.0",
|
|
81
|
+
"mkdirp": "^1.0.3",
|
|
82
|
+
"npm-run-all2": "^7.0.1",
|
|
83
|
+
"prettier": "^3.0.0",
|
|
84
|
+
"rimraf": "^5.0.1",
|
|
85
|
+
"source-map-loader": "^1.0.2",
|
|
86
|
+
"style-loader": "^3.3.1",
|
|
87
|
+
"stylelint": "^15.10.1",
|
|
88
|
+
"stylelint-config-recommended": "^13.0.0",
|
|
89
|
+
"stylelint-config-standard": "^34.0.0",
|
|
90
|
+
"stylelint-csstree-validator": "^3.0.0",
|
|
91
|
+
"stylelint-prettier": "^4.0.0",
|
|
92
|
+
"typescript": "~5.8.0",
|
|
93
|
+
"yjs": "^13.5.0"
|
|
94
|
+
},
|
|
95
|
+
"resolutions": {
|
|
96
|
+
"lib0": "0.2.111",
|
|
97
|
+
"webpack": "5.106.0",
|
|
98
|
+
"chalk": "4.1.2"
|
|
99
|
+
},
|
|
100
|
+
"overrides": {
|
|
101
|
+
"lib0": "0.2.111",
|
|
102
|
+
"webpack": "5.106.0",
|
|
103
|
+
"chalk": "4.1.2"
|
|
104
|
+
},
|
|
105
|
+
"sideEffects": [
|
|
106
|
+
"style/*.css",
|
|
107
|
+
"style/index.js"
|
|
108
|
+
],
|
|
109
|
+
"styleModule": "style/index.js",
|
|
110
|
+
"publishConfig": {
|
|
111
|
+
"access": "public"
|
|
112
|
+
},
|
|
113
|
+
"jupyterlab": {
|
|
114
|
+
"extension": true,
|
|
115
|
+
"outputDir": "jupyterlab_advanced_image_viewer_extension/labextension",
|
|
116
|
+
"schemaDir": "schema"
|
|
117
|
+
},
|
|
118
|
+
"eslintIgnore": [
|
|
119
|
+
"node_modules",
|
|
120
|
+
"dist",
|
|
121
|
+
"coverage",
|
|
122
|
+
"**/*.d.ts",
|
|
123
|
+
"tests",
|
|
124
|
+
"**/__tests__",
|
|
125
|
+
"ui-tests"
|
|
126
|
+
],
|
|
127
|
+
"eslintConfig": {
|
|
128
|
+
"extends": [
|
|
129
|
+
"eslint:recommended",
|
|
130
|
+
"plugin:@typescript-eslint/eslint-recommended",
|
|
131
|
+
"plugin:@typescript-eslint/recommended",
|
|
132
|
+
"plugin:prettier/recommended"
|
|
133
|
+
],
|
|
134
|
+
"parser": "@typescript-eslint/parser",
|
|
135
|
+
"parserOptions": {
|
|
136
|
+
"project": "tsconfig.json",
|
|
137
|
+
"sourceType": "module"
|
|
138
|
+
},
|
|
139
|
+
"plugins": [
|
|
140
|
+
"@typescript-eslint"
|
|
141
|
+
],
|
|
142
|
+
"rules": {
|
|
143
|
+
"@typescript-eslint/naming-convention": [
|
|
144
|
+
"error",
|
|
145
|
+
{
|
|
146
|
+
"selector": "interface",
|
|
147
|
+
"format": [
|
|
148
|
+
"PascalCase"
|
|
149
|
+
],
|
|
150
|
+
"custom": {
|
|
151
|
+
"regex": "^I[A-Z]",
|
|
152
|
+
"match": true
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
],
|
|
156
|
+
"@typescript-eslint/no-unused-vars": [
|
|
157
|
+
"warn",
|
|
158
|
+
{
|
|
159
|
+
"args": "none"
|
|
160
|
+
}
|
|
161
|
+
],
|
|
162
|
+
"@typescript-eslint/no-explicit-any": "off",
|
|
163
|
+
"@typescript-eslint/no-namespace": "off",
|
|
164
|
+
"@typescript-eslint/no-use-before-define": "off",
|
|
165
|
+
"@typescript-eslint/quotes": [
|
|
166
|
+
"error",
|
|
167
|
+
"single",
|
|
168
|
+
{
|
|
169
|
+
"avoidEscape": true,
|
|
170
|
+
"allowTemplateLiterals": false
|
|
171
|
+
}
|
|
172
|
+
],
|
|
173
|
+
"curly": [
|
|
174
|
+
"error",
|
|
175
|
+
"all"
|
|
176
|
+
],
|
|
177
|
+
"eqeqeq": "error",
|
|
178
|
+
"prefer-arrow-callback": "error"
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
"prettier": {
|
|
182
|
+
"singleQuote": true,
|
|
183
|
+
"trailingComma": "none",
|
|
184
|
+
"arrowParens": "avoid",
|
|
185
|
+
"endOfLine": "auto",
|
|
186
|
+
"overrides": [
|
|
187
|
+
{
|
|
188
|
+
"files": "package.json",
|
|
189
|
+
"options": {
|
|
190
|
+
"tabWidth": 4
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
]
|
|
194
|
+
},
|
|
195
|
+
"stylelint": {
|
|
196
|
+
"extends": [
|
|
197
|
+
"stylelint-config-recommended",
|
|
198
|
+
"stylelint-config-standard",
|
|
199
|
+
"stylelint-prettier/recommended"
|
|
200
|
+
],
|
|
201
|
+
"plugins": [
|
|
202
|
+
"stylelint-csstree-validator"
|
|
203
|
+
],
|
|
204
|
+
"rules": {
|
|
205
|
+
"csstree/validator": true,
|
|
206
|
+
"property-no-vendor-prefix": null,
|
|
207
|
+
"selector-class-pattern": "^([a-z][A-z\\d]*)(-[A-z\\d]+)*$",
|
|
208
|
+
"selector-no-vendor-prefix": null,
|
|
209
|
+
"value-no-vendor-prefix": null
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
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.
|
|
8
|
+
|
|
9
|
+
const MIN_SCALE = 0.05;
|
|
10
|
+
const MAX_SCALE = 40;
|
|
11
|
+
|
|
12
|
+
export class ViewerController {
|
|
13
|
+
private host: HTMLElement;
|
|
14
|
+
private img: HTMLImageElement;
|
|
15
|
+
private s = 1;
|
|
16
|
+
private tx = 0;
|
|
17
|
+
private ty = 0;
|
|
18
|
+
private panning = false;
|
|
19
|
+
private start = { x: 0, y: 0, tx: 0, ty: 0 };
|
|
20
|
+
private zoomStep: number;
|
|
21
|
+
private ro: ResizeObserver;
|
|
22
|
+
private disposed = false;
|
|
23
|
+
|
|
24
|
+
constructor(host: HTMLElement, img: HTMLImageElement, zoomStep: number) {
|
|
25
|
+
this.host = host;
|
|
26
|
+
this.img = img;
|
|
27
|
+
this.zoomStep = zoomStep;
|
|
28
|
+
this.host.style.overflow = 'hidden';
|
|
29
|
+
this.img.style.transformOrigin = 'center center';
|
|
30
|
+
this.host.addEventListener('wheel', this.onWheel, { passive: false });
|
|
31
|
+
this.host.addEventListener('mousedown', this.onDown);
|
|
32
|
+
this.ro = new ResizeObserver(() => this.onResize());
|
|
33
|
+
this.ro.observe(this.host);
|
|
34
|
+
this.apply();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
setZoomStep(zoomStep: number): void {
|
|
38
|
+
this.zoomStep = zoomStep;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
reset(): void {
|
|
42
|
+
this.s = 1;
|
|
43
|
+
this.tx = 0;
|
|
44
|
+
this.ty = 0;
|
|
45
|
+
this.apply();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
private viewport(): { vw: number; vh: number } {
|
|
49
|
+
return { vw: this.host.clientWidth, vh: this.host.clientHeight };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
private clampScale(v: number): number {
|
|
53
|
+
return Math.min(Math.max(v, MIN_SCALE), MAX_SCALE);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Cursor-anchored zoom about the host centre. With transform
|
|
57
|
+
// M(p) = C + s*(p - C) + t, keeping the point under (ax, ay) fixed gives
|
|
58
|
+
// t' = a - C - (s'/s) * (a - C - t).
|
|
59
|
+
private zoomAt(factor: number, ax: number, ay: number): void {
|
|
60
|
+
const sNew = this.clampScale(this.s * factor);
|
|
61
|
+
if (sNew === this.s) {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const { vw, vh } = this.viewport();
|
|
65
|
+
const cx = vw / 2;
|
|
66
|
+
const cy = vh / 2;
|
|
67
|
+
const k = sNew / this.s;
|
|
68
|
+
this.tx = ax - cx - k * (ax - cx - this.tx);
|
|
69
|
+
this.ty = ay - cy - k * (ay - cy - this.ty);
|
|
70
|
+
this.s = sNew;
|
|
71
|
+
this.clampPan();
|
|
72
|
+
this.apply();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private onWheel = (e: WheelEvent): void => {
|
|
76
|
+
e.preventDefault();
|
|
77
|
+
const r = this.host.getBoundingClientRect();
|
|
78
|
+
const factor = e.deltaY < 0 ? 1 + this.zoomStep : 1 / (1 + this.zoomStep);
|
|
79
|
+
this.zoomAt(factor, e.clientX - r.left, e.clientY - r.top);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
zoomIn(): void {
|
|
83
|
+
const { vw, vh } = this.viewport();
|
|
84
|
+
this.zoomAt(1 + this.zoomStep, vw / 2, vh / 2);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
zoomOut(): void {
|
|
88
|
+
const { vw, vh } = this.viewport();
|
|
89
|
+
this.zoomAt(1 / (1 + this.zoomStep), vw / 2, vh / 2);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private canPan(): boolean {
|
|
93
|
+
return this.s > 1;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Bound the translate to the overflow implied by the zoom so the image
|
|
97
|
+
// cannot be panned out of view. Range collapses to 0 as s approaches 1.
|
|
98
|
+
private clampPan(): void {
|
|
99
|
+
const { vw, vh } = this.viewport();
|
|
100
|
+
const mx = (Math.max(this.s, 1) - 1) * vw * 0.5;
|
|
101
|
+
const my = (Math.max(this.s, 1) - 1) * vh * 0.5;
|
|
102
|
+
this.tx = Math.min(mx, Math.max(-mx, this.tx));
|
|
103
|
+
this.ty = Math.min(my, Math.max(-my, this.ty));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private onDown = (e: MouseEvent): void => {
|
|
107
|
+
if (!this.canPan()) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
e.preventDefault();
|
|
111
|
+
this.panning = true;
|
|
112
|
+
this.start = { x: e.clientX, y: e.clientY, tx: this.tx, ty: this.ty };
|
|
113
|
+
this.host.style.cursor = 'grabbing';
|
|
114
|
+
window.addEventListener('mousemove', this.onMove);
|
|
115
|
+
window.addEventListener('mouseup', this.onUp);
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
private onMove = (e: MouseEvent): void => {
|
|
119
|
+
if (!this.panning) {
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
this.tx = this.start.tx + (e.clientX - this.start.x);
|
|
123
|
+
this.ty = this.start.ty + (e.clientY - this.start.y);
|
|
124
|
+
this.clampPan();
|
|
125
|
+
this.apply();
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
private onUp = (): void => {
|
|
129
|
+
this.panning = false;
|
|
130
|
+
this.updateCursor();
|
|
131
|
+
window.removeEventListener('mousemove', this.onMove);
|
|
132
|
+
window.removeEventListener('mouseup', this.onUp);
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
private onResize(): void {
|
|
136
|
+
this.clampPan();
|
|
137
|
+
this.apply();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private updateCursor(): void {
|
|
141
|
+
this.host.style.cursor = this.canPan() ? 'grab' : 'default';
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
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})`;
|
|
147
|
+
this.updateCursor();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
dispose(): void {
|
|
151
|
+
if (this.disposed) {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
this.disposed = true;
|
|
155
|
+
this.ro.disconnect();
|
|
156
|
+
this.host.removeEventListener('wheel', this.onWheel);
|
|
157
|
+
this.host.removeEventListener('mousedown', this.onDown);
|
|
158
|
+
window.removeEventListener('mousemove', this.onMove);
|
|
159
|
+
window.removeEventListener('mouseup', this.onUp);
|
|
160
|
+
}
|
|
161
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import {
|
|
2
|
+
JupyterFrontEnd,
|
|
3
|
+
JupyterFrontEndPlugin
|
|
4
|
+
} from '@jupyterlab/application';
|
|
5
|
+
|
|
6
|
+
import { ToolbarButton } from '@jupyterlab/apputils';
|
|
7
|
+
|
|
8
|
+
import { PathExt } from '@jupyterlab/coreutils';
|
|
9
|
+
|
|
10
|
+
import { IDocumentWidget } from '@jupyterlab/docregistry';
|
|
11
|
+
|
|
12
|
+
import { IImageTracker, ImageViewer } from '@jupyterlab/imageviewer';
|
|
13
|
+
|
|
14
|
+
import { ISettingRegistry } from '@jupyterlab/settingregistry';
|
|
15
|
+
|
|
16
|
+
import { IDisposable } from '@lumino/disposable';
|
|
17
|
+
|
|
18
|
+
import { ViewerController } from './controller';
|
|
19
|
+
|
|
20
|
+
const PLUGIN_ID = 'jupyterlab_advanced_image_viewer_extension:plugin';
|
|
21
|
+
|
|
22
|
+
const CommandIDs = {
|
|
23
|
+
zoomIn: 'advanced-image-viewer:zoom-in',
|
|
24
|
+
zoomOut: 'advanced-image-viewer:zoom-out',
|
|
25
|
+
resetFit: 'advanced-image-viewer:reset-fit',
|
|
26
|
+
previous: 'advanced-image-viewer:previous-image',
|
|
27
|
+
next: 'advanced-image-viewer:next-image'
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const IMAGE_EXTS = new Set([
|
|
31
|
+
'.png',
|
|
32
|
+
'.jpg',
|
|
33
|
+
'.jpeg',
|
|
34
|
+
'.gif',
|
|
35
|
+
'.bmp',
|
|
36
|
+
'.svg',
|
|
37
|
+
'.webp'
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
interface ISettingsState {
|
|
41
|
+
navEnabled: boolean;
|
|
42
|
+
zoomStep: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const defaults: ISettingsState = {
|
|
46
|
+
navEnabled: true,
|
|
47
|
+
zoomStep: 0.1
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const plugin: JupyterFrontEndPlugin<void> = {
|
|
51
|
+
id: PLUGIN_ID,
|
|
52
|
+
description:
|
|
53
|
+
'Advanced image viewer: cursor-anchored wheel zoom, drag-to-pan, fit-to-screen reset, and arrow-key folder navigation.',
|
|
54
|
+
autoStart: true,
|
|
55
|
+
requires: [IImageTracker],
|
|
56
|
+
optional: [ISettingRegistry],
|
|
57
|
+
activate: (
|
|
58
|
+
app: JupyterFrontEnd,
|
|
59
|
+
tracker: IImageTracker,
|
|
60
|
+
settingRegistry: ISettingRegistry | null
|
|
61
|
+
): void => {
|
|
62
|
+
console.log(
|
|
63
|
+
'JupyterLab extension jupyterlab_advanced_image_viewer_extension is activated!'
|
|
64
|
+
);
|
|
65
|
+
const controllers = new WeakMap<ImageViewer, ViewerController>();
|
|
66
|
+
const state: ISettingsState = { ...defaults };
|
|
67
|
+
let keyBindings: IDisposable[] = [];
|
|
68
|
+
|
|
69
|
+
const currentController = (): ViewerController | null => {
|
|
70
|
+
const widget = tracker.currentWidget;
|
|
71
|
+
if (!widget) {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
return controllers.get(widget.content) ?? null;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const attach = (widget: IDocumentWidget<ImageViewer>): void => {
|
|
78
|
+
const viewer = widget.content;
|
|
79
|
+
if (controllers.has(viewer)) {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const host = viewer.node;
|
|
83
|
+
const img = host.querySelector('img');
|
|
84
|
+
if (!img) {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const controller = new ViewerController(
|
|
88
|
+
host,
|
|
89
|
+
img as HTMLImageElement,
|
|
90
|
+
state.zoomStep
|
|
91
|
+
);
|
|
92
|
+
controllers.set(viewer, controller);
|
|
93
|
+
widget.disposed.connect(() => {
|
|
94
|
+
controller.dispose();
|
|
95
|
+
controllers.delete(viewer);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
widget.toolbar.addItem(
|
|
99
|
+
'advanced-zoom-out',
|
|
100
|
+
new ToolbarButton({
|
|
101
|
+
label: '-',
|
|
102
|
+
tooltip: 'Zoom out',
|
|
103
|
+
onClick: () => app.commands.execute(CommandIDs.zoomOut)
|
|
104
|
+
})
|
|
105
|
+
);
|
|
106
|
+
widget.toolbar.addItem(
|
|
107
|
+
'advanced-zoom-in',
|
|
108
|
+
new ToolbarButton({
|
|
109
|
+
label: '+',
|
|
110
|
+
tooltip: 'Zoom in',
|
|
111
|
+
onClick: () => app.commands.execute(CommandIDs.zoomIn)
|
|
112
|
+
})
|
|
113
|
+
);
|
|
114
|
+
widget.toolbar.addItem(
|
|
115
|
+
'advanced-reset-fit',
|
|
116
|
+
new ToolbarButton({
|
|
117
|
+
label: 'Fit',
|
|
118
|
+
tooltip: 'Reset to fit',
|
|
119
|
+
onClick: () => app.commands.execute(CommandIDs.resetFit)
|
|
120
|
+
})
|
|
121
|
+
);
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
tracker.forEach(widget => attach(widget));
|
|
125
|
+
tracker.widgetAdded.connect((_, widget) => attach(widget));
|
|
126
|
+
|
|
127
|
+
app.commands.addCommand(CommandIDs.zoomIn, {
|
|
128
|
+
label: 'Zoom In (Advanced Image Viewer)',
|
|
129
|
+
isEnabled: () => currentController() !== null,
|
|
130
|
+
execute: () => currentController()?.zoomIn()
|
|
131
|
+
});
|
|
132
|
+
app.commands.addCommand(CommandIDs.zoomOut, {
|
|
133
|
+
label: 'Zoom Out (Advanced Image Viewer)',
|
|
134
|
+
isEnabled: () => currentController() !== null,
|
|
135
|
+
execute: () => currentController()?.zoomOut()
|
|
136
|
+
});
|
|
137
|
+
app.commands.addCommand(CommandIDs.resetFit, {
|
|
138
|
+
label: 'Reset to Fit (Advanced Image Viewer)',
|
|
139
|
+
isEnabled: () => currentController() !== null,
|
|
140
|
+
execute: () => currentController()?.reset()
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const navigate = async (delta: number): Promise<void> => {
|
|
144
|
+
const widget = tracker.currentWidget;
|
|
145
|
+
if (!widget) {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const path = widget.context.path;
|
|
149
|
+
const dirPath = PathExt.dirname(path);
|
|
150
|
+
const listing = await app.serviceManager.contents.get(dirPath, {
|
|
151
|
+
content: true
|
|
152
|
+
});
|
|
153
|
+
const content = listing.content as Array<{
|
|
154
|
+
name: string;
|
|
155
|
+
path: string;
|
|
156
|
+
type: string;
|
|
157
|
+
}> | null;
|
|
158
|
+
if (!content) {
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
const images = content
|
|
162
|
+
.filter(
|
|
163
|
+
item =>
|
|
164
|
+
item.type === 'file' &&
|
|
165
|
+
IMAGE_EXTS.has(PathExt.extname(item.name).toLowerCase())
|
|
166
|
+
)
|
|
167
|
+
.sort((a, b) =>
|
|
168
|
+
a.name.localeCompare(b.name, undefined, {
|
|
169
|
+
numeric: true,
|
|
170
|
+
sensitivity: 'base'
|
|
171
|
+
})
|
|
172
|
+
);
|
|
173
|
+
if (images.length <= 1) {
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
const idx = images.findIndex(item => item.path === path);
|
|
177
|
+
if (idx === -1) {
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
const target =
|
|
181
|
+
delta < 0 ? Math.max(0, idx - 1) : Math.min(images.length - 1, idx + 1);
|
|
182
|
+
if (target === idx) {
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
// Advance within a single viewer: open the next image, then close the
|
|
186
|
+
// previous one. The stock image viewer binds each file to its own
|
|
187
|
+
// DocumentWidget, so an in-place file swap is not possible; opening and
|
|
188
|
+
// disposing the previous widget leaves one image tab that advances.
|
|
189
|
+
const previous = widget;
|
|
190
|
+
await app.commands.execute('docmanager:open', {
|
|
191
|
+
path: images[target].path,
|
|
192
|
+
factory: 'Image'
|
|
193
|
+
});
|
|
194
|
+
if (!previous.isDisposed && previous !== tracker.currentWidget) {
|
|
195
|
+
previous.dispose();
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
app.commands.addCommand(CommandIDs.previous, {
|
|
200
|
+
label: 'Previous Image In Folder',
|
|
201
|
+
isEnabled: () => state.navEnabled && tracker.currentWidget !== null,
|
|
202
|
+
execute: () => navigate(-1)
|
|
203
|
+
});
|
|
204
|
+
app.commands.addCommand(CommandIDs.next, {
|
|
205
|
+
label: 'Next Image In Folder',
|
|
206
|
+
isEnabled: () => state.navEnabled && tracker.currentWidget !== null,
|
|
207
|
+
execute: () => navigate(1)
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
const bindKeys = (): void => {
|
|
211
|
+
keyBindings.forEach(b => b.dispose());
|
|
212
|
+
keyBindings = [];
|
|
213
|
+
if (!state.navEnabled) {
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
keyBindings.push(
|
|
217
|
+
app.commands.addKeyBinding({
|
|
218
|
+
command: CommandIDs.previous,
|
|
219
|
+
keys: ['ArrowLeft'],
|
|
220
|
+
selector: '.jp-ImageViewer'
|
|
221
|
+
})
|
|
222
|
+
);
|
|
223
|
+
keyBindings.push(
|
|
224
|
+
app.commands.addKeyBinding({
|
|
225
|
+
command: CommandIDs.next,
|
|
226
|
+
keys: ['ArrowRight'],
|
|
227
|
+
selector: '.jp-ImageViewer'
|
|
228
|
+
})
|
|
229
|
+
);
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const applyState = (): void => {
|
|
233
|
+
tracker.forEach(widget => {
|
|
234
|
+
const controller = controllers.get(widget.content);
|
|
235
|
+
if (controller) {
|
|
236
|
+
controller.setZoomStep(state.zoomStep);
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
bindKeys();
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
if (settingRegistry) {
|
|
243
|
+
settingRegistry
|
|
244
|
+
.load(PLUGIN_ID)
|
|
245
|
+
.then(settings => {
|
|
246
|
+
const read = (): void => {
|
|
247
|
+
const c = settings.composite as Partial<ISettingsState>;
|
|
248
|
+
state.navEnabled = c.navEnabled ?? defaults.navEnabled;
|
|
249
|
+
state.zoomStep = c.zoomStep ?? defaults.zoomStep;
|
|
250
|
+
};
|
|
251
|
+
read();
|
|
252
|
+
applyState();
|
|
253
|
+
settings.changed.connect(() => {
|
|
254
|
+
read();
|
|
255
|
+
applyState();
|
|
256
|
+
});
|
|
257
|
+
})
|
|
258
|
+
.catch(reason => {
|
|
259
|
+
console.error(`Failed to load settings for ${PLUGIN_ID}.`, reason);
|
|
260
|
+
applyState();
|
|
261
|
+
});
|
|
262
|
+
} else {
|
|
263
|
+
applyState();
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
export default plugin;
|
package/style/base.css
ADDED
package/style/index.css
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
@import url('base.css');
|
package/style/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import './base.css';
|