jupyterlab_advanced_image_viewer_extension 1.1.2 → 1.1.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.
package/README.md CHANGED
@@ -20,14 +20,14 @@ The viewer toolbar adds zoom in / out / fit controls and a `help` link that list
20
20
  - **Wheel zoom** - scroll to zoom in and out, anchored at the cursor; zoom out as far as you like
21
21
  - **Drag to pan** - hold and drag to move the image at any zoom; the image can touch a viewport border but never leave it, the grab cursor is always available, and Fit recenters
22
22
  - **Toolbar controls** - zoom in, zoom out, and reset-to-fit buttons on the viewer toolbar
23
- - **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
23
+ - **Arrow-key navigation** - Left and Right open the previous and next image in the same folder, in the order the file browser lists them, advancing within one viewer instead of opening new tabs
24
24
  - **Copy to clipboard** - right-click a raster image (PNG/JPG/…) and choose "Copy to Clipboard" to copy it as PNG to the system clipboard
25
25
  - **Refresh** - a toolbar refresh button reloads the image from disk, the same control the HTML viewer provides
26
26
  - **WebP support** - `.webp` files open in the image viewer with every feature above; stock JupyterLab opens them in the text editor
27
27
 
28
28
  ## Requirements
29
29
 
30
- - JupyterLab >= 4.0.0
30
+ - JupyterLab >= 4.6
31
31
 
32
32
  ## Install
33
33
 
package/lib/controller.js CHANGED
@@ -53,7 +53,11 @@ export class ViewerController {
53
53
  this.zoomAt(factor, e.clientX - r.left, e.clientY - r.top);
54
54
  };
55
55
  this.onDown = (e) => {
56
+ // preventDefault stops the browser dragging the image out, and with it the
57
+ // focus a click would give the viewer; focus it here, or after a toolbar
58
+ // click the stock keys and Left/Right go to the button instead.
56
59
  e.preventDefault();
60
+ this.host.focus();
57
61
  this.panning = true;
58
62
  this.start = { x: e.clientX, y: e.clientY, tx: this.tx, ty: this.ty };
59
63
  this.host.style.cursor = 'grabbing';
package/lib/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { Dialog, showDialog, Toolbar, ToolbarButton } from '@jupyterlab/apputils';
2
2
  import { PathExt } from '@jupyterlab/coreutils';
3
+ import { IDefaultFileBrowser } from '@jupyterlab/filebrowser';
3
4
  import { IImageTracker } from '@jupyterlab/imageviewer';
4
5
  import { ISettingRegistry } from '@jupyterlab/settingregistry';
5
6
  import { refreshIcon } from '@jupyterlab/ui-components';
@@ -35,8 +36,8 @@ const plugin = {
35
36
  description: 'Advanced image viewer: cursor-anchored wheel zoom, drag-to-pan, fit-to-screen reset, and arrow-key folder navigation.',
36
37
  autoStart: true,
37
38
  requires: [IImageTracker],
38
- optional: [ISettingRegistry],
39
- activate: (app, tracker, settingRegistry) => {
39
+ optional: [ISettingRegistry, IDefaultFileBrowser],
40
+ activate: (app, tracker, settingRegistry, fileBrowser) => {
40
41
  console.log('JupyterLab extension jupyterlab_advanced_image_viewer_extension is activated!');
41
42
  const controllers = new WeakMap();
42
43
  const state = { ...defaults };
@@ -97,6 +98,7 @@ const plugin = {
97
98
  widget.toolbar.addItem('advanced-refresh', new ToolbarButton({
98
99
  icon: refreshIcon,
99
100
  tooltip: 'Reload image from disk',
101
+ noFocusOnClick: true,
100
102
  onClick: async () => {
101
103
  await widget.context.revert();
102
104
  widget.update();
@@ -105,16 +107,19 @@ const plugin = {
105
107
  widget.toolbar.addItem('advanced-zoom-out', new ToolbarButton({
106
108
  label: '-',
107
109
  tooltip: 'Zoom out',
110
+ noFocusOnClick: true,
108
111
  onClick: () => app.commands.execute(CommandIDs.zoomOut)
109
112
  }));
110
113
  widget.toolbar.addItem('advanced-zoom-in', new ToolbarButton({
111
114
  label: '+',
112
115
  tooltip: 'Zoom in',
116
+ noFocusOnClick: true,
113
117
  onClick: () => app.commands.execute(CommandIDs.zoomIn)
114
118
  }));
115
119
  widget.toolbar.addItem('advanced-reset-fit', new ToolbarButton({
116
120
  label: 'Fit',
117
121
  tooltip: 'Reset to fit',
122
+ noFocusOnClick: true,
118
123
  onClick: () => app.commands.execute(CommandIDs.resetFit)
119
124
  }));
120
125
  widget.toolbar.addItem('advanced-spacer', Toolbar.createSpacerItem());
@@ -218,20 +223,39 @@ const plugin = {
218
223
  }
219
224
  const path = widget.context.path;
220
225
  const dirPath = PathExt.dirname(path);
221
- const listing = await app.serviceManager.contents.get(dirPath, {
222
- content: true
223
- });
224
- const content = listing.content;
225
- if (!content) {
226
- return;
226
+ const isImage = (item) => item.type === 'file' &&
227
+ IMAGE_EXTS.has(PathExt.extname(item.name).toLowerCase());
228
+ // Step through the images in the order the file browser shows them
229
+ // when it lists this folder, so its sort key, its direction and any
230
+ // sort plugin decide what comes next: a C-locale sort puts '.' before
231
+ // '_' where localeCompare puts '_' first, and a re-sort here skipped
232
+ // files. FileBrowser keeps `listing` protected, but it is the only
233
+ // source of the displayed order. The listing re-sorts on a refresh only
234
+ // while it is visible, so a hidden one is re-sorted here; re-sorting a
235
+ // visible one would re-render every row on each step. Otherwise fall
236
+ // back to natural name order, the stock file browser default.
237
+ let images = [];
238
+ if (fileBrowser && fileBrowser.model.path === dirPath) {
239
+ const shown = fileBrowser
240
+ .listing;
241
+ if (!shown.isVisible) {
242
+ shown.sort(shown.sortState);
243
+ }
244
+ images = Array.from(shown.sortedItems()).filter(isImage);
245
+ }
246
+ if (!images.some(item => item.path === path)) {
247
+ const listing = await app.serviceManager.contents.get(dirPath, {
248
+ content: true
249
+ });
250
+ const content = listing.content;
251
+ if (!content) {
252
+ return;
253
+ }
254
+ images = content.filter(isImage).sort((a, b) => a.name.localeCompare(b.name, undefined, {
255
+ numeric: true,
256
+ sensitivity: 'base'
257
+ }));
227
258
  }
228
- const images = content
229
- .filter(item => item.type === 'file' &&
230
- IMAGE_EXTS.has(PathExt.extname(item.name).toLowerCase()))
231
- .sort((a, b) => a.name.localeCompare(b.name, undefined, {
232
- numeric: true,
233
- sensitivity: 'base'
234
- }));
235
259
  if (images.length <= 1) {
236
260
  return;
237
261
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jupyterlab_advanced_image_viewer_extension",
3
- "version": "1.1.2",
3
+ "version": "1.1.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",
@@ -31,8 +31,8 @@
31
31
  "scripts": {
32
32
  "build": "jlpm build:lib && jlpm build:labextension:dev",
33
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 .",
34
+ "build:labextension": "jupyter-builder build .",
35
+ "build:labextension:dev": "jupyter-builder build --development True .",
36
36
  "build:lib": "tsc --sourceMap",
37
37
  "build:lib:prod": "tsc",
38
38
  "clean": "jlpm clean:lib",
@@ -41,7 +41,7 @@
41
41
  "clean:labextension": "rimraf jupyterlab_advanced_image_viewer_extension/labextension jupyterlab_advanced_image_viewer_extension/_version.py",
42
42
  "clean:all": "jlpm clean:lib && jlpm clean:labextension && jlpm clean:lintcache",
43
43
  "eslint": "jlpm eslint:check --fix",
44
- "eslint:check": "eslint . --cache --ext .ts,.tsx",
44
+ "eslint:check": "eslint . --cache",
45
45
  "install:extension": "jlpm build",
46
46
  "lint": "jlpm stylelint && jlpm prettier && jlpm eslint",
47
47
  "lint:check": "jlpm stylelint:check && jlpm prettier:check && jlpm eslint:check",
@@ -53,46 +53,46 @@
53
53
  "test": "jest --coverage",
54
54
  "watch": "run-p watch:src watch:labextension",
55
55
  "watch:src": "tsc -w --sourceMap",
56
- "watch:labextension": "jupyter labextension watch ."
56
+ "watch:labextension": "jupyter-builder watch ."
57
57
  },
58
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
- "@jupyterlab/ui-components": "^4.0.0",
66
- "@lumino/disposable": "^2.0.0",
67
- "@lumino/widgets": "^2.0.0"
59
+ "@jupyterlab/application": "^4.6.0",
60
+ "@jupyterlab/apputils": "^4.7.0",
61
+ "@jupyterlab/coreutils": "^6.6.0",
62
+ "@jupyterlab/docregistry": "^4.6.0",
63
+ "@jupyterlab/filebrowser": "^4.6.0",
64
+ "@jupyterlab/imageviewer": "^4.6.0",
65
+ "@jupyterlab/settingregistry": "^4.6.0",
66
+ "@jupyterlab/ui-components": "^4.6.0",
67
+ "@lumino/disposable": "^2.1.0",
68
+ "@lumino/widgets": "^2.9.0"
68
69
  },
69
70
  "devDependencies": {
70
- "@jupyterlab/builder": "^4.0.0",
71
- "@jupyterlab/testutils": "^4.0.0",
72
- "@types/jest": "^29.2.0",
71
+ "@eslint/js": "^9.39.0",
72
+ "@jupyter/builder": "^1.2.0",
73
+ "@jupyter/eslint-plugin": "^1.3.0",
74
+ "@jupyterlab/testutils": "^4.6.0",
75
+ "@types/jest": "^29.5.0",
73
76
  "@types/json-schema": "^7.0.11",
74
- "@types/react": "^18.0.26",
77
+ "@types/react": "^18.3.0",
75
78
  "@types/react-addons-linked-state-mixin": "^0.14.22",
76
- "@typescript-eslint/eslint-plugin": "^6.1.0",
77
- "@typescript-eslint/parser": "^6.1.0",
78
- "css-loader": "^6.7.1",
79
- "eslint": "^8.36.0",
80
- "eslint-config-prettier": "^8.8.0",
81
- "eslint-plugin-prettier": "^5.0.0",
82
- "jest": "^29.2.0",
79
+ "eslint": "^9.39.0",
80
+ "eslint-config-prettier": "^9.1.0",
81
+ "eslint-plugin-prettier": "^5.5.0",
82
+ "globals": "^15.15.0",
83
+ "jest": "^29.7.0",
83
84
  "mkdirp": "^1.0.3",
84
85
  "npm-run-all2": "^7.0.1",
85
- "prettier": "^3.0.0",
86
+ "prettier": "^3.9.0",
86
87
  "rimraf": "^5.0.1",
87
- "source-map-loader": "^1.0.2",
88
- "style-loader": "^3.3.1",
89
- "stylelint": "^15.10.1",
88
+ "stylelint": "^15.11.0",
90
89
  "stylelint-config-recommended": "^13.0.0",
91
90
  "stylelint-config-standard": "^34.0.0",
92
- "stylelint-csstree-validator": "^3.0.0",
93
- "stylelint-prettier": "^4.0.0",
91
+ "stylelint-csstree-validator": "^3.1.0",
92
+ "stylelint-prettier": "^4.1.0",
94
93
  "typescript": "~5.8.0",
95
- "yjs": "^13.5.0"
94
+ "typescript-eslint": "^8.71.0",
95
+ "yjs": "^13.6.0"
96
96
  },
97
97
  "resolutions": {
98
98
  "lib0": "0.2.111",
@@ -117,69 +117,6 @@
117
117
  "outputDir": "jupyterlab_advanced_image_viewer_extension/labextension",
118
118
  "schemaDir": "schema"
119
119
  },
120
- "eslintIgnore": [
121
- "node_modules",
122
- "dist",
123
- "coverage",
124
- "**/*.d.ts",
125
- "tests",
126
- "**/__tests__",
127
- "ui-tests"
128
- ],
129
- "eslintConfig": {
130
- "extends": [
131
- "eslint:recommended",
132
- "plugin:@typescript-eslint/eslint-recommended",
133
- "plugin:@typescript-eslint/recommended",
134
- "plugin:prettier/recommended"
135
- ],
136
- "parser": "@typescript-eslint/parser",
137
- "parserOptions": {
138
- "project": "tsconfig.json",
139
- "sourceType": "module"
140
- },
141
- "plugins": [
142
- "@typescript-eslint"
143
- ],
144
- "rules": {
145
- "@typescript-eslint/naming-convention": [
146
- "error",
147
- {
148
- "selector": "interface",
149
- "format": [
150
- "PascalCase"
151
- ],
152
- "custom": {
153
- "regex": "^I[A-Z]",
154
- "match": true
155
- }
156
- }
157
- ],
158
- "@typescript-eslint/no-unused-vars": [
159
- "warn",
160
- {
161
- "args": "none"
162
- }
163
- ],
164
- "@typescript-eslint/no-explicit-any": "off",
165
- "@typescript-eslint/no-namespace": "off",
166
- "@typescript-eslint/no-use-before-define": "off",
167
- "@typescript-eslint/quotes": [
168
- "error",
169
- "single",
170
- {
171
- "avoidEscape": true,
172
- "allowTemplateLiterals": false
173
- }
174
- ],
175
- "curly": [
176
- "error",
177
- "all"
178
- ],
179
- "eqeqeq": "error",
180
- "prefer-arrow-callback": "error"
181
- }
182
- },
183
120
  "prettier": {
184
121
  "singleQuote": true,
185
122
  "trailingComma": "none",
@@ -297,6 +297,23 @@ describe('ViewerController', () => {
297
297
  c.dispose();
298
298
  });
299
299
 
300
+ // DEF-KEYS-2: the mousedown handler cancels the browser's image drag, which
301
+ // also cancels the focus a click gives; the viewer must take focus itself.
302
+ it('takes keyboard focus from a toolbar button when the image is pressed', () => {
303
+ host.tabIndex = 0;
304
+ const button = document.createElement('button');
305
+ document.body.appendChild(button);
306
+ new ViewerController(host, img, 0.1);
307
+ button.focus();
308
+ expect(document.activeElement).toBe(button);
309
+ host.dispatchEvent(
310
+ new MouseEvent('mousedown', { clientX: 0, clientY: 0, cancelable: true })
311
+ );
312
+ window.dispatchEvent(new MouseEvent('mouseup'));
313
+ expect(document.activeElement).toBe(host);
314
+ button.remove();
315
+ });
316
+
300
317
  it('stops observing the host and the image on dispose', () => {
301
318
  stubGeometry(80, 60);
302
319
  const c = new ViewerController(host, img, 0.1);
package/src/controller.ts CHANGED
@@ -147,7 +147,11 @@ export class ViewerController {
147
147
  }
148
148
 
149
149
  private onDown = (e: MouseEvent): void => {
150
+ // preventDefault stops the browser dragging the image out, and with it the
151
+ // focus a click would give the viewer; focus it here, or after a toolbar
152
+ // click the stock keys and Left/Right go to the button instead.
150
153
  e.preventDefault();
154
+ this.host.focus();
151
155
  this.panning = true;
152
156
  this.start = { x: e.clientX, y: e.clientY, tx: this.tx, ty: this.ty };
153
157
  this.host.style.cursor = 'grabbing';
package/src/index.ts CHANGED
@@ -14,6 +14,8 @@ import { PathExt } from '@jupyterlab/coreutils';
14
14
 
15
15
  import { IDocumentWidget } from '@jupyterlab/docregistry';
16
16
 
17
+ import { DirListing, IDefaultFileBrowser } from '@jupyterlab/filebrowser';
18
+
17
19
  import { IImageTracker, ImageViewer } from '@jupyterlab/imageviewer';
18
20
 
19
21
  import { ISettingRegistry } from '@jupyterlab/settingregistry';
@@ -67,11 +69,12 @@ const plugin: JupyterFrontEndPlugin<void> = {
67
69
  'Advanced image viewer: cursor-anchored wheel zoom, drag-to-pan, fit-to-screen reset, and arrow-key folder navigation.',
68
70
  autoStart: true,
69
71
  requires: [IImageTracker],
70
- optional: [ISettingRegistry],
72
+ optional: [ISettingRegistry, IDefaultFileBrowser],
71
73
  activate: (
72
74
  app: JupyterFrontEnd,
73
75
  tracker: IImageTracker,
74
- settingRegistry: ISettingRegistry | null
76
+ settingRegistry: ISettingRegistry | null,
77
+ fileBrowser: IDefaultFileBrowser | null
75
78
  ): void => {
76
79
  console.log(
77
80
  'JupyterLab extension jupyterlab_advanced_image_viewer_extension is activated!'
@@ -144,6 +147,7 @@ const plugin: JupyterFrontEndPlugin<void> = {
144
147
  new ToolbarButton({
145
148
  icon: refreshIcon,
146
149
  tooltip: 'Reload image from disk',
150
+ noFocusOnClick: true,
147
151
  onClick: async () => {
148
152
  await widget.context.revert();
149
153
  widget.update();
@@ -155,6 +159,7 @@ const plugin: JupyterFrontEndPlugin<void> = {
155
159
  new ToolbarButton({
156
160
  label: '-',
157
161
  tooltip: 'Zoom out',
162
+ noFocusOnClick: true,
158
163
  onClick: () => app.commands.execute(CommandIDs.zoomOut)
159
164
  })
160
165
  );
@@ -163,6 +168,7 @@ const plugin: JupyterFrontEndPlugin<void> = {
163
168
  new ToolbarButton({
164
169
  label: '+',
165
170
  tooltip: 'Zoom in',
171
+ noFocusOnClick: true,
166
172
  onClick: () => app.commands.execute(CommandIDs.zoomIn)
167
173
  })
168
174
  );
@@ -171,6 +177,7 @@ const plugin: JupyterFrontEndPlugin<void> = {
171
177
  new ToolbarButton({
172
178
  label: 'Fit',
173
179
  tooltip: 'Reset to fit',
180
+ noFocusOnClick: true,
174
181
  onClick: () => app.commands.execute(CommandIDs.resetFit)
175
182
  })
176
183
  );
@@ -285,29 +292,46 @@ const plugin: JupyterFrontEndPlugin<void> = {
285
292
  }
286
293
  const path = widget.context.path;
287
294
  const dirPath = PathExt.dirname(path);
288
- const listing = await app.serviceManager.contents.get(dirPath, {
289
- content: true
290
- });
291
- const content = listing.content as Array<{
292
- name: string;
293
- path: string;
294
- type: string;
295
- }> | null;
296
- if (!content) {
297
- return;
295
+ const isImage = (item: { name: string; type: string }): boolean =>
296
+ item.type === 'file' &&
297
+ IMAGE_EXTS.has(PathExt.extname(item.name).toLowerCase());
298
+ // Step through the images in the order the file browser shows them
299
+ // when it lists this folder, so its sort key, its direction and any
300
+ // sort plugin decide what comes next: a C-locale sort puts '.' before
301
+ // '_' where localeCompare puts '_' first, and a re-sort here skipped
302
+ // files. FileBrowser keeps `listing` protected, but it is the only
303
+ // source of the displayed order. The listing re-sorts on a refresh only
304
+ // while it is visible, so a hidden one is re-sorted here; re-sorting a
305
+ // visible one would re-render every row on each step. Otherwise fall
306
+ // back to natural name order, the stock file browser default.
307
+ let images: Array<{ name: string; path: string; type: string }> = [];
308
+ if (fileBrowser && fileBrowser.model.path === dirPath) {
309
+ const shown = (fileBrowser as unknown as { listing: DirListing })
310
+ .listing;
311
+ if (!shown.isVisible) {
312
+ shown.sort(shown.sortState);
313
+ }
314
+ images = Array.from(shown.sortedItems()).filter(isImage);
298
315
  }
299
- const images = content
300
- .filter(
301
- item =>
302
- item.type === 'file' &&
303
- IMAGE_EXTS.has(PathExt.extname(item.name).toLowerCase())
304
- )
305
- .sort((a, b) =>
316
+ if (!images.some(item => item.path === path)) {
317
+ const listing = await app.serviceManager.contents.get(dirPath, {
318
+ content: true
319
+ });
320
+ const content = listing.content as Array<{
321
+ name: string;
322
+ path: string;
323
+ type: string;
324
+ }> | null;
325
+ if (!content) {
326
+ return;
327
+ }
328
+ images = content.filter(isImage).sort((a, b) =>
306
329
  a.name.localeCompare(b.name, undefined, {
307
330
  numeric: true,
308
331
  sensitivity: 'base'
309
332
  })
310
333
  );
334
+ }
311
335
  if (images.length <= 1) {
312
336
  return;
313
337
  }