jupyterlab_advanced_image_viewer_extension 1.0.7 → 1.0.11

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
@@ -21,6 +21,8 @@ The viewer toolbar adds zoom in / out / fit controls and a `help` link that list
21
21
  - **Drag to pan** - hold and drag to move the image at any zoom (canvas-style); the grab cursor is always available and Fit recenters
22
22
  - **Toolbar controls** - zoom in, zoom out, and reset-to-fit buttons on the viewer toolbar
23
23
  - **Arrow-key navigation** - Left and Right open the previous and next image in the same folder, advancing within one viewer instead of opening new tabs
24
+ - **Copy to clipboard** - right-click a raster image (PNG/JPG/…) and choose "Copy to Clipboard" to copy it as PNG to the system clipboard
25
+ - **Refresh** - a toolbar refresh button reloads the image from disk, the same control the HTML viewer provides
24
26
 
25
27
  ## Requirements
26
28
 
package/lib/index.js CHANGED
@@ -2,6 +2,7 @@ import { Dialog, showDialog, Toolbar, ToolbarButton } from '@jupyterlab/apputils
2
2
  import { PathExt } from '@jupyterlab/coreutils';
3
3
  import { IImageTracker } from '@jupyterlab/imageviewer';
4
4
  import { ISettingRegistry } from '@jupyterlab/settingregistry';
5
+ import { refreshIcon } from '@jupyterlab/ui-components';
5
6
  import { Widget } from '@lumino/widgets';
6
7
  import { ViewerController } from './controller';
7
8
  const PLUGIN_ID = 'jupyterlab_advanced_image_viewer_extension:plugin';
@@ -10,7 +11,8 @@ const CommandIDs = {
10
11
  zoomOut: 'advanced-image-viewer:zoom-out',
11
12
  resetFit: 'advanced-image-viewer:reset-fit',
12
13
  previous: 'advanced-image-viewer:previous-image',
13
- next: 'advanced-image-viewer:next-image'
14
+ next: 'advanced-image-viewer:next-image',
15
+ copyClipboard: 'advanced-image-viewer:copy-to-clipboard'
14
16
  };
15
17
  const IMAGE_EXTS = new Set([
16
18
  '.png',
@@ -21,6 +23,9 @@ const IMAGE_EXTS = new Set([
21
23
  '.svg',
22
24
  '.webp'
23
25
  ]);
26
+ // Raster formats that can be drawn to a canvas and copied as PNG. SVG is
27
+ // excluded - the drawio/SVG extension already adds its own "Copy as PNG".
28
+ const RASTER_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp']);
24
29
  const defaults = {
25
30
  navEnabled: true,
26
31
  zoomStep: 0.1
@@ -86,6 +91,17 @@ const plugin = {
86
91
  controller.dispose();
87
92
  controllers.delete(viewer);
88
93
  });
94
+ // Reload from disk, like the HTML viewer's refresh button. That button
95
+ // skips the reload while the model is dirty; an image model is never
96
+ // edited, so there is nothing here to discard.
97
+ widget.toolbar.addItem('advanced-refresh', new ToolbarButton({
98
+ icon: refreshIcon,
99
+ tooltip: 'Reload image from disk',
100
+ onClick: async () => {
101
+ await widget.context.revert();
102
+ widget.update();
103
+ }
104
+ }));
89
105
  widget.toolbar.addItem('advanced-zoom-out', new ToolbarButton({
90
106
  label: '-',
91
107
  tooltip: 'Zoom out',
@@ -131,6 +147,59 @@ const plugin = {
131
147
  isEnabled: () => currentController() !== null,
132
148
  execute: () => { var _a; return (_a = currentController()) === null || _a === void 0 ? void 0 : _a.reset(); }
133
149
  });
150
+ // Resolve the image widget under the context-menu target. Right-click does
151
+ // not activate the widget, so hit-test the clicked node rather than rely on
152
+ // tracker.currentWidget.
153
+ const contextImageWidget = () => {
154
+ var _a;
155
+ const node = app.contextMenuHitTest(n => n.classList.contains('jp-ImageViewer'));
156
+ if (!node) {
157
+ return null;
158
+ }
159
+ return (_a = tracker.find(widget => widget.content.node === node)) !== null && _a !== void 0 ? _a : null;
160
+ };
161
+ app.commands.addCommand(CommandIDs.copyClipboard, {
162
+ label: 'Copy to Clipboard',
163
+ // Raster only - SVG already has "Copy as PNG" from the drawio extension.
164
+ isVisible: () => {
165
+ const widget = contextImageWidget();
166
+ return (widget !== null &&
167
+ RASTER_EXTS.has(PathExt.extname(widget.context.path).toLowerCase()));
168
+ },
169
+ execute: async () => {
170
+ var _a;
171
+ const img = (_a = contextImageWidget()) === null || _a === void 0 ? void 0 : _a.content.node.querySelector('img');
172
+ if (!img) {
173
+ return;
174
+ }
175
+ // Copy the source pixels, not the on-screen zoom/pan/rotate view.
176
+ const canvas = document.createElement('canvas');
177
+ canvas.width = img.naturalWidth;
178
+ canvas.height = img.naturalHeight;
179
+ const ctx = canvas.getContext('2d');
180
+ if (!ctx) {
181
+ return;
182
+ }
183
+ ctx.drawImage(img, 0, 0);
184
+ const blob = await new Promise(resolve => canvas.toBlob(resolve, 'image/png'));
185
+ if (!blob) {
186
+ return;
187
+ }
188
+ try {
189
+ await navigator.clipboard.write([
190
+ new ClipboardItem({ 'image/png': blob })
191
+ ]);
192
+ }
193
+ catch (err) {
194
+ console.error('Advanced image viewer: clipboard copy failed', err);
195
+ }
196
+ }
197
+ });
198
+ app.contextMenu.addItem({
199
+ command: CommandIDs.copyClipboard,
200
+ selector: '.jp-ImageViewer',
201
+ rank: 0
202
+ });
134
203
  const navigate = async (delta) => {
135
204
  const widget = tracker.currentWidget;
136
205
  if (!widget) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jupyterlab_advanced_image_viewer_extension",
3
- "version": "1.0.7",
3
+ "version": "1.0.11",
4
4
  "description": "JupyterLab extension for advanced image viewing: fit-to-screen, cursor-anchored wheel zoom, drag-to-pan, and Left/Right arrow keys to move to the previous and next image in a folder",
5
5
  "keywords": [
6
6
  "jupyter",
@@ -62,6 +62,7 @@
62
62
  "@jupyterlab/docregistry": "^4.0.0",
63
63
  "@jupyterlab/imageviewer": "^4.0.0",
64
64
  "@jupyterlab/settingregistry": "^4.0.0",
65
+ "@jupyterlab/ui-components": "^4.0.0",
65
66
  "@lumino/disposable": "^2.0.0",
66
67
  "@lumino/widgets": "^2.0.0"
67
68
  },
package/src/index.ts CHANGED
@@ -18,6 +18,8 @@ import { IImageTracker, ImageViewer } from '@jupyterlab/imageviewer';
18
18
 
19
19
  import { ISettingRegistry } from '@jupyterlab/settingregistry';
20
20
 
21
+ import { refreshIcon } from '@jupyterlab/ui-components';
22
+
21
23
  import { IDisposable } from '@lumino/disposable';
22
24
 
23
25
  import { Widget } from '@lumino/widgets';
@@ -31,7 +33,8 @@ const CommandIDs = {
31
33
  zoomOut: 'advanced-image-viewer:zoom-out',
32
34
  resetFit: 'advanced-image-viewer:reset-fit',
33
35
  previous: 'advanced-image-viewer:previous-image',
34
- next: 'advanced-image-viewer:next-image'
36
+ next: 'advanced-image-viewer:next-image',
37
+ copyClipboard: 'advanced-image-viewer:copy-to-clipboard'
35
38
  };
36
39
 
37
40
  const IMAGE_EXTS = new Set([
@@ -44,6 +47,10 @@ const IMAGE_EXTS = new Set([
44
47
  '.webp'
45
48
  ]);
46
49
 
50
+ // Raster formats that can be drawn to a canvas and copied as PNG. SVG is
51
+ // excluded - the drawio/SVG extension already adds its own "Copy as PNG".
52
+ const RASTER_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp']);
53
+
47
54
  interface ISettingsState {
48
55
  navEnabled: boolean;
49
56
  zoomStep: number;
@@ -129,6 +136,20 @@ const plugin: JupyterFrontEndPlugin<void> = {
129
136
  controllers.delete(viewer);
130
137
  });
131
138
 
139
+ // Reload from disk, like the HTML viewer's refresh button. That button
140
+ // skips the reload while the model is dirty; an image model is never
141
+ // edited, so there is nothing here to discard.
142
+ widget.toolbar.addItem(
143
+ 'advanced-refresh',
144
+ new ToolbarButton({
145
+ icon: refreshIcon,
146
+ tooltip: 'Reload image from disk',
147
+ onClick: async () => {
148
+ await widget.context.revert();
149
+ widget.update();
150
+ }
151
+ })
152
+ );
132
153
  widget.toolbar.addItem(
133
154
  'advanced-zoom-out',
134
155
  new ToolbarButton({
@@ -186,6 +207,65 @@ const plugin: JupyterFrontEndPlugin<void> = {
186
207
  execute: () => currentController()?.reset()
187
208
  });
188
209
 
210
+ // Resolve the image widget under the context-menu target. Right-click does
211
+ // not activate the widget, so hit-test the clicked node rather than rely on
212
+ // tracker.currentWidget.
213
+ const contextImageWidget = (): IDocumentWidget<ImageViewer> | null => {
214
+ const node = app.contextMenuHitTest(n =>
215
+ n.classList.contains('jp-ImageViewer')
216
+ );
217
+ if (!node) {
218
+ return null;
219
+ }
220
+ return tracker.find(widget => widget.content.node === node) ?? null;
221
+ };
222
+
223
+ app.commands.addCommand(CommandIDs.copyClipboard, {
224
+ label: 'Copy to Clipboard',
225
+ // Raster only - SVG already has "Copy as PNG" from the drawio extension.
226
+ isVisible: () => {
227
+ const widget = contextImageWidget();
228
+ return (
229
+ widget !== null &&
230
+ RASTER_EXTS.has(PathExt.extname(widget.context.path).toLowerCase())
231
+ );
232
+ },
233
+ execute: async () => {
234
+ const img = contextImageWidget()?.content.node.querySelector('img');
235
+ if (!img) {
236
+ return;
237
+ }
238
+ // Copy the source pixels, not the on-screen zoom/pan/rotate view.
239
+ const canvas = document.createElement('canvas');
240
+ canvas.width = img.naturalWidth;
241
+ canvas.height = img.naturalHeight;
242
+ const ctx = canvas.getContext('2d');
243
+ if (!ctx) {
244
+ return;
245
+ }
246
+ ctx.drawImage(img, 0, 0);
247
+ const blob = await new Promise<Blob | null>(resolve =>
248
+ canvas.toBlob(resolve, 'image/png')
249
+ );
250
+ if (!blob) {
251
+ return;
252
+ }
253
+ try {
254
+ await navigator.clipboard.write([
255
+ new ClipboardItem({ 'image/png': blob })
256
+ ]);
257
+ } catch (err) {
258
+ console.error('Advanced image viewer: clipboard copy failed', err);
259
+ }
260
+ }
261
+ });
262
+
263
+ app.contextMenu.addItem({
264
+ command: CommandIDs.copyClipboard,
265
+ selector: '.jp-ImageViewer',
266
+ rank: 0
267
+ });
268
+
189
269
  const navigate = async (delta: number): Promise<void> => {
190
270
  const widget = tracker.currentWidget;
191
271
  if (!widget) {