@fails-components/jupyter-react-edit 0.0.1-alpha.10

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) 2024, Marten Richter
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,65 @@
1
+ !["FAILS logo"](failslogo.svg)
2
+
3
+ # Fancy automated internet lecture system (**FAILS**) - components (jupyter react edit)
4
+
5
+ (c) 2024 Marten Richter
6
+
7
+ The package provides a React component to include jupyter within FAILS, but may be usuable outside of fails.
8
+
9
+ This package is part of FAILS.
10
+ A web-based lecture system developed out of university lectures.
11
+
12
+ While FAILS as a whole is licensed via GNU Affero GPL version 3.0, this package is licensed under a BSD-style license that can be found in the LICENSE file.
13
+ This package is licensed more permissive since it can be useful outside of the FAILS environment, esspecially if you want to integrate Jupyter Lite into your application.
14
+ So the default license from Jupyter is used to be license compatible.
15
+
16
+ ## Installation and usage
17
+
18
+ ### Installation
19
+
20
+ You can install the package directly via npm from node.js:
21
+
22
+ For installation simply run:
23
+
24
+ ```
25
+ npm install @fails-components/jupyter-react-edit
26
+ ```
27
+
28
+ ### Usage
29
+
30
+ You can integrate the React component via
31
+
32
+ ```
33
+ import { JupyterEdit } from '@fails-components/jupyter-react-edit'
34
+
35
+ ...
36
+
37
+ <JupyterEdit
38
+ editActivated={/* true or false, to control activation */}
39
+ jupyterurl={window.location.origin + '/jupyter/index.html' /* provides the URL of your jupyter lite deployment */}
40
+ pointerOff={
41
+ /* turn off and on mouse pointer inter actzion*/
42
+ }
43
+ rerunAtStartup={/* if true rerun all cells at startup*/}
44
+ installScreenShotPatches={
45
+ /* if true patches are installed within jupyter to allow for screenshots of applets*/
46
+ }
47
+ ref={/* you may want to track the reference to interact with the control*/}
48
+ document={/* initial javascript object representing the jupyter file */}
49
+ filename={/* file name */}
50
+ appid={/* you can set an id to show only a specific applet, instead of full editing */}
51
+ stateCallback={/* call back to process changes of the state of the jupyter applets */}
52
+ appletSizeChanged={(appid, width, height) => {
53
+ /* callback invoked if the size of an applet changes */
54
+ }}
55
+ kernelStatusCallback={status => {
56
+ /* callback, if the kernel status changed */
57
+ }}
58
+ receiveInterceptorUpdate={({ path, mime, state }) => {
59
+ /* information from the interceptor about a state update */
60
+ }}
61
+ />;
62
+
63
+ ```
64
+
65
+ For more information please look at the usuage of the componenent inside fails, notably inside `@fails-components\app` and `@fails-components\lectureapp`.
package/lib/index.d.ts ADDED
@@ -0,0 +1 @@
1
+ export * from './jupyteredit.js';
package/lib/index.js ADDED
@@ -0,0 +1 @@
1
+ export * from './jupyteredit.js';
@@ -0,0 +1,53 @@
1
+ import React, { Component } from 'react';
2
+ import { IFailsToJupyterMessage, IInterceptorUpdate, IScreenShotOpts, IFailsAppletSize, IGDPRProxyInfo } from '@fails-components/jupyter-launcher';
3
+ import { JSONObject } from '@lumino/coreutils';
4
+ import '../style/index.css';
5
+ interface IJupyterState {
6
+ dirty?: boolean;
7
+ failsApp?: JSONObject;
8
+ kernelspec?: JSONObject;
9
+ }
10
+ interface IJupyterEditProps {
11
+ stateCallback?: (state: IJupyterState) => void;
12
+ receiveInterceptorUpdate?: (update: IInterceptorUpdate) => void;
13
+ kernelStatusCallback?: (status: string) => void;
14
+ appletSizeChanged?: (appid: string, width: number, height: number) => void;
15
+ jupyterurl: string;
16
+ filename: string;
17
+ document: JSONObject | undefined;
18
+ appid?: string;
19
+ rerunAtStartup: boolean;
20
+ installScreenShotPatches: boolean;
21
+ GDPRProxy?: IGDPRProxyInfo;
22
+ editActivated?: boolean;
23
+ pointerOff?: boolean;
24
+ }
25
+ interface IJupyterEditState {
26
+ appletSizes?: {
27
+ [key: string]: IFailsAppletSize;
28
+ } | undefined;
29
+ dirty: boolean;
30
+ appLoading: boolean;
31
+ }
32
+ export declare class JupyterEdit extends Component<IJupyterEditProps, IJupyterEditState> {
33
+ constructor(props: IJupyterEditProps);
34
+ componentDidMount(): void;
35
+ componentDidUpdate(prevProps: IJupyterEditProps): void;
36
+ componentWillUnmount(): void;
37
+ loadJupyter(): void;
38
+ saveJupyter(): Promise<any>;
39
+ screenShot({ dpi }: IScreenShotOpts): Promise<any>;
40
+ activateApp(): Promise<any>;
41
+ getLicenses(): Promise<any>;
42
+ restartKernelAndRunCells(): Promise<any>;
43
+ activateInterceptor(activate: boolean): Promise<any>;
44
+ sendInterceptorUpdate({ path, mime, state }: IInterceptorUpdate): Promise<any>;
45
+ onMessage(event: MessageEvent): void;
46
+ sendToIFrame(message: IFailsToJupyterMessage): void;
47
+ sendToIFrameAndReceive(message: IFailsToJupyterMessage): Promise<any>;
48
+ render(): React.JSX.Element;
49
+ private _iframe;
50
+ private _requestId;
51
+ private _requests;
52
+ }
53
+ export {};
@@ -0,0 +1,288 @@
1
+ /*
2
+ BSD 3-Clause License
3
+
4
+ Copyright (c) 2024, Marten Richter
5
+ All rights reserved.
6
+
7
+ Redistribution and use in source and binary forms, with or without
8
+ modification, are permitted provided that the following conditions are met:
9
+
10
+ 1. Redistributions of source code must retain the above copyright notice, this
11
+ list of conditions and the following disclaimer.
12
+
13
+ 2. Redistributions in binary form must reproduce the above copyright notice,
14
+ this list of conditions and the following disclaimer in the documentation
15
+ and/or other materials provided with the distribution.
16
+
17
+ 3. Neither the name of the copyright holder nor the names of its
18
+ contributors may be used to endorse or promote products derived from
19
+ this software without specific prior written permission.
20
+
21
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
22
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
23
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
24
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
25
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
26
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
27
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
28
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
29
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
30
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
31
+ */
32
+ import React, { Component, Fragment } from 'react';
33
+ import '../style/index.css';
34
+ export class JupyterEdit extends Component {
35
+ constructor(props) {
36
+ super(props);
37
+ this._iframe = null;
38
+ this._requestId = 1; // id, if we request something
39
+ this._requests = new Map();
40
+ this.state = { dirty: false, appLoading: true };
41
+ this.onMessage = this.onMessage.bind(this);
42
+ if (props.stateCallback) {
43
+ props.stateCallback({ dirty: false });
44
+ }
45
+ }
46
+ componentDidMount() {
47
+ if (!this.props.jupyterurl) {
48
+ throw new Error('No jupyter url passed');
49
+ }
50
+ window.addEventListener('message', this.onMessage);
51
+ if (this.props.receiveInterceptorUpdate) {
52
+ this.activateInterceptor(true);
53
+ }
54
+ }
55
+ componentDidUpdate(prevProps) {
56
+ if (!this.props.jupyterurl) {
57
+ throw new Error('No jupyter url passed');
58
+ }
59
+ if (this.props.appid !== prevProps.appid) {
60
+ this.activateApp();
61
+ }
62
+ if (this.props.receiveInterceptorUpdate !== prevProps.receiveInterceptorUpdate) {
63
+ this.activateInterceptor(!!this.props.receiveInterceptorUpdate);
64
+ }
65
+ }
66
+ componentWillUnmount() {
67
+ window.removeEventListener('message', this.onMessage);
68
+ }
69
+ loadJupyter() {
70
+ var _a, _b, _c;
71
+ const data = this.props.document;
72
+ const metadata = data === null || data === void 0 ? void 0 : data.metadata;
73
+ if (metadata === null || metadata === void 0 ? void 0 : metadata.kernelspec) {
74
+ const kernelspec = metadata === null || metadata === void 0 ? void 0 : metadata.kernelspec;
75
+ if ((kernelspec === null || kernelspec === void 0 ? void 0 : kernelspec.name) !== 'python' && (kernelspec === null || kernelspec === void 0 ? void 0 : kernelspec.name) !== 'xpython') {
76
+ // replace the kernel
77
+ kernelspec.name = 'python';
78
+ kernelspec.display_name = 'Python (Pyodide)';
79
+ kernelspec.language = 'python';
80
+ kernelspec.name = 'python';
81
+ }
82
+ }
83
+ this.sendToIFrame({
84
+ type: 'loadJupyter',
85
+ inLecture: !!this.props.appid,
86
+ rerunAtStartup: !!this.props.rerunAtStartup,
87
+ installScreenShotPatches: !!this.props.installScreenShotPatches,
88
+ installGDPRProxy: this.props.GDPRProxy,
89
+ appid: this.props.appid,
90
+ fileName: this.props.filename || 'example.ipynb',
91
+ fileData: data,
92
+ kernelName: (_c = (_b = (_a = data === null || data === void 0 ? void 0 : data.metadata) === null || _a === void 0 ? void 0 : _a.kernelspec) === null || _b === void 0 ? void 0 : _b.name) !== null && _c !== void 0 ? _c : 'python'
93
+ });
94
+ }
95
+ async saveJupyter() {
96
+ const fileToSaveObj = await this.sendToIFrameAndReceive({
97
+ type: 'saveJupyter',
98
+ fileName: this.props.filename || 'example.ipynb'
99
+ });
100
+ if (!fileToSaveObj.fileData) {
101
+ throw new Error('Empty saveJupyter response');
102
+ }
103
+ return fileToSaveObj.fileData;
104
+ }
105
+ async screenShot({ dpi }) {
106
+ const { screenshot } = await this.sendToIFrameAndReceive({
107
+ type: 'screenshotApp',
108
+ dpi
109
+ });
110
+ return screenshot;
111
+ }
112
+ activateApp() {
113
+ const appid = this.props.appid;
114
+ return this.sendToIFrameAndReceive({
115
+ type: 'activateApp',
116
+ inLecture: !!appid,
117
+ appid
118
+ });
119
+ }
120
+ async getLicenses() {
121
+ return this.sendToIFrameAndReceive({
122
+ type: 'getLicenses'
123
+ });
124
+ }
125
+ async restartKernelAndRunCells() {
126
+ return this.sendToIFrameAndReceive({
127
+ type: 'restartKernelAndRerunCells'
128
+ });
129
+ }
130
+ activateInterceptor(activate) {
131
+ return this.sendToIFrameAndReceive({
132
+ type: 'activateInterceptor',
133
+ activate
134
+ });
135
+ }
136
+ sendInterceptorUpdate({ path, mime, state }) {
137
+ return this.sendToIFrameAndReceive({
138
+ type: 'receiveInterceptorUpdate',
139
+ path,
140
+ mime,
141
+ state
142
+ });
143
+ }
144
+ onMessage(event) {
145
+ var _a, _b, _c, _d, _e, _f;
146
+ if (event.source === window) {
147
+ return;
148
+ }
149
+ if (event.source !== ((_a = this._iframe) === null || _a === void 0 ? void 0 : _a.contentWindow)) {
150
+ return;
151
+ }
152
+ if (event.origin !== new URL(this.props.jupyterurl).origin) {
153
+ return;
154
+ }
155
+ const data = event.data;
156
+ if (event.data.requestId) {
157
+ const requestId = event.data.requestId;
158
+ if (this._requests.has(requestId)) {
159
+ const request = this._requests.get(requestId);
160
+ this._requests.delete(requestId);
161
+ if (event.data.error) {
162
+ request.reject(new Error(event.data.error));
163
+ return;
164
+ }
165
+ request.resolve(event.data);
166
+ return;
167
+ }
168
+ }
169
+ switch (data === null || data === void 0 ? void 0 : data.task) {
170
+ case 'appLoaded':
171
+ this.setState({ appLoading: false });
172
+ this.loadJupyter();
173
+ break;
174
+ case 'docDirty':
175
+ {
176
+ const { dirty = undefined } = data;
177
+ if (this.props.stateCallback && typeof dirty !== 'undefined') {
178
+ this.props.stateCallback({ dirty });
179
+ }
180
+ }
181
+ break;
182
+ case 'reportMetadata':
183
+ {
184
+ const { failsApp = undefined, kernelspec = undefined } = (_b = data === null || data === void 0 ? void 0 : data.metadata) !== null && _b !== void 0 ? _b : {};
185
+ if (this.props.stateCallback &&
186
+ (typeof failsApp !== 'undefined' ||
187
+ typeof kernelspec !== 'undefined')) {
188
+ this.props.stateCallback({ failsApp, kernelspec });
189
+ }
190
+ }
191
+ break;
192
+ case 'reportFailsAppletSizes':
193
+ {
194
+ const { appletSizes = undefined } = data;
195
+ if (typeof appletSizes !== 'undefined') {
196
+ this.setState(state => {
197
+ var _a, _b, _c;
198
+ const retState = {};
199
+ for (const appletSize of Object.values(appletSizes)) {
200
+ const { appid, height, width } = appletSize;
201
+ if ((_a = state === null || state === void 0 ? void 0 : state.appletSizes) === null || _a === void 0 ? void 0 : _a[appid]) {
202
+ const oldsize = state.appletSizes[appid];
203
+ if (oldsize.height === height && oldsize.width === width) {
204
+ continue;
205
+ }
206
+ }
207
+ if (!retState.appletSizes) {
208
+ retState.appletSizes = {};
209
+ }
210
+ retState.appletSizes[appid] = { width, height, appid };
211
+ (_c = (_b = this.props) === null || _b === void 0 ? void 0 : _b.appletSizeChanged) === null || _c === void 0 ? void 0 : _c.call(_b, appid, width, height);
212
+ }
213
+ return retState;
214
+ });
215
+ }
216
+ }
217
+ break;
218
+ case 'reportKernelStatus':
219
+ (_d = (_c = this.props) === null || _c === void 0 ? void 0 : _c.kernelStatusCallback) === null || _d === void 0 ? void 0 : _d.call(_c, data.status);
220
+ break;
221
+ case 'sendInterceptorUpdate':
222
+ {
223
+ const { path, mime, state } = data;
224
+ (_f = (_e = this.props) === null || _e === void 0 ? void 0 : _e.receiveInterceptorUpdate) === null || _f === void 0 ? void 0 : _f.call(_e, { path, mime, state });
225
+ }
226
+ break;
227
+ default:
228
+ }
229
+ }
230
+ sendToIFrame(message) {
231
+ var _a;
232
+ if (this._iframe) {
233
+ (_a = this._iframe.contentWindow) === null || _a === void 0 ? void 0 : _a.postMessage(message, this.props.jupyterurl);
234
+ }
235
+ }
236
+ async sendToIFrameAndReceive(message) {
237
+ const requestId = this._requestId++;
238
+ return new Promise((resolve, reject) => {
239
+ this._requests.set(requestId, {
240
+ requestId,
241
+ resolve,
242
+ reject
243
+ });
244
+ this.sendToIFrame({
245
+ // @ts-expect-error requestId is not included in type
246
+ requestId,
247
+ ...message
248
+ });
249
+ });
250
+ }
251
+ render() {
252
+ // launch debugging in the following way:
253
+ // jupyter lab --allow-root --ServerApp.allow_origin='*' --ServerApp.tornado_settings="{'headers': {'Content-Security-Policy': 'frame-ancestors self *'}}" --ServerApp.allow_websocket_origin='*' --ServerApp.cookie_options="{'samesite': 'None', 'secure': True}"
254
+ // jupyter lab --allow-root --ServerApp.allow_origin='*' --ServerApp.tornado_settings="{'headers': {'Content-Security-Policy': 'frame-ancestors self *'}}" --ServerApp.allow_websocket_origin='*' --ServerApp.cookie_options="{'samesite': 'None', 'secure': True}" --LabServerApp.app_settings_dir=/workspaces/jupyterfails/development/config/app-edit
255
+ // do it only in a container!
256
+ if (!this.props.editActivated) {
257
+ return React.createElement(Fragment, null, "JupyterEdit is not activated");
258
+ }
259
+ let width = '100%';
260
+ let height = '99%';
261
+ if (this.props.appid) {
262
+ const appletSize = this.state.appletSizes && this.state.appletSizes[this.props.appid];
263
+ if (appletSize) {
264
+ width = Math.ceil(appletSize.width * 1.01) + 'px';
265
+ height = Math.ceil(appletSize.height * 1.01) + 'px';
266
+ }
267
+ }
268
+ let className = 'jpt-edit-iframe';
269
+ if (this.props.pointerOff) {
270
+ className += ' jpyt-edit-iframe-pointeroff';
271
+ }
272
+ return (React.createElement(Fragment, null,
273
+ React.createElement("iframe", { style: { width, height }, className: className, src: this.props.jupyterurl, ref: el => {
274
+ this._iframe = el;
275
+ }, onLoad: () => {
276
+ console.log('Jupyter iframe loaded');
277
+ }, allow: "",
278
+ // @ts-expect-error credentialless
279
+ credentialless: "true", sandbox: "allow-scripts allow-downloads allow-same-origin allow-popups" // we need allow-forms for a local jupyter server, remove for jupyterlite
280
+ , title: "jupyteredit" }),
281
+ this.state.appLoading && (React.createElement("h2", { style: {
282
+ position: 'absolute',
283
+ top: '50%',
284
+ left: '50%',
285
+ transform: 'translate(-50%, -50%)'
286
+ } }, "Jupyter is loading, be patient..."))));
287
+ }
288
+ }
package/package.json ADDED
@@ -0,0 +1,185 @@
1
+ {
2
+ "name": "@fails-components/jupyter-react-edit",
3
+ "version": "0.0.1-alpha.10",
4
+ "description": "A react control, that lets you embed jupyter lite in your application including applets with screenshots etc.",
5
+ "keywords": [
6
+ "jupyter",
7
+ "jupyterlab",
8
+ "react",
9
+ "iframe"
10
+ ],
11
+ "homepage": "https://github.com/fails-components/jupyterfails",
12
+ "bugs": {
13
+ "url": "https://github.com/fails-components/jupyterfails/issues"
14
+ },
15
+ "license": "BSD-3-Clause",
16
+ "author": {
17
+ "name": "Marten Richter",
18
+ "email": "marten.richter@freenet.de"
19
+ },
20
+ "files": [
21
+ "lib/**/*.{d.ts,eot,gif,html,jpg,js,js.map,json,png,svg,woff2,ttf}",
22
+ "style/**/*.{css,js,eot,gif,html,jpg,json,png,svg,woff2,ttf}",
23
+ "src/**/*.{ts,tsx}",
24
+ "LICENSE",
25
+ "README.md"
26
+ ],
27
+ "main": "lib/index.js",
28
+ "types": "lib/index.d.ts",
29
+ "style": "style/index.css",
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "https://github.com/fails-components/jupyterfails.git"
33
+ },
34
+ "scripts": {
35
+ "build": "jlpm build:lib",
36
+ "build:prod": "jlpm clean && jlpm build:lib:prod",
37
+ "build:lib": "tsc --sourceMap",
38
+ "build:lib:prod": "tsc",
39
+ "clean": "jlpm clean:lib",
40
+ "clean:lib": "rimraf lib tsconfig.tsbuildinfo",
41
+ "clean:lintcache": "rimraf .eslintcache .stylelintcache",
42
+ "clean:all": "jlpm clean:lib && jlpm clean:lintcache",
43
+ "eslint": "jlpm eslint:check --fix",
44
+ "eslint:check": "eslint . --cache --ext .ts,.tsx",
45
+ "lint": "jlpm stylelint && jlpm prettier && jlpm eslint",
46
+ "lint:check": "jlpm stylelint:check && jlpm prettier:check && jlpm eslint:check",
47
+ "prepublishOnly": "npm run build",
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 --passWithNoTests",
54
+ "watch": "run-p watch:src",
55
+ "watch:src": "tsc -w --sourceMap"
56
+ },
57
+ "devDependencies": {
58
+ "@fails-components/jupyter-launcher": "^0.0.1-alpha.10",
59
+ "@types/jest": "^29.2.0",
60
+ "@types/json-schema": "^7.0.11",
61
+ "@types/react": "^18.0.26",
62
+ "@types/react-addons-linked-state-mixin": "^0.14.22",
63
+ "@typescript-eslint/eslint-plugin": "^6.1.0",
64
+ "@typescript-eslint/parser": "^6.1.0",
65
+ "css-loader": "^6.7.1",
66
+ "eslint": "^8.36.0",
67
+ "eslint-config-prettier": "^8.8.0",
68
+ "eslint-plugin-prettier": "^5.0.0",
69
+ "jest": "^29.2.0",
70
+ "npm-run-all": "^4.1.5",
71
+ "prettier": "^3.0.0",
72
+ "rimraf": "^5.0.1",
73
+ "source-map-loader": "^1.0.2",
74
+ "style-loader": "^3.3.1",
75
+ "stylelint": "^15.10.1",
76
+ "stylelint-config-recommended": "^13.0.0",
77
+ "stylelint-config-standard": "^34.0.0",
78
+ "stylelint-csstree-validator": "^3.0.0",
79
+ "stylelint-prettier": "^4.0.0",
80
+ "typescript": "~5.0.2",
81
+ "yjs": "^13.5.0"
82
+ },
83
+ "sideEffects": [
84
+ "style/*.css",
85
+ "style/index.js"
86
+ ],
87
+ "styleModule": "style/index.js",
88
+ "publishConfig": {
89
+ "access": "public"
90
+ },
91
+ "eslintIgnore": [
92
+ "node_modules",
93
+ "dist",
94
+ "coverage",
95
+ "**/*.d.ts",
96
+ "tests",
97
+ "**/__tests__",
98
+ "ui-tests"
99
+ ],
100
+ "eslintConfig": {
101
+ "extends": [
102
+ "eslint:recommended",
103
+ "plugin:@typescript-eslint/eslint-recommended",
104
+ "plugin:@typescript-eslint/recommended",
105
+ "plugin:prettier/recommended"
106
+ ],
107
+ "parser": "@typescript-eslint/parser",
108
+ "parserOptions": {
109
+ "project": "tsconfig.json",
110
+ "sourceType": "module"
111
+ },
112
+ "plugins": [
113
+ "@typescript-eslint"
114
+ ],
115
+ "rules": {
116
+ "@typescript-eslint/naming-convention": [
117
+ "error",
118
+ {
119
+ "selector": "interface",
120
+ "format": [
121
+ "PascalCase"
122
+ ],
123
+ "custom": {
124
+ "regex": "^I[A-Z]",
125
+ "match": true
126
+ }
127
+ }
128
+ ],
129
+ "@typescript-eslint/no-unused-vars": [
130
+ "warn",
131
+ {
132
+ "args": "none"
133
+ }
134
+ ],
135
+ "@typescript-eslint/no-explicit-any": "off",
136
+ "@typescript-eslint/no-namespace": "off",
137
+ "@typescript-eslint/no-use-before-define": "off",
138
+ "@typescript-eslint/quotes": [
139
+ "error",
140
+ "single",
141
+ {
142
+ "avoidEscape": true,
143
+ "allowTemplateLiterals": false
144
+ }
145
+ ],
146
+ "curly": [
147
+ "error",
148
+ "all"
149
+ ],
150
+ "eqeqeq": "error",
151
+ "prefer-arrow-callback": "error"
152
+ }
153
+ },
154
+ "prettier": {
155
+ "singleQuote": true,
156
+ "trailingComma": "none",
157
+ "arrowParens": "avoid",
158
+ "endOfLine": "auto",
159
+ "overrides": [
160
+ {
161
+ "files": "package.json",
162
+ "options": {
163
+ "tabWidth": 4
164
+ }
165
+ }
166
+ ]
167
+ },
168
+ "stylelint": {
169
+ "extends": [
170
+ "stylelint-config-recommended",
171
+ "stylelint-config-standard",
172
+ "stylelint-prettier/recommended"
173
+ ],
174
+ "plugins": [
175
+ "stylelint-csstree-validator"
176
+ ],
177
+ "rules": {
178
+ "csstree/validator": true,
179
+ "property-no-vendor-prefix": null,
180
+ "selector-class-pattern": "^([a-z][A-z\\d]*)(-[A-z\\d]+)*$",
181
+ "selector-no-vendor-prefix": null,
182
+ "value-no-vendor-prefix": null
183
+ }
184
+ }
185
+ }
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export * from './jupyteredit.js';
@@ -0,0 +1,385 @@
1
+ /*
2
+ BSD 3-Clause License
3
+
4
+ Copyright (c) 2024, Marten Richter
5
+ All rights reserved.
6
+
7
+ Redistribution and use in source and binary forms, with or without
8
+ modification, are permitted provided that the following conditions are met:
9
+
10
+ 1. Redistributions of source code must retain the above copyright notice, this
11
+ list of conditions and the following disclaimer.
12
+
13
+ 2. Redistributions in binary form must reproduce the above copyright notice,
14
+ this list of conditions and the following disclaimer in the documentation
15
+ and/or other materials provided with the distribution.
16
+
17
+ 3. Neither the name of the copyright holder nor the names of its
18
+ contributors may be used to endorse or promote products derived from
19
+ this software without specific prior written permission.
20
+
21
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
22
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
23
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
24
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
25
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
26
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
27
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
28
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
29
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
30
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
31
+ */
32
+ import React, { Component, Fragment } from 'react';
33
+ import {
34
+ IJupyterToFailsMessage,
35
+ IFailsToJupyterMessage,
36
+ IInterceptorUpdate,
37
+ IReportFailsAppletSizes,
38
+ IScreenShotOpts,
39
+ IDocDirty,
40
+ IFailsAppletSize,
41
+ IGDPRProxyInfo
42
+ } from '@fails-components/jupyter-launcher';
43
+ import { JSONObject, PartialJSONObject } from '@lumino/coreutils';
44
+ import '../style/index.css';
45
+
46
+ interface IJupyterState {
47
+ dirty?: boolean;
48
+ failsApp?: JSONObject;
49
+ kernelspec?: JSONObject;
50
+ }
51
+
52
+ interface IJupyterEditProps {
53
+ stateCallback?: (state: IJupyterState) => void;
54
+ receiveInterceptorUpdate?: (update: IInterceptorUpdate) => void;
55
+ kernelStatusCallback?: (status: string) => void;
56
+ appletSizeChanged?: (appid: string, width: number, height: number) => void;
57
+ jupyterurl: string; // url of the jupyter lite distribution
58
+ filename: string; // filename of the document
59
+ document: JSONObject | undefined; // the document as javascript object
60
+ appid?: string; // id of the applet or undefined if not in applet mode
61
+ rerunAtStartup: boolean;
62
+ installScreenShotPatches: boolean; // install patches to allow screenshots of plotly
63
+ GDPRProxy?: IGDPRProxyInfo;
64
+ editActivated?: boolean; // whether the jupyter edit is activated.
65
+ pointerOff?: boolean; // if true, no pointer interaction with jupyter is possible
66
+ }
67
+
68
+ interface IJupyterEditState {
69
+ appletSizes?:
70
+ | {
71
+ [key: string]: IFailsAppletSize;
72
+ }
73
+ | undefined;
74
+ dirty: boolean;
75
+ appLoading: boolean;
76
+ }
77
+
78
+ interface IDocumentMetadata extends PartialJSONObject {
79
+ kernelspec?: JSONObject;
80
+ }
81
+
82
+ export class JupyterEdit extends Component<
83
+ IJupyterEditProps,
84
+ IJupyterEditState
85
+ > {
86
+ constructor(props: IJupyterEditProps) {
87
+ super(props);
88
+ this.state = { dirty: false, appLoading: true };
89
+ this.onMessage = this.onMessage.bind(this);
90
+ if (props.stateCallback) {
91
+ props.stateCallback({ dirty: false });
92
+ }
93
+ }
94
+
95
+ componentDidMount() {
96
+ if (!this.props.jupyterurl) {
97
+ throw new Error('No jupyter url passed');
98
+ }
99
+ window.addEventListener('message', this.onMessage);
100
+ if (this.props.receiveInterceptorUpdate) {
101
+ this.activateInterceptor(true);
102
+ }
103
+ }
104
+
105
+ componentDidUpdate(prevProps: IJupyterEditProps) {
106
+ if (!this.props.jupyterurl) {
107
+ throw new Error('No jupyter url passed');
108
+ }
109
+ if (this.props.appid !== prevProps.appid) {
110
+ this.activateApp();
111
+ }
112
+ if (
113
+ this.props.receiveInterceptorUpdate !== prevProps.receiveInterceptorUpdate
114
+ ) {
115
+ this.activateInterceptor(!!this.props.receiveInterceptorUpdate);
116
+ }
117
+ }
118
+
119
+ componentWillUnmount() {
120
+ window.removeEventListener('message', this.onMessage);
121
+ }
122
+
123
+ loadJupyter() {
124
+ const data = this.props.document;
125
+ const metadata = data?.metadata as IDocumentMetadata | null;
126
+ if (metadata?.kernelspec) {
127
+ const kernelspec = metadata?.kernelspec;
128
+ if (kernelspec?.name !== 'python' && kernelspec?.name !== 'xpython') {
129
+ // replace the kernel
130
+ kernelspec.name = 'python';
131
+ kernelspec.display_name = 'Python (Pyodide)';
132
+ kernelspec.language = 'python';
133
+ kernelspec.name = 'python';
134
+ }
135
+ }
136
+ this.sendToIFrame({
137
+ type: 'loadJupyter',
138
+ inLecture: !!this.props.appid,
139
+ rerunAtStartup: !!this.props.rerunAtStartup,
140
+ installScreenShotPatches: !!this.props.installScreenShotPatches,
141
+ installGDPRProxy: this.props.GDPRProxy,
142
+ appid: this.props.appid,
143
+ fileName: this.props.filename || 'example.ipynb',
144
+ fileData: data,
145
+ kernelName:
146
+ ((data?.metadata as IDocumentMetadata)?.kernelspec?.name as
147
+ | 'python'
148
+ | 'xpython'
149
+ | undefined) ?? 'python'
150
+ });
151
+ }
152
+
153
+ async saveJupyter() {
154
+ const fileToSaveObj = await this.sendToIFrameAndReceive({
155
+ type: 'saveJupyter',
156
+ fileName: this.props.filename || 'example.ipynb'
157
+ });
158
+ if (!fileToSaveObj.fileData) {
159
+ throw new Error('Empty saveJupyter response');
160
+ }
161
+ return fileToSaveObj.fileData;
162
+ }
163
+
164
+ async screenShot({ dpi }: IScreenShotOpts) {
165
+ const { screenshot } = await this.sendToIFrameAndReceive({
166
+ type: 'screenshotApp',
167
+ dpi
168
+ });
169
+ return screenshot;
170
+ }
171
+
172
+ activateApp() {
173
+ const appid = this.props.appid;
174
+ return this.sendToIFrameAndReceive({
175
+ type: 'activateApp',
176
+ inLecture: !!appid,
177
+ appid
178
+ });
179
+ }
180
+
181
+ async getLicenses() {
182
+ return this.sendToIFrameAndReceive({
183
+ type: 'getLicenses'
184
+ });
185
+ }
186
+
187
+ async restartKernelAndRunCells() {
188
+ return this.sendToIFrameAndReceive({
189
+ type: 'restartKernelAndRerunCells'
190
+ });
191
+ }
192
+
193
+ activateInterceptor(activate: boolean) {
194
+ return this.sendToIFrameAndReceive({
195
+ type: 'activateInterceptor',
196
+ activate
197
+ });
198
+ }
199
+
200
+ sendInterceptorUpdate({ path, mime, state }: IInterceptorUpdate) {
201
+ return this.sendToIFrameAndReceive({
202
+ type: 'receiveInterceptorUpdate',
203
+ path,
204
+ mime,
205
+ state
206
+ });
207
+ }
208
+
209
+ onMessage(event: MessageEvent) {
210
+ if (event.source === window) {
211
+ return;
212
+ }
213
+ if (event.source !== this._iframe?.contentWindow) {
214
+ return;
215
+ }
216
+ if (event.origin !== new URL(this.props.jupyterurl).origin) {
217
+ return;
218
+ }
219
+ const data = event.data as IJupyterToFailsMessage;
220
+ if (event.data.requestId) {
221
+ const requestId = event.data.requestId;
222
+ if (this._requests.has(requestId)) {
223
+ const request = this._requests.get(requestId);
224
+ this._requests.delete(requestId);
225
+ if (event.data.error) {
226
+ request.reject(new Error(event.data.error));
227
+ return;
228
+ }
229
+ request.resolve(event.data);
230
+ return;
231
+ }
232
+ }
233
+ switch (data?.task) {
234
+ case 'appLoaded':
235
+ this.setState({ appLoading: false });
236
+ this.loadJupyter();
237
+ break;
238
+ case 'docDirty':
239
+ {
240
+ const { dirty = undefined } = data as IDocDirty;
241
+ if (this.props.stateCallback && typeof dirty !== 'undefined') {
242
+ this.props.stateCallback({ dirty });
243
+ }
244
+ }
245
+ break;
246
+ case 'reportMetadata':
247
+ {
248
+ const { failsApp = undefined, kernelspec = undefined } =
249
+ data?.metadata ?? {};
250
+ if (
251
+ this.props.stateCallback &&
252
+ (typeof failsApp !== 'undefined' ||
253
+ typeof kernelspec !== 'undefined')
254
+ ) {
255
+ this.props.stateCallback({ failsApp, kernelspec });
256
+ }
257
+ }
258
+ break;
259
+ case 'reportFailsAppletSizes':
260
+ {
261
+ const { appletSizes = undefined } = data as IReportFailsAppletSizes;
262
+ if (typeof appletSizes !== 'undefined') {
263
+ this.setState(state => {
264
+ const retState: {
265
+ appletSizes?: {
266
+ [key: string]: IFailsAppletSize;
267
+ };
268
+ } = {};
269
+ for (const appletSize of Object.values(appletSizes)) {
270
+ const { appid, height, width } = appletSize;
271
+ if (state?.appletSizes?.[appid]) {
272
+ const oldsize = state.appletSizes[appid];
273
+ if (oldsize.height === height && oldsize.width === width) {
274
+ continue;
275
+ }
276
+ }
277
+ if (!retState.appletSizes) {
278
+ retState.appletSizes = {};
279
+ }
280
+ retState.appletSizes[appid] = { width, height, appid };
281
+ this.props?.appletSizeChanged?.(appid, width, height);
282
+ }
283
+ return retState;
284
+ });
285
+ }
286
+ }
287
+ break;
288
+ case 'reportKernelStatus':
289
+ this.props?.kernelStatusCallback?.(data.status);
290
+ break;
291
+ case 'sendInterceptorUpdate':
292
+ {
293
+ const { path, mime, state } = data;
294
+ this.props?.receiveInterceptorUpdate?.({ path, mime, state });
295
+ }
296
+ break;
297
+ default:
298
+ }
299
+ }
300
+
301
+ sendToIFrame(message: IFailsToJupyterMessage) {
302
+ if (this._iframe) {
303
+ this._iframe.contentWindow?.postMessage(message, this.props.jupyterurl);
304
+ }
305
+ }
306
+
307
+ async sendToIFrameAndReceive(message: IFailsToJupyterMessage): Promise<any> {
308
+ const requestId = this._requestId++;
309
+ return new Promise((resolve, reject) => {
310
+ this._requests.set(requestId, {
311
+ requestId,
312
+ resolve,
313
+ reject
314
+ });
315
+ this.sendToIFrame({
316
+ // @ts-expect-error requestId is not included in type
317
+ requestId,
318
+ ...message
319
+ });
320
+ });
321
+ }
322
+
323
+ render() {
324
+ // launch debugging in the following way:
325
+ // jupyter lab --allow-root --ServerApp.allow_origin='*' --ServerApp.tornado_settings="{'headers': {'Content-Security-Policy': 'frame-ancestors self *'}}" --ServerApp.allow_websocket_origin='*' --ServerApp.cookie_options="{'samesite': 'None', 'secure': True}"
326
+ // jupyter lab --allow-root --ServerApp.allow_origin='*' --ServerApp.tornado_settings="{'headers': {'Content-Security-Policy': 'frame-ancestors self *'}}" --ServerApp.allow_websocket_origin='*' --ServerApp.cookie_options="{'samesite': 'None', 'secure': True}" --LabServerApp.app_settings_dir=/workspaces/jupyterfails/development/config/app-edit
327
+ // do it only in a container!
328
+
329
+ if (!this.props.editActivated) {
330
+ return <Fragment>JupyterEdit is not activated</Fragment>;
331
+ }
332
+ let width = '100%';
333
+ let height = '99%';
334
+
335
+ if (this.props.appid) {
336
+ const appletSize =
337
+ this.state.appletSizes && this.state.appletSizes[this.props.appid];
338
+ if (appletSize) {
339
+ width = Math.ceil(appletSize.width * 1.01) + 'px';
340
+ height = Math.ceil(appletSize.height * 1.01) + 'px';
341
+ }
342
+ }
343
+ let className = 'jpt-edit-iframe';
344
+ if (this.props.pointerOff) {
345
+ className += ' jpyt-edit-iframe-pointeroff';
346
+ }
347
+
348
+ return (
349
+ <Fragment>
350
+ <iframe
351
+ style={{ width, height }}
352
+ className={className}
353
+ src={this.props.jupyterurl}
354
+ ref={el => {
355
+ this._iframe = el;
356
+ }}
357
+ onLoad={() => {
358
+ console.log('Jupyter iframe loaded');
359
+ }}
360
+ allow=""
361
+ // @ts-expect-error credentialless
362
+ credentialless="true"
363
+ sandbox="allow-scripts allow-downloads allow-same-origin allow-popups" // we need allow-forms for a local jupyter server, remove for jupyterlite
364
+ title="jupyteredit"
365
+ ></iframe>
366
+ {this.state.appLoading && (
367
+ <h2
368
+ style={{
369
+ position: 'absolute',
370
+ top: '50%',
371
+ left: '50%',
372
+ transform: 'translate(-50%, -50%)'
373
+ }}
374
+ >
375
+ Jupyter is loading, be patient...
376
+ </h2>
377
+ )}
378
+ </Fragment>
379
+ );
380
+ }
381
+
382
+ private _iframe: HTMLIFrameElement | null = null;
383
+ private _requestId: number = 1; // id, if we request something
384
+ private _requests = new Map();
385
+ }
@@ -0,0 +1,7 @@
1
+ .jpt-edit-iframe {
2
+ box-sizing: content-box;
3
+ }
4
+
5
+ .jpyt-edit-iframe-pointeroff {
6
+ pointer-events: none;
7
+ }