jupyterlab_advanced_image_viewer_extension 1.0.6 → 1.0.9

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
@@ -18,9 +18,10 @@ The viewer toolbar adds zoom in / out / fit controls and a `help` link that list
18
18
 
19
19
  - **Fit to screen** - every image opens scaled to fit the panel (same result as the stock viewer), and reset returns to that fit
20
20
  - **Wheel zoom** - scroll to zoom in and out, anchored at the cursor; zoom out as far as you like
21
- - **Drag to pan** - hold and drag to move around a zoomed-in image
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
24
25
 
25
26
  ## Requirements
26
27
 
@@ -7,7 +7,6 @@ export declare class ViewerController {
7
7
  private panning;
8
8
  private start;
9
9
  private zoomStep;
10
- private ro;
11
10
  private disposed;
12
11
  constructor(host: HTMLElement, img: HTMLImageElement, zoomStep: number);
13
12
  setZoomStep(zoomStep: number): void;
@@ -18,12 +17,9 @@ export declare class ViewerController {
18
17
  private onWheel;
19
18
  zoomIn(): void;
20
19
  zoomOut(): void;
21
- private canPan;
22
- private clampPan;
23
20
  private onDown;
24
21
  private onMove;
25
22
  private onUp;
26
- private onResize;
27
23
  private updateCursor;
28
24
  private apply;
29
25
  dispose(): void;
package/lib/controller.js CHANGED
@@ -23,9 +23,6 @@ export class ViewerController {
23
23
  this.zoomAt(factor, e.clientX - r.left, e.clientY - r.top);
24
24
  };
25
25
  this.onDown = (e) => {
26
- if (!this.canPan()) {
27
- return;
28
- }
29
26
  e.preventDefault();
30
27
  this.panning = true;
31
28
  this.start = { x: e.clientX, y: e.clientY, tx: this.tx, ty: this.ty };
@@ -39,7 +36,6 @@ export class ViewerController {
39
36
  }
40
37
  this.tx = this.start.tx + (e.clientX - this.start.x);
41
38
  this.ty = this.start.ty + (e.clientY - this.start.y);
42
- this.clampPan();
43
39
  this.apply();
44
40
  };
45
41
  this.onUp = () => {
@@ -65,8 +61,6 @@ export class ViewerController {
65
61
  this.layer = layer;
66
62
  this.host.addEventListener('wheel', this.onWheel, { passive: false });
67
63
  this.host.addEventListener('mousedown', this.onDown);
68
- this.ro = new ResizeObserver(() => this.onResize());
69
- this.ro.observe(this.host);
70
64
  this.apply();
71
65
  }
72
66
  setZoomStep(zoomStep) {
@@ -99,7 +93,6 @@ export class ViewerController {
99
93
  this.tx = ax - cx - k * (ax - cx - this.tx);
100
94
  this.ty = ay - cy - k * (ay - cy - this.ty);
101
95
  this.s = sNew;
102
- this.clampPan();
103
96
  this.apply();
104
97
  }
105
98
  zoomIn() {
@@ -110,24 +103,10 @@ export class ViewerController {
110
103
  const { vw, vh } = this.viewport();
111
104
  this.zoomAt(1 / (1 + this.zoomStep), vw / 2, vh / 2);
112
105
  }
113
- canPan() {
114
- return this.s > 1;
115
- }
116
- // Bound the translate to the overflow implied by the zoom so the image
117
- // cannot be panned out of view. Range collapses to 0 as s approaches 1.
118
- clampPan() {
119
- const { vw, vh } = this.viewport();
120
- const mx = (Math.max(this.s, 1) - 1) * vw * 0.5;
121
- const my = (Math.max(this.s, 1) - 1) * vh * 0.5;
122
- this.tx = Math.min(mx, Math.max(-mx, this.tx));
123
- this.ty = Math.min(my, Math.max(-my, this.ty));
124
- }
125
- onResize() {
126
- this.clampPan();
127
- this.apply();
128
- }
106
+ // Pan is always available (canvas-style): show grab normally, grabbing
107
+ // while a drag is in progress (apply() re-runs on every pan move).
129
108
  updateCursor() {
130
- this.host.style.cursor = this.canPan() ? 'grab' : 'default';
109
+ this.host.style.cursor = this.panning ? 'grabbing' : 'grab';
131
110
  }
132
111
  apply() {
133
112
  this.layer.style.transformOrigin = 'center center';
@@ -139,7 +118,6 @@ export class ViewerController {
139
118
  return;
140
119
  }
141
120
  this.disposed = true;
142
- this.ro.disconnect();
143
121
  this.host.removeEventListener('wheel', this.onWheel);
144
122
  this.host.removeEventListener('mousedown', this.onDown);
145
123
  window.removeEventListener('mousemove', this.onMove);
package/lib/index.js CHANGED
@@ -10,7 +10,8 @@ const CommandIDs = {
10
10
  zoomOut: 'advanced-image-viewer:zoom-out',
11
11
  resetFit: 'advanced-image-viewer:reset-fit',
12
12
  previous: 'advanced-image-viewer:previous-image',
13
- next: 'advanced-image-viewer:next-image'
13
+ next: 'advanced-image-viewer:next-image',
14
+ copyClipboard: 'advanced-image-viewer:copy-to-clipboard'
14
15
  };
15
16
  const IMAGE_EXTS = new Set([
16
17
  '.png',
@@ -21,6 +22,9 @@ const IMAGE_EXTS = new Set([
21
22
  '.svg',
22
23
  '.webp'
23
24
  ]);
25
+ // Raster formats that can be drawn to a canvas and copied as PNG. SVG is
26
+ // excluded - the drawio/SVG extension already adds its own "Copy as PNG".
27
+ const RASTER_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp']);
24
28
  const defaults = {
25
29
  navEnabled: true,
26
30
  zoomStep: 0.1
@@ -52,7 +56,7 @@ const plugin = {
52
56
  '<p class="jp-AdvancedImageViewer-help-section">This extension</p>',
53
57
  '<ul>',
54
58
  '<li><b>Wheel up / down</b> - zoom in / out at the cursor</li>',
55
- '<li><b>Click and drag</b> - pan a zoomed-in image</li>',
59
+ '<li><b>Click and drag</b> - pan the image at any zoom</li>',
56
60
  '<li><b>Toolbar buttons</b> - zoom in, zoom out, reset to fit</li>',
57
61
  '<li><b>Left / Right arrows</b> - previous / next image in the folder</li>',
58
62
  '</ul>',
@@ -131,6 +135,59 @@ const plugin = {
131
135
  isEnabled: () => currentController() !== null,
132
136
  execute: () => { var _a; return (_a = currentController()) === null || _a === void 0 ? void 0 : _a.reset(); }
133
137
  });
138
+ // Resolve the image widget under the context-menu target. Right-click does
139
+ // not activate the widget, so hit-test the clicked node rather than rely on
140
+ // tracker.currentWidget.
141
+ const contextImageWidget = () => {
142
+ var _a;
143
+ const node = app.contextMenuHitTest(n => n.classList.contains('jp-ImageViewer'));
144
+ if (!node) {
145
+ return null;
146
+ }
147
+ return (_a = tracker.find(widget => widget.content.node === node)) !== null && _a !== void 0 ? _a : null;
148
+ };
149
+ app.commands.addCommand(CommandIDs.copyClipboard, {
150
+ label: 'Copy to Clipboard',
151
+ // Raster only - SVG already has "Copy as PNG" from the drawio extension.
152
+ isVisible: () => {
153
+ const widget = contextImageWidget();
154
+ return (widget !== null &&
155
+ RASTER_EXTS.has(PathExt.extname(widget.context.path).toLowerCase()));
156
+ },
157
+ execute: async () => {
158
+ var _a;
159
+ const img = (_a = contextImageWidget()) === null || _a === void 0 ? void 0 : _a.content.node.querySelector('img');
160
+ if (!img) {
161
+ return;
162
+ }
163
+ // Copy the source pixels, not the on-screen zoom/pan/rotate view.
164
+ const canvas = document.createElement('canvas');
165
+ canvas.width = img.naturalWidth;
166
+ canvas.height = img.naturalHeight;
167
+ const ctx = canvas.getContext('2d');
168
+ if (!ctx) {
169
+ return;
170
+ }
171
+ ctx.drawImage(img, 0, 0);
172
+ const blob = await new Promise(resolve => canvas.toBlob(resolve, 'image/png'));
173
+ if (!blob) {
174
+ return;
175
+ }
176
+ try {
177
+ await navigator.clipboard.write([
178
+ new ClipboardItem({ 'image/png': blob })
179
+ ]);
180
+ }
181
+ catch (err) {
182
+ console.error('Advanced image viewer: clipboard copy failed', err);
183
+ }
184
+ }
185
+ });
186
+ app.contextMenu.addItem({
187
+ command: CommandIDs.copyClipboard,
188
+ selector: '.jp-ImageViewer',
189
+ rank: 0
190
+ });
134
191
  const navigate = async (delta) => {
135
192
  const widget = tracker.currentWidget;
136
193
  if (!widget) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jupyterlab_advanced_image_viewer_extension",
3
- "version": "1.0.6",
3
+ "version": "1.0.9",
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",
package/src/controller.ts CHANGED
@@ -19,7 +19,6 @@ export class ViewerController {
19
19
  private panning = false;
20
20
  private start = { x: 0, y: 0, tx: 0, ty: 0 };
21
21
  private zoomStep: number;
22
- private ro: ResizeObserver;
23
22
  private disposed = false;
24
23
 
25
24
  constructor(host: HTMLElement, img: HTMLImageElement, zoomStep: number) {
@@ -40,8 +39,6 @@ export class ViewerController {
40
39
  this.layer = layer;
41
40
  this.host.addEventListener('wheel', this.onWheel, { passive: false });
42
41
  this.host.addEventListener('mousedown', this.onDown);
43
- this.ro = new ResizeObserver(() => this.onResize());
44
- this.ro.observe(this.host);
45
42
  this.apply();
46
43
  }
47
44
 
@@ -79,7 +76,6 @@ export class ViewerController {
79
76
  this.tx = ax - cx - k * (ax - cx - this.tx);
80
77
  this.ty = ay - cy - k * (ay - cy - this.ty);
81
78
  this.s = sNew;
82
- this.clampPan();
83
79
  this.apply();
84
80
  }
85
81
 
@@ -100,24 +96,7 @@ export class ViewerController {
100
96
  this.zoomAt(1 / (1 + this.zoomStep), vw / 2, vh / 2);
101
97
  }
102
98
 
103
- private canPan(): boolean {
104
- return this.s > 1;
105
- }
106
-
107
- // Bound the translate to the overflow implied by the zoom so the image
108
- // cannot be panned out of view. Range collapses to 0 as s approaches 1.
109
- private clampPan(): void {
110
- const { vw, vh } = this.viewport();
111
- const mx = (Math.max(this.s, 1) - 1) * vw * 0.5;
112
- const my = (Math.max(this.s, 1) - 1) * vh * 0.5;
113
- this.tx = Math.min(mx, Math.max(-mx, this.tx));
114
- this.ty = Math.min(my, Math.max(-my, this.ty));
115
- }
116
-
117
99
  private onDown = (e: MouseEvent): void => {
118
- if (!this.canPan()) {
119
- return;
120
- }
121
100
  e.preventDefault();
122
101
  this.panning = true;
123
102
  this.start = { x: e.clientX, y: e.clientY, tx: this.tx, ty: this.ty };
@@ -132,7 +111,6 @@ export class ViewerController {
132
111
  }
133
112
  this.tx = this.start.tx + (e.clientX - this.start.x);
134
113
  this.ty = this.start.ty + (e.clientY - this.start.y);
135
- this.clampPan();
136
114
  this.apply();
137
115
  };
138
116
 
@@ -143,13 +121,10 @@ export class ViewerController {
143
121
  window.removeEventListener('mouseup', this.onUp);
144
122
  };
145
123
 
146
- private onResize(): void {
147
- this.clampPan();
148
- this.apply();
149
- }
150
-
124
+ // Pan is always available (canvas-style): show grab normally, grabbing
125
+ // while a drag is in progress (apply() re-runs on every pan move).
151
126
  private updateCursor(): void {
152
- this.host.style.cursor = this.canPan() ? 'grab' : 'default';
127
+ this.host.style.cursor = this.panning ? 'grabbing' : 'grab';
153
128
  }
154
129
 
155
130
  private apply(): void {
@@ -163,7 +138,6 @@ export class ViewerController {
163
138
  return;
164
139
  }
165
140
  this.disposed = true;
166
- this.ro.disconnect();
167
141
  this.host.removeEventListener('wheel', this.onWheel);
168
142
  this.host.removeEventListener('mousedown', this.onDown);
169
143
  window.removeEventListener('mousemove', this.onMove);
package/src/index.ts CHANGED
@@ -31,7 +31,8 @@ const CommandIDs = {
31
31
  zoomOut: 'advanced-image-viewer:zoom-out',
32
32
  resetFit: 'advanced-image-viewer:reset-fit',
33
33
  previous: 'advanced-image-viewer:previous-image',
34
- next: 'advanced-image-viewer:next-image'
34
+ next: 'advanced-image-viewer:next-image',
35
+ copyClipboard: 'advanced-image-viewer:copy-to-clipboard'
35
36
  };
36
37
 
37
38
  const IMAGE_EXTS = new Set([
@@ -44,6 +45,10 @@ const IMAGE_EXTS = new Set([
44
45
  '.webp'
45
46
  ]);
46
47
 
48
+ // Raster formats that can be drawn to a canvas and copied as PNG. SVG is
49
+ // excluded - the drawio/SVG extension already adds its own "Copy as PNG".
50
+ const RASTER_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp']);
51
+
47
52
  interface ISettingsState {
48
53
  navEnabled: boolean;
49
54
  zoomStep: number;
@@ -89,7 +94,7 @@ const plugin: JupyterFrontEndPlugin<void> = {
89
94
  '<p class="jp-AdvancedImageViewer-help-section">This extension</p>',
90
95
  '<ul>',
91
96
  '<li><b>Wheel up / down</b> - zoom in / out at the cursor</li>',
92
- '<li><b>Click and drag</b> - pan a zoomed-in image</li>',
97
+ '<li><b>Click and drag</b> - pan the image at any zoom</li>',
93
98
  '<li><b>Toolbar buttons</b> - zoom in, zoom out, reset to fit</li>',
94
99
  '<li><b>Left / Right arrows</b> - previous / next image in the folder</li>',
95
100
  '</ul>',
@@ -186,6 +191,65 @@ const plugin: JupyterFrontEndPlugin<void> = {
186
191
  execute: () => currentController()?.reset()
187
192
  });
188
193
 
194
+ // Resolve the image widget under the context-menu target. Right-click does
195
+ // not activate the widget, so hit-test the clicked node rather than rely on
196
+ // tracker.currentWidget.
197
+ const contextImageWidget = (): IDocumentWidget<ImageViewer> | null => {
198
+ const node = app.contextMenuHitTest(n =>
199
+ n.classList.contains('jp-ImageViewer')
200
+ );
201
+ if (!node) {
202
+ return null;
203
+ }
204
+ return tracker.find(widget => widget.content.node === node) ?? null;
205
+ };
206
+
207
+ app.commands.addCommand(CommandIDs.copyClipboard, {
208
+ label: 'Copy to Clipboard',
209
+ // Raster only - SVG already has "Copy as PNG" from the drawio extension.
210
+ isVisible: () => {
211
+ const widget = contextImageWidget();
212
+ return (
213
+ widget !== null &&
214
+ RASTER_EXTS.has(PathExt.extname(widget.context.path).toLowerCase())
215
+ );
216
+ },
217
+ execute: async () => {
218
+ const img = contextImageWidget()?.content.node.querySelector('img');
219
+ if (!img) {
220
+ return;
221
+ }
222
+ // Copy the source pixels, not the on-screen zoom/pan/rotate view.
223
+ const canvas = document.createElement('canvas');
224
+ canvas.width = img.naturalWidth;
225
+ canvas.height = img.naturalHeight;
226
+ const ctx = canvas.getContext('2d');
227
+ if (!ctx) {
228
+ return;
229
+ }
230
+ ctx.drawImage(img, 0, 0);
231
+ const blob = await new Promise<Blob | null>(resolve =>
232
+ canvas.toBlob(resolve, 'image/png')
233
+ );
234
+ if (!blob) {
235
+ return;
236
+ }
237
+ try {
238
+ await navigator.clipboard.write([
239
+ new ClipboardItem({ 'image/png': blob })
240
+ ]);
241
+ } catch (err) {
242
+ console.error('Advanced image viewer: clipboard copy failed', err);
243
+ }
244
+ }
245
+ });
246
+
247
+ app.contextMenu.addItem({
248
+ command: CommandIDs.copyClipboard,
249
+ selector: '.jp-ImageViewer',
250
+ rank: 0
251
+ });
252
+
189
253
  const navigate = async (delta: number): Promise<void> => {
190
254
  const widget = tracker.currentWidget;
191
255
  if (!widget) {