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 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
+ [![GitHub Actions](https://github.com/stellarshenson/jupyterlab_advanced_image_viewer_extension/actions/workflows/build.yml/badge.svg)](https://github.com/stellarshenson/jupyterlab_advanced_image_viewer_extension/actions/workflows/build.yml)
4
+ [![npm version](https://img.shields.io/npm/v/jupyterlab_advanced_image_viewer_extension.svg)](https://www.npmjs.com/package/jupyterlab_advanced_image_viewer_extension)
5
+ [![PyPI version](https://img.shields.io/pypi/v/jupyterlab-advanced-image-viewer-extension.svg)](https://pypi.org/project/jupyterlab-advanced-image-viewer-extension/)
6
+ [![Total PyPI downloads](https://static.pepy.tech/badge/jupyterlab-advanced-image-viewer-extension)](https://pepy.tech/project/jupyterlab-advanced-image-viewer-extension)
7
+ [![JupyterLab 4](https://img.shields.io/badge/JupyterLab-4-orange.svg)](https://jupyterlab.readthedocs.io/en/stable/)
8
+ [![Brought To You By KOLOMOLO](https://img.shields.io/badge/Brought%20To%20You%20By-KOLOMOLO-00ffff?style=flat)](https://kolomolo.com)
9
+ [![Donate PayPal](https://img.shields.io/badge/Donate-PayPal-blue?style=flat)](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
@@ -0,0 +1,3 @@
1
+ import { JupyterFrontEndPlugin } from '@jupyterlab/application';
2
+ declare const plugin: JupyterFrontEndPlugin<void>;
3
+ export default plugin;
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,9 @@
1
+ /**
2
+ * Example of [Jest](https://jestjs.io/docs/getting-started) unit tests
3
+ */
4
+
5
+ describe('jupyterlab_advanced_image_viewer_extension', () => {
6
+ it('should be tested', () => {
7
+ expect(1 + 1).toEqual(2);
8
+ });
9
+ });
@@ -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
@@ -0,0 +1,5 @@
1
+ /*
2
+ See the JupyterLab Developer Guide for useful CSS Patterns:
3
+
4
+ https://jupyterlab.readthedocs.io/en/stable/developer/css.html
5
+ */
@@ -0,0 +1 @@
1
+ @import url('base.css');
package/style/index.js ADDED
@@ -0,0 +1 @@
1
+ import './base.css';