8000 Py editor (#1860) · sugruedes/pyscript@40e99ab · GitHub
[go: up one dir, main page]

Skip to content

Commit 40e99ab

Browse files
Py editor (pyscript#1860)
* added a *py-editor* plugin based on *codemirror* * use a `<script type="py-editor">` wrapper to bootstrap code * tested that all is good via smoke-test in test/py-editor.html
1 parent 8b6b055 commit 40e99ab

15 files changed

+486
-80
lines changed

pyscript.core/package-lock.json

Lines changed: 161 additions & 58 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyscript.core/package.json

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,12 @@
2525
"build:plugins": "node rollup/plugins.cjs",
2626
"build:stdlib": "node rollup/stdlib.cjs",
2727
"build:3rd-party": "node rollup/3rd-party.cjs",
28+
"clean:3rd-party": "rm src/3rd-party/*.js && rm src/3rd-party/*.css",
2829
"test:mpy": "static-handler --coi . 2>/dev/null & SH_PID=$!; EXIT_CODE=0; playwright test --fully-parallel test/ || EXIT_CODE=$?; kill $SH_PID 2>/dev/null; exit $EXIT_CODE",
2930
"dev": "node dev.cjs",
3031
"release": "npm run build && npm run zip",
31-
"size": "echo -e \"\\033[1mdist/*.js file size\\033[0m\"; for js in $(ls dist/*.js); do echo -e \"\\033[2m$js:\\033[0m $(cat $js | brotli | wc -c) bytes\"; done",
32-
"ts": "tsc -p .",
32+
"size": "echo -e \"\\033[1mdist/*.js file size\\033[0m\"; for js in $(ls dist/*.js); do cat $js | brotli > ._; echo -e \"\\033[2m$js:\\033[0m $(du -h --apparent-size ._ | sed -e 's/[[:space:]]*._//')\"; rm ._; done",
33+
"ts": "rm -rf types && tsc -p .",
3334
"zip": "zip -r dist.zip ./dist"
3435
},
3536
"keywords": [
@@ -47,15 +48,21 @@
4748
"type-checked-collections": "^0.1.7"
4849
},
4950
"devDependencies": {
50-
"@playwright/test": "^1.40.1",
51+
"@codemirror/commands": "^6.3.0",
52+
"@codemirror/lang-python": "^6.1.3",
53+
"@codemirror/language": "^6.9.2",
54+
"@codemirror/state": "^6.3.1",
55+
"@codemirror/view": "^6.22.0",
56+
"@playwright/test": "^1.39.0",
5157
"@rollup/plugin-commonjs": "^25.0.7",
5258
"@rollup/plugin-node-resolve": "^15.2.3",
5359
"@rollup/plugin-terser": "^0.4.4",
5460
"@webreflection/toml-j0.4": "^1.1.3",
5561
"@xterm/addon-fit": "^0.9.0-beta.1",
5662
"chokidar": "^3.5.3",
57-
"eslint": "^8.54.0",
58-
"rollup": "^4.6.1",
63+
"codemirror": "^6.0.1",
64+
"eslint": "^8.53.0",
65+
"rollup": "^4.4.1",
5966
"rollup-plugin-postcss": "^4.0.2",
6067
"rollup-plugin-string": "^3.0.0",
6168
"static-handler": "^0.4.3",

pyscript.core/rollup/3rd-party.cjs

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ const { devDependencies } = require(join(__dirname, "..", "package.json"));
1010

1111
const v = (name) => devDependencies[name].replace(/[^\d.]/g, "");
1212

13+
const dropSourceMap = (str) =>
14+
str.replace(/^\/.+? sourceMappingURL=\/.+$/m, "");
15+
1316
// Fetch a module via jsdelivr CDN `/+esm` orchestration
1417
// then sanitize the resulting outcome to avoid importing
1518
// anything via `/npm/...` through Rollup
@@ -31,25 +34,41 @@ const resolve = (name) => {
3134
);
3235
};
3336

37+
// create a file rollup can then process and understand
38+
const reBundle = (name) => Promise.resolve(`export * from "${name}";\n`);
39+
3440
// key/value pairs as:
3541
// "3rd-party/file-name.js"
3642
// string as content or
3743
// Promise<string> as resolved content
3844
const modules = {
45+
// toml
3946
"toml.js": join(node_modules, "@webreflection", "toml-j0.4", "toml.js"),
47+
48+
// xterm
4049
"xterm.js": resolve("xterm"),
41-
"xterm.css": fetch(`${CDN}/xterm@${v("xterm")}/css/xterm.min.css`).then(
42-
(b) => b.text(),
43-
),
4450
"xterm-readline.js": resolve("xterm-readline"),
4551
"xterm_addon-fit.js": fetch(`${CDN}/@xterm/addon-fit/+esm`).then((b) =>
4652
b.text(),
4753
),
54+
"xterm.css": fetch(`${CDN}/xterm@${v("xterm")}/css/xterm.min.css`).then(
55+
(b) => b.text(),
56+
),
57+
58+
// codemirror
59+
"codemirror.js": reBundle("codemirror"),
60+
"codemirror_state.js": reBundle("@codemirror/state"),
61+
"codemirror_lang-python.js": reBundle("@codemirror/lang-python"),
62+
"codemirror_language.js": reBundle("@codemirror/language"),
63+
"codemirror_view.js": reBundle("@codemirror/view"),
64+
"codemirror_commands.js": reBundle("@codemirror/commands"),
4865
};
4966

5067
for (const [target, source] of Object.entries(modules)) {
5168
if (typeof source === "string") copyFileSync(source, join(targets, target));
5269
else {
53-
source.then((text) => writeFileSync(join(targets, target), text));
70+
source.then((text) =>
71+
writeFileSync(join(targets, target), dropSourceMap(text)),
72+
);
5473
}
5574
}
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
// PyScript py-editor plugin
2+
import { Hook, XWorker, dedent } from "polyscript/exports";
3+
import { TYPES } from "../core.js";
4+
5+
const RUN_BUTTON = `<svg style="height:20px;width:20px;vertical-align:-.125em;transform-origin:center;overflow:visible;color:green" viewBox="0 0 384 512" aria-hidden="true" role="img" xmlns="http://www.w3.org/2000/svg"><g transform="translate(192 256)" transform-origin="96 0"><g transform="translate(0,0) scale(1,1)"><path d="M361 215C375.3 223.8 384 239.3 384 256C384 272.7 375.3 288.2 361 296.1L73.03 472.1C58.21 482 39.66 482.4 24.52 473.9C9.377 465.4 0 449.4 0 432V80C0 62.64 9.377 46.63 24.52 38.13C39.66 29.64 58.21 29.99 73.03 39.04L361 215z" fill="currentColor" transform="translate(-192 -256)"></path></g></g></svg>`;
6+
7+
let id = 0;
8+
const getID = (type) => `${type}-editor-${id++}`;
9+
10+
const envs = new Map();
11+
12+
const hooks = {
13+
worker: {
14+
// works on both Pyodide and MicroPython
15+
onReady: ({ runAsync, io }, { sync }) => {
16+
io.stdout = (line) => sync.write(line);
17+
io.stderr = (line) => sync.writeErr(line);
18+
sync.revoke();
19+
sync.runAsync = runAsync;
20+
},
21+
},
22+
};
23+
24+
async function execute({ currentTarget }) {
25+
const { env, pySrc, outDiv } = this;
26+
27+
currentTarget.disabled = true;
28+
outDiv.innerHTML = "";
29+
30+
if (!envs.has(env)) {
31+
const srcLink = URL.createObjectURL(new Blob([""]));
32+
const xworker = XWorker.call(new Hook(null, hooks), srcLink, {
33+
type: this.interpreter,
34+
});
35+
36+
const { sync } = xworker;
37+
const { promise, resolve } = Promise.withResolvers();
38+
envs.set(env, promise);
39+
sync.revoke = () => {
40+
URL.revokeObjectURL(srcLink);
41+
resolve(xworker);
42+
};
43+
}
44+
45+
// wait for the env then set the target div
46+
// before executing the current code
47+
envs.get(env).then((xworker) => {
48+
xworker.onerror = ({ error }) => {
49+
outDiv.innerHTML += `<span style='color:red'>${
50+
error.message || error
51+
}</span>`;
52+
console.log(error);
53+
};
54+
55+
const enable = () => {
56+
currentTarget.disabled = false;
57+
};
58+
const { sync } = xworker;
59+
sync.write = (str) => {
60+
outDiv.innerText += str;
61+
};
62+
sync.writeErr = (str) => {
63+
outDiv.innerHTML += `<span style='color:red'>${str}</span>`;
64+
};
65+
sync.runAsync(pySrc).then(enable, enable);
66+
});
67+
}
68+
69+
const makeRunButton = (listener, type) => {
70+
const runButton = document.createElement("button");
71+
runButton.className = `absolute ${type}-editor-run-button`;
72+
runButton.innerHTML = RUN_BUTTON;
73+
runButton.setAttribute("aria-label", "Python Script Run Button");
74+
runButton.addEventListener("click", listener);
75+
return runButton;
76+
};
77+
78+
const makeEditorDiv = (listener, type) => {
79+
const editorDiv = document.createElement("div");
80+
editorDiv.className = `${type}-editor-input`;
81+
editorDiv.setAttribute("aria-label", "Python Script Area");
82+
83+
const runButton = makeRunButton(listener, type);
84+
const editorShadowContainer = document.createElement("div");
85+
86+
// avoid outer elements intercepting key events (reveal as example)
87+
editorShadowContainer.addEventListener("keydown", (event) => {
88+
event.stopPropagation();
89+
});
90+
91+
editorDiv.append(editorShadowContainer, runButton);
92+
93+
return editorDiv;
94+
};
95+
96+
const makeOutDiv = (type) => {
97+
const outDiv = document.createElement("div");
98+
outDiv.className = `${type}-editor-output`;
99+
outDiv.id = `${getID(type)}-output`;
100+
return outDiv;
101+
};
102+
103+
const makeBoxDiv = (listener, type) => {
104+
const boxDiv = document.createElement("div");
105+
boxDiv.className = `${type}-editor-box`;
106+
107+
const editorDiv = makeEditorDiv(listener, type);
108+
const outDiv = makeOutDiv(type);
109+
boxDiv.append(editorDiv, outDiv);
110+
111+
return [boxDiv, outDiv];
112+
};
113+
114+
const init = async (script, type, interpreter) => {
115+
const [
116+
{ basicSetup, EditorView },
117+
{ Compartment },
118+
{ python },
119+
{ indentUnit },
120+
{ keymap },
121+
{ defaultKeymap },
122+
] = await Promise.all([
123+
// TODO: find a way to actually produce these bundles locally
124+
import(/* webpackIgnore: true */ "../3rd-party/codemirror.js"),
125+
import(/* webpackIgnore: true */ "../3rd-party/codemirror_state.js"),
126+
import(
127+
/* webpackIgnore: true */ "../3rd-party/codemirror_lang-python.js"
128+
),
129+
import(/* webpackIgnore: true */ "../3rd-party/codemirror_language.js"),
130+
import(/* webpackIgnore: true */ "../3rd-party/codemirror_view.js"),
131+
import(/* webpackIgnore: true */ "../3rd-party/codemirror_commands.js"),
132+
]);
133+
134+
const selector = script.getAttribute("target");
135+
136+
let target;
137+
if (selector) {
138+
target =
139+
document.getElementById(selector) ||
140+
document.querySelector(selector);
141+
if (!target) throw new Error(`Unknown target ${selector}`);
142+
} else {
143+
target = document.createElement(`${type}-editor`);
144+
target.style.display = "block";
145+
script.after(target);
146+
}
147+
148+
if (!target.id) target.id = getID(type);
149+
if (!target.hasAttribute("exec-id")) target.setAttribute("exec-id", 0);
150+
if (!target.hasAttribute("root")) target.setAttribute("root", target.id);
151+
152+
const env = `${interpreter}-${script.getAttribute("env") || getID(type)}`;
153+
const context = {
154+
interpreter,
155+
env,
156+
get pySrc() {
157+
return editor.state.doc.toString();
158+
},
159+
get outDiv() {
160+
return outDiv;
161+
},
162+
};
163+
164+
// @see https://github.com/JeffersGlass/mkdocs-pyscript/blob/main/mkdocs_pyscript/js/makeblocks.js
165+
const listener = execute.bind(context);
166+
const [boxDiv, outDiv] = makeBoxDiv(listener, type);
167+
168+
const inputChild = boxDiv.querySelector(`.${type}-editor-input > div`);
169+
const parent = inputChild.attachShadow({ mode: "open" }< 10000 /span>);
170+
// avoid inheriting styles from the outer component
171+
parent.innerHTML = `<style> :host { all: initial; }</style>`;
172+
173+
target.appendChild(boxDiv);
174+
175+
const doc = dedent(script.textContent).trim();
176+
177+
// preserve user indentation, if any
178+
const indentation = /^(\s+)/m.test(doc) ? RegExp.$1 : " ";
179+
180+
const editor = new EditorView({
181+
extensions: [
182+
indentUnit.of(indentation),
183+
new Compartment().of(python()),
184+
keymap.of([
185+
...defaultKeymap,
186+
{ key: "Ctrl-Enter", run: listener, preventDefault: true },
187+
{ key: "Cmd-Enter", run: listener, preventDefault: true },
188+
{ key: "Shift-Enter", run: listener, preventDefault: true },
189+
]),
190+
basicSetup,
191+
],
192+
parent,
193+
doc,
194+
});
195+
196+
editor.focus();
197+
};
198+
199+
// avoid too greedy MutationObserver operations at distance
200+
let timeout = 0;
201+
202+
// reset interval value then check for new scripts
203+
const resetTimeout = () => {
204+
timeout = 0;
205+
pyEditor();
206+
};
207+
208+
// triggered both ASAP on the living DOM and via MutationObserver later
209+
const pyEditor = async () => {
210+
if (timeout) return;
211+
timeout = setTimeout(resetTimeout, 250);
212+
for (const [type, interpreter] of TYPES) {
213+
const selector = `script[type="${type}-editor"]`;
214+
for (const script of document.querySelectorAll(selector)) {
215+
// avoid any further bootstrap
216+
script.type += "-active";
217+
await init(script, type, interpreter);
218+
}
219+
}
220+
};
221+
222+
new MutationObserver(pyEditor).observe(document, {
223+
childList: true,
224+
subtree: true,
225+
});
226+
227+
// try to check the current document ASAP
228+
export default pyEditor();

pyscript.core/test/py-editor.html

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+
<title>PyTerminal</title>
7+
<link rel="stylesheet" href="../dist/core.css">
8+
<script type="module" src="../dist/core.js"></script>
9+
<style>
10+
.py-editor-box, .mpy-editor-box {
11+
padding: .5rem;
12+
position: relative;
13+
}
14+
.py-editor-run-button, .mpy-editor-run-button {
15+
position: absolute;
16+
right: .5rem;
17+
top: .5rem;
18+
opacity: 0;
19+
transition: opacity .25s;
20+
}
21+
.py-editor-box:hover .py-editor-run-button,
22+
.mpy-editor-box:hover .mpy-editor-run-button,
23+
.py-editor-run-button:focus,
24+
.py-editor-run-button:disabled,
25+
.mpy-editor-run-button:focus,
26+
.mpy-editor-run-button:disabled {
27+
opacity: 1;
28+
}
29+
</style>
30+
</head>
31+
<body>
32+
<script type="py-editor">
33+
import sys
34+
print(sys.version)
35+
</script>
36+
<script type="mpy-editor">
37+
import sys
38+
print(sys.version)
39+
a = 42
40+
</script>
41+
<script type="mpy-editor" env="shared">
42+
if not 'a' in globals():
43+
a = 1
44+
else:
45+
a += 1
46+
print(a)
47+
</script>
48+
<script type="mpy-editor" env="shared">
49+
# doubled a
50+
print(a * 2)
51+
</script>
52+
</body>
53+
</html>
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from "codemirror";
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from "@codemirror/commands";
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from "@codemirror/lang-python";
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from "@codemirror/language";
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from "@codemirror/state";

0 commit comments

Comments
 (0)
0