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 +2 -1
- package/lib/controller.d.ts +0 -4
- package/lib/controller.js +3 -25
- package/lib/index.js +59 -2
- package/package.json +1 -1
- package/src/controller.ts +3 -29
- package/src/index.ts +66 -2
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
|
|
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
|
|
package/lib/controller.d.ts
CHANGED
|
@@ -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
|
-
|
|
114
|
-
|
|
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.
|
|
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
|
|
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.
|
|
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
|
-
|
|
147
|
-
|
|
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.
|
|
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
|
|
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) {
|