forked from marijnh/Eloquent-JavaScript
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathejs.mjs
More file actions
355 lines (332 loc) · 11.5 KB
/
ejs.mjs
File metadata and controls
355 lines (332 loc) · 11.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
import {EditorView, keymap} from "@codemirror/view"
import {Facet} from "@codemirror/state"
import {createState} from "./editor.mjs"
import {Sandbox} from "./sandbox.mjs"
function chapterInteraction() {
document.querySelectorAll("button.help").forEach(button => {
button.style.display = "inline"
button.addEventListener("click", showHelp)
})
document.body.addEventListener("keydown", e => {
let active = document.activeElement
if (e.key == "?" && !e.ctrlKey && !e.altKey && !e.metaKey) {
if (!active || (active.contentEditable != "true" && active.nodeName != "INPUT")) {
e.preventDefault()
showHelp()
}
}
if (e.key == "Enter" && !e.ctrlKey && !e.altKey && !e.metaKey) {
let editor = active && maybeActivateCode(active)
if (editor) {
e.preventDefault()
editor.focus()
}
}
})
let modName = /Mac/.test(navigator.platform) ? "Cmd-" : "Ctrl-"
function showHelp() {
let popup = document.body.appendChild(document.createElement("div"))
popup.className = "popup"
popup.appendChild(document.createElement("h2")).textContent = "Instructions"
popup.appendChild(document.createElement("p")).textContent = `Code snippets on this page can be edited and run by clicking them or moving focus to them and pressing Enter. Executed snippets share their environment with other snippets ran on the page, and some pre-defined code for the chapter. When inside the code editor, the following keyboard shortcuts are available:`
for (let [key, desc] of [
[modName + "Enter", "Run code"],
[modName + "j", "Revert code"],
[modName + "↓", "Deactivate editor"],
[modName + "Escape", "Reset environment"],
]) {
let b = popup.appendChild(document.createElement("div"))
b.appendChild(document.createElement("kbd")).textContent = key
b.appendChild(document.createTextNode(": " + desc))
}
popup.tabIndex = 0
popup.addEventListener("blur", () => popup.remove())
popup.addEventListener("keydown", e => {
if (e.key == "Escape") { e.preventDefault(); popup.remove() }
})
popup.focus()
}
document.body.addEventListener("mousedown", e => {
for (let n = e.target; n; n = n.parentNode) {
if (n.className == "c_ident") return
let editor = maybeActivateCode(n)
if (editor) {
e.preventDefault()
setTimeout(() => {
let pos = editor.posAtCoords({x: e.clientX, y: e.clientY}, false)
editor.dispatch({selection: {anchor: pos}})
editor.focus()
}, 20)
return
}
}
})
function elt(type, attrs) {
let firstChild = 1
let node = document.createElement(type)
if (attrs && typeof attrs == "object" && attrs.nodeType == null) {
for (let attr in attrs) if (attrs.hasOwnProperty(attr)) {
let value = attrs[attr]
if (attr == "css") node.style.cssText = value
else if (typeof value !== "string") node[attr] = value
else node.setAttribute(attr, value)
}
firstChild = 2
}
for (let i = firstChild; i < arguments.length; ++i) {
let child = arguments[i]
if (typeof child == "string") child = document.createTextNode(child)
node.appendChild(child)
}
return node
}
const contextFacet = Facet.define({
combine(vs) { return vs[0] }
})
const extraKeys = keymap.of([
{key: "ArrowDown", run(cm) {
let {main} = cm.state.selection
if (!main.empty || main.head < cm.state.doc.length) return false
document.activeElement.blur()
return true
}},
{key: "ArrowUp", run(cm) {
let {main} = cm.state.selection
if (!main.empty || main.head > 0) return false
document.activeElement.blur()
return true
}},
{key: "Escape", run(cm) {
cm.contentDOM.blur()
return true
}},
{key: "Mod-Enter", run(cm) {
runCode(cm)
return true
}},
{key: "Mod-j", run(cm) {
revertCode(cm)
return true
}},
{key: "Mod-ArrowDown", run(cm) {
closeCode(cm)
return true
}},
{key: "Mod-Escape", run(cm) {
resetSandbox(cm.state.facet(contextFacet).sandbox)
return true
}}
])
function maybeActivateCode(element) {
if (element.nodeName == "PRE") {
let lang = element.getAttribute("data-language")
if (/^(javascript|html)$/.test(lang))
return activateCode(element, lang)
}
}
let nextID = 0
let article = document.getElementsByTagName("article")[0]
function activateCode(node, lang) {
let scrollPos = pageYOffset, rect = node.getBoundingClientRect()
if (rect.top < 0 && rect.height > 500) scrollPos -= Math.min(-rect.top, rect.height - 500)
let codeId = node.querySelector("a").id
let code = (window.localStorage && localStorage.getItem(codeId)) || node.textContent
let wrap = node.parentNode.insertBefore(elt("div", {"class": "editor-wrap"}), node)
let pollingScroll = null
function pollScroll() {
if (document.activeElement != editor.contentDOM) return
let rect = editor.dom.getBoundingClientRect()
if (rect.bottom < 0 || rect.top > innerHeight) editor.contentDOM.blur()
else pollingScroll = setTimeout(pollScroll, 500)
}
let sandbox = node.getAttribute("data-sandbox")
let context = {
wrap: wrap,
orig: node,
isHTML: lang == "html",
sandbox,
meta: node.getAttribute("data-meta")
}
let editorState = createState(code, lang, [
extraKeys,
EditorView.domEventHandlers({
focus: (e, view) => {
clearTimeout(pollingScroll)
pollingScroll = setTimeout(pollScroll, 500)
showEditorControls(view)
},
blur: (e, view) => {
setTimeout(() => {
if (!view.hasFocus) hideEditorControls(view)
}, 100)
}
}),
EditorView.updateListener.of(debounce(update => {
if (update.docChanged && window.localStorage)
localStorage.setItem(codeId, editor.state.doc.toString())
}, 250)),
contextFacet.of(context)
])
let editor = new EditorView({state: editorState, parent: wrap})
let out = wrap.appendChild(elt("div", {"class": "sandbox-output", "aria-live": "polite"}))
context.output = new Sandbox.Output(out)
if (lang == "html" && !sandbox) {
sandbox = context.sandbox = "html" + nextID++
node.setAttribute("data-sandbox", sandbox)
sandboxSnippets[sandbox] = node
}
node.style.display = "none"
// Cancel weird scroll stabilization magic from brower (which
// doesn't work at all for this)
window.scrollTo(pageXOffset, scrollPos)
setTimeout(() => window.scrollTo(pageXOffset, scrollPos), 20)
return editor
}
function openMenu(editor, node) {
let menu = elt("div", {"class": "sandbox-open-menu"})
let context = editor.state.facet(contextFacet)
function click(e) {
let target = e.target
if (e.target.parentNode == menu) {
for (let i = 0; i < menu.childNodes.length; ++i)
if (target == menu.childNodes[i])
items[i][1]()
}
menu.parentNode.removeChild(menu)
window.removeEventListener("click", click)
}
setTimeout(() => window.addEventListener("click", click), 20)
node.offsetParent.appendChild(menu)
}
function runCode(editor) {
let context = editor.state.facet(contextFacet)
context.output.clear()
let val = editor.state.doc.toString()
getSandbox(context.sandbox, context.isHTML).then(box => {
if (context.isHTML)
box.setHTML(val, context.output).then(() => {
if (context.orig.getAttribute("data-focus")) {
box.win.focus()
box.win.document.body.focus()
}
})
else
box.run(val, context.output).then(value => {
if (value != null && context.meta && /\bexpr\b/.test(context.meta) && context.output.empty)
box.out("log", [value])
})
})
}
function closeCode(editor) {
let context = editor.state.facet(contextFacet)
if (context.isHTML && context.sandbox) return
context.wrap.remove()
context.orig.style.display = ""
}
function revertCode(editor) {
let context = editor.state.facet(contextFacet)
editor.dispatch({
selection: {anchor: 0},
changes: {from: 0, to: editor.state.doc.length, insert: context.orig.textContent}
})
}
function showEditorControls(editor) {
if (editor.dom.parentNode.querySelector(".editor-controls")) return
editor.dom.parentNode.appendChild(elt("div", {
class: "editor-controls"
}, elt("button", {
onmousedown: e => {
runCode(editor)
e.preventDefault()
},
title: `Run code (${modName}Enter)`,
"aria-label": "Run code"
}, "▸"), elt("button", {
onmousedown: e => {
revertCode(editor)
e.preventDefault()
},
title: `Revert code (${modName}j)`,
"aria-label": "Revert code"
}, "▫"), elt("button", {
onmousedown: e => {
resetSandbox(editor.state.facet(contextFacet).sandbox)
e.preventDefault()
},
title: `Reset sandbox (${modName}Escape)`,
"aria-label": "Reset sandbox"
}, "ø")))
}
function hideEditorControls(editor) {
let controls = editor.dom.parentNode.querySelector(".editor-controls")
if (controls) controls.remove()
}
let sandboxSnippets = {}
{
let snippets = document.getElementsByClassName("snippet")
for (let i = 0; i < snippets.length; i++) {
let snippet = snippets[i]
if (snippet.getAttribute("data-language") == "html" &&
snippet.getAttribute("data-sandbox"))
sandboxSnippets[snippet.getAttribute("data-sandbox")] = snippet
}
}
let sandboxes = {}
async function getSandbox(name, forHTML) {
name = name || "null"
if (sandboxes.hasOwnProperty(name)) return sandboxes[name]
let options = {loadFiles: window.page.load_files}, html
if (sandboxSnippets.hasOwnProperty(name)) {
let snippet = sandboxSnippets[name]
options.place = node => placeFrame(node, snippet)
if (!forHTML) html = snippet.textContent
}
let box = await Sandbox.create(options)
if (html != null)
box.win.document.documentElement.innerHTML = html
sandboxes[name] = box
return box
}
function resetSandbox(name) {
if (!sandboxes.hasOwnProperty(name)) return
let frame = sandboxes[name].frame
frame.parentNode.removeChild(frame)
delete sandboxes[name]
}
function placeFrame(frame, snippet) {
let wrap = snippet.previousSibling, bot
if (!wrap || wrap.className != "editor-wrap") {
bot = snippet.getBoundingClientRect().bottom
activateCode(snippet, "html")
wrap = snippet.previousSibling
} else {
bot = wrap.getBoundingClientRect().bottom
}
wrap.insertBefore(frame, wrap.childNodes[1])
if (bot < 50) {
let newBot = wrap.getBoundingClientRect().bottom
window.scrollBy(0, newBot - bot)
}
}
}
function debounce(fn, delay = 50) {
let timeout
return arg => {
if (timeout) clearTimeout(timeout)
timeout = setTimeout(() => fn(arg), delay)
}
}
if (window.page && /^chapter|hints$/.test(window.page.type)) {
chapterInteraction()
// 3rd-edition-style anchor
let {hash} = document.location
if (/^#[phic]_./.test(hash)) {
let exists = document.getElementById(hash.replace(/_/, "-"))
if (exists) {
document.location.hash = hash.replace(/_/, "-")
} else {
let chapter = /\/[^\/]+\.html/.exec(document.location)
if (chapter) document.location = `https://eloquentjavascript.net/3rd_edition${chapter[0]}${hash}`
}
}
}