even better sandbox

This commit is contained in:
liquidex 2024-02-18 12:10:02 +01:00
parent 0580db6c68
commit 668e9a050e
10 changed files with 169 additions and 19 deletions

View file

@ -111,6 +111,27 @@ scripts = ["components/literate-programming.js"]
- this API wraps a lower-level message-passing API which is used to communicate with the main page, to let it set things like the sandbox `<iframe>`'s size (as well as make it visible). - this API wraps a lower-level message-passing API which is used to communicate with the main page, to let it set things like the sandbox `<iframe>`'s size (as well as make it visible).
see the source code for details. see the source code for details.
% id = "01HPXYH05CWEAKZ406ZNA919TS"
- it's also possible to use ordinary DOM elements, *however* instead of `document.body` you should use `treehouse/sandbox.js`'s `body()`.
there's also sugar for `body().appendChild()` in form of `addElement()`:
```javascript dom-output
import { addElement } from "treehouse/sandbox.js";
let slider = document.createElement("input");
slider.type = "range";
addElement(slider);
```
<th-literate-program data-mode="graphics" data-program="dom-output"></th-literate-program>
% id = "01HPXYH05C3VC96N214D8VQGND"
- do note however that this isn't used on the site right now due to a lack of CSS in the sandbox, therefore rendering the sandbox's theme unreadable in dark mode.
% id = "01HPXYH05C75CGRW5GN1K9W8GY"
- technically you *can* use `document.body`, but its content will be replaced with `body()`'s once the script finishes running, so in the end it's quite useless
% id = "01HPWJB4Y5H9DKZT2ZA8PWNV99" % id = "01HPWJB4Y5H9DKZT2ZA8PWNV99"
+ ### known issues + ### known issues

View file

@ -41,6 +41,7 @@ enum TableState {
struct HtmlWriter<'a, I, W> { struct HtmlWriter<'a, I, W> {
treehouse: &'a Treehouse, treehouse: &'a Treehouse,
config: &'a Config, config: &'a Config,
page_id: &'a str,
/// Iterator supplying events. /// Iterator supplying events.
iter: I, iter: I,
@ -64,10 +65,17 @@ where
I: Iterator<Item = Event<'a>>, I: Iterator<Item = Event<'a>>,
W: StrWrite, W: StrWrite,
{ {
fn new(treehouse: &'a Treehouse, config: &'a Config, iter: I, writer: W) -> Self { fn new(
treehouse: &'a Treehouse,
config: &'a Config,
page_id: &'a str,
iter: I,
writer: W,
) -> Self {
Self { Self {
treehouse, treehouse,
config, config,
page_id,
iter, iter,
writer, writer,
end_newline: true, end_newline: true,
@ -248,6 +256,8 @@ where
} }
})?; })?;
self.write("data-program=\"")?; self.write("data-program=\"")?;
escape_href(&mut self.writer, self.page_id)?;
self.write(":")?;
escape_html(&mut self.writer, program_name)?; escape_html(&mut self.writer, program_name)?;
self.write("\" data-language=\"")?; self.write("\" data-language=\"")?;
escape_html(&mut self.writer, language)?; escape_html(&mut self.writer, language)?;
@ -606,9 +616,16 @@ impl<'a> CodeBlockMode<'a> {
/// </ul> /// </ul>
/// "#); /// "#);
/// ``` /// ```
pub fn push_html<'a, I>(s: &mut String, treehouse: &'a Treehouse, config: &'a Config, iter: I) pub fn push_html<'a, I>(
where s: &mut String,
treehouse: &'a Treehouse,
config: &'a Config,
page_id: &'a str,
iter: I,
) where
I: Iterator<Item = Event<'a>>, I: Iterator<Item = Event<'a>>,
{ {
HtmlWriter::new(treehouse, config, iter, s).run().unwrap(); HtmlWriter::new(treehouse, config, page_id, iter, s)
.run()
.unwrap();
} }

View file

@ -130,7 +130,13 @@ pub fn branch_to_html(
Some(broken_link_callback), Some(broken_link_callback),
); );
s.push_str("<th-bc>"); s.push_str("<th-bc>");
markdown::push_html(s, treehouse, config, markdown_parser); markdown::push_html(
s,
treehouse,
config,
treehouse.tree_path(file_id).expect(".tree file expected"),
markdown_parser,
);
if let Content::Link(link) = &branch.attributes.content { if let Content::Link(link) = &branch.attributes.content {
write!( write!(
s, s,

View file

@ -537,6 +537,16 @@ img[is="th-emoji"] {
/* Literate programming support */ /* Literate programming support */
:root {
--error-color: #d94141;
}
@media (prefers-color-scheme: dark) {
:root {
--error-color: #e39393;
}
}
th-literate-program[data-mode="input"] { th-literate-program[data-mode="input"] {
/* Override the cursor with an I-beam, because the editor captures clicks and does not bubble /* Override the cursor with an I-beam, because the editor captures clicks and does not bubble
them back up to the caller */ them back up to the caller */
@ -551,7 +561,7 @@ th-literate-program[data-mode="output"] {
} }
& code.error { & code.error {
color: #e39393; color: var(--error-color);
} }
&::after { &::after {
@ -570,6 +580,7 @@ th-literate-program[data-mode="output"] {
th-literate-program[data-mode="graphics"] { th-literate-program[data-mode="graphics"] {
padding: 0; padding: 0;
background: none; background: none;
border: none;
& iframe { & iframe {
border-style: none; border-style: none;
@ -581,6 +592,28 @@ th-literate-program[data-mode="graphics"] {
& iframe.hidden { & iframe.hidden {
display: none; display: none;
} }
& pre.error {
color: var(--error-color);
position: relative;
&:empty {
display: none;
}
&::after {
content: 'Error';
padding: 8px;
position: absolute;
right: 0;
top: 0;
color: var(--text-color);
opacity: 50%;
}
}
} }
/* Syntax highlighting */ /* Syntax highlighting */

View file

@ -200,12 +200,25 @@ class OutputMode {
clearResults() { clearResults() {
this.frame.replaceChildren(); this.frame.replaceChildren();
} }
static messageOutputArrayToString(output) {
return output
.map(x => {
if (typeof x === "object") return JSON.stringify(x);
else return x + "";
})
.join(" ");
}
} }
class GraphicsMode { class GraphicsMode {
constructor(frame) { constructor(frame) {
this.frame = frame; this.frame = frame;
this.error = document.createElement("pre");
this.error.classList.add("error");
this.frame.appendChild(this.error);
this.iframe = document.createElement("iframe"); this.iframe = document.createElement("iframe");
this.iframe.classList.add("hidden"); this.iframe.classList.add("hidden");
this.iframe.src = import.meta.resolve("../../html/sandbox.html"); this.iframe.src = import.meta.resolve("../../html/sandbox.html");
@ -213,15 +226,20 @@ class GraphicsMode {
this.iframe.contentWindow.addEventListener("message", event => { this.iframe.contentWindow.addEventListener("message", event => {
let message = event.data; let message = event.data;
if (message.kind == "resize") { if (message.kind == "ready") {
this.evaluate();
}
else if (message.kind == "resize") {
this.resize(message); this.resize(message);
} else if (message.kind == "output" && message.output.kind == "error") {
this.error.textContent = OutputMode.messageOutputArrayToString(message.output.message);
this.iframe.classList.add("hidden");
} else if (message.kind == "evalComplete") {
this.error.textContent = "";
} }
}); });
this.iframe.contentWindow.addEventListener("DOMContentLoaded", () => this.evaluate());
this.frame.program.onChanged.push(_ => this.evaluate()); this.frame.program.onChanged.push(_ => this.evaluate());
this.evaluate();
} }
evaluate() { evaluate() {

View file

@ -18,7 +18,22 @@ async function withTemporaryGlobalScope(callback) {
} }
} }
export async function evaluate(commands) { let evaluationComplete = null;
export async function evaluate(commands, { start, success, error }) {
if (evaluationComplete != null) {
await evaluationComplete;
}
if (start != null) {
start();
}
let signalEvaluationComplete;
evaluationComplete = new Promise((resolve, _reject) => {
signalEvaluationComplete = resolve;
})
outputIndex = 0; outputIndex = 0;
try { try {
await withTemporaryGlobalScope(async scope => { await withTemporaryGlobalScope(async scope => {
@ -34,15 +49,25 @@ export async function evaluate(commands) {
} }
} }
}); });
} catch (error) { if (success != null) {
success();
}
postMessage({
kind: "evalComplete",
});
} catch (err) {
postMessage({ postMessage({
kind: "output", kind: "output",
output: { output: {
kind: "error", kind: "error",
message: [error.toString()], message: [err.toString()],
}, },
outputIndex, outputIndex,
}); });
if (error != null) {
error();
}
} }
signalEvaluationComplete();
} }

View file

@ -18,6 +18,6 @@ globalThis.console = {
addEventListener("message", async event => { addEventListener("message", async event => {
let message = event.data; let message = event.data;
if (message.action == "eval") { if (message.action == "eval") {
evaluate(message.input); evaluate(message.input, {});
} }
}); });

View file

@ -1,3 +1,15 @@
export const internals = {
body: document.createElement("body"),
};
export function body() {
return internals.body;
}
export function addElement(element) {
body().appendChild(element);
}
export class Sketch { export class Sketch {
constructor(width, height) { constructor(width, height) {
this.canvas = document.createElement("canvas"); this.canvas = document.createElement("canvas");
@ -5,6 +17,6 @@ export class Sketch {
this.canvas.height = height; this.canvas.height = height;
this.ctx = this.canvas.getContext("2d"); this.ctx = this.canvas.getContext("2d");
document.body.appendChild(this.canvas); addElement(this.canvas);
} }
} }

View file

@ -306,8 +306,8 @@ export function CodeJar(editor, highlight, opt = {}) {
} }
} }
function handleSelfClosingCharacters(event) { function handleSelfClosingCharacters(event) {
const open = `([{'"`; const open = `([{'"\``;
const close = `)]}'"`; const close = `)]}'"\``;
if (open.includes(event.key)) { if (open.includes(event.key)) {
preventDefault(event); preventDefault(event);
const pos = save(); const pos = save();

View file

@ -20,6 +20,7 @@
<script type="module"> <script type="module">
import { evaluate } from "treehouse/components/literate-programming/eval.js"; import { evaluate } from "treehouse/components/literate-programming/eval.js";
import { internals as sandboxInternals } from "treehouse/sandbox.js";
// I'm aware there's also ResizeObserver but it didn't seem to fire off any events when a // I'm aware there's also ResizeObserver but it didn't seem to fire off any events when a
// canvas was added, rendering it pretty much useless. // canvas was added, rendering it pretty much useless.
@ -35,11 +36,28 @@
addEventListener("message", async event => { addEventListener("message", async event => {
let message = event.data; let message = event.data;
if (message.action == "eval") { if (message.action == "eval") {
document.body.replaceChildren(); evaluate(message.input, {
evaluate(message.input); success() {
// A double buffered approach for flickerless code modifications.
document.body.replaceChildren(...sandboxInternals.body.childNodes);
sandboxInternals.body.replaceChildren();
requestAnimationFrame(() => {
postMessage({
kind: "resize",
width: document.body.scrollWidth,
height: document.body.scrollHeight,
});
});
},
error() {
sandboxInternals.body.replaceChildren();
},
});
} }
}); });
postMessage({ kind: "ready" });
</script> </script>
</head> </head>