This is a list of named code blocks, with customizable order. With the names as paths to files, it can represent a directory of files. It is similar to GitHub gists and GitLab snippets.
notebook.json
{
"bundleFiles": [
["codemirror-bundle.md", "codemirror-bundle.js"]
],
"importFiles": [
["loader.md", "builder.js"],
["forms.md", "button-group.js"],
["code-edit.md", "code-edit.js"],
["menu.md", "dropdown.js"]
]
}
This renders a list of files. The order needs to be changeable.
file-group.js
export class FileGroup extends HTMLElement {
textEn = {}
textEs = {}
constructor() {
super()
this.language = navigator.language
this.attachShadow({mode: 'open'})
this.headerEl = document.createElement('div')
this.headerEl.classList.add('header')
this.contentEl = document.createElement('div')
this.contentEl.classList.add('content')
this.shadowRoot.appendChild(this.headerEl)
this.shadowRoot.appendChild(this.contentEl)
const bGroup = document.createElement(
'm-forms-button-group'
)
this.shadowRoot.appendChild(bGroup)
this.contentEl.addEventListener('click-add-above', e => { this.handleAdd(e, 'up') })
this.contentEl.addEventListener('click-add-below', e => { this.handleAdd(e, 'down') })
this.contentEl.addEventListener('click-move-up', e => { this.handleMove(e, 'up') })
this.contentEl.addEventListener('click-move-down', e => { this.handleMove(e, 'down') })
}
connectedCallback() {
const style = document.createElement('style')
style.textContent = `
:host {
display: flex;
flex-direction: column;
align-items: stretch;
}
div.header {
display: flex;
flex-direction: row;
}
div.files {
display: flex;
flex-direction: column;
flex-grow: 1;
overflow-y: auto;
}
`
this.shadowRoot.appendChild(style)
if (this.contentEl.childNodes.length === 0) {
this.addFile()
}
}
addFile({name, data, collapsed} = {}) {
const el = document.createElement('m-editor-file-view')
el.codeMirror = this.codeMirror
if (name !== undefined) {
el.name = name
}
if (data !== undefined) {
el.data = data
}
if (collapsed !== undefined) {
el.collapsed = collapsed
}
this.contentEl.appendChild(el)
return el
}
handleAdd(e, direction) {
const el = document.createElement(
'm-editor-file-view'
)
el.codeMirror = this.codeMirror
const position = direction == 'up' ? 'beforebegin' : 'afterend'
e.target.insertAdjacentElement(position, el)
}
handleMove(e, direction) {
const siblingEl = direction == 'up' ? e.target.previousElementSibling : e.target.nextElementSibling
if (siblingEl) {
const position = direction == 'up' ? 'beforebegin' : 'afterend'
siblingEl.insertAdjacentElement(position, e.target)
}
}
get language() {
return this._language
}
set language(language) {
this._language = language
this.text = this.langEs ? this.textEs : this.textEn
}
get langEs() {
return /^es\b/.test(this.language)
}
get files() {
return [...this.contentEl.children]
}
}
file-view.js
export class FileView extends HTMLElement {
icons = {
menu: `
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" class="bi bi-three-dots" viewBox="0 0 16 16">
<path d="M3 9.5a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zm5 0a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zm5 0a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3z"/>
</svg>
`,
down: `
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-caret-down-fill" viewBox="0 0 16 16">
<path d="M7.247 11.14 2.451 5.658C1.885 5.013 2.345 4 3.204 4h9.592a1 1 0 0 1 .753 1.659l-4.796 5.48a1 1 0 0 1-1.506 0z"/>
</svg>
`,
up: `
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-caret-up-fill" viewBox="0 0 16 16">
<path d="m7.247 4.86-4.796 5.481c-.566.647-.106 1.659.753 1.659h9.592a1 1 0 0 0 .753-1.659l-4.796-5.48a1 1 0 0 0-1.506 0z"/>
</svg>
`,
}
textEn = {
addAbove: 'Add above',
addBelow: 'Add below',
moveUp: 'Move up',
moveDown: 'Move down',
delete: 'Delete',
}
textEs = {
addAbove: 'Añadir arriba',
addBelow: 'Añadir abajo',
moveUp: 'Mover arriba',
moveDown: 'Mover abajo',
delete: 'Borrar',
}
constructor() {
super()
this.language = navigator.language
this.attachShadow({mode: 'open'})
this.headerEl = document.createElement('div')
this.headerEl.classList.add('header')
this.contentEl = document.createElement('div')
this.contentEl.classList.add('content')
this.shadowRoot.appendChild(this.headerEl)
this.shadowRoot.appendChild(this.contentEl)
this.nameEl = document.createElement('input')
this.nameEl.classList.add('name')
this.nameEl.setAttribute('spellcheck', 'false')
this.nameEl.addEventListener('input', e => {
this.setFileType(e.target.value)
})
this.headerEl.appendChild(this.nameEl)
this.collapseBtn = document.createElement(
'button'
)
this.collapseBtn.innerHTML = this.icons.up
this.collapseBtn.addEventListener('click', () => {
this.collapsed = !this.collapsed
})
this.headerEl.appendChild(this.collapseBtn)
this.menuBtn = document.createElement('button')
this.menuBtn.innerHTML = this.icons.menu
this.menuBtn.addEventListener('click', () => {
this.openMenu()
})
this.headerEl.appendChild(this.menuBtn)
this.menu = document.createElement(
'm-menu-dropdown'
)
this.shadowRoot.appendChild(this.menu)
}
connectedCallback() {
const style = document.createElement('style')
style.textContent = `
:host {
display: flex;
flex-direction: column;
align-items: stretch;
}
div.header {
display: flex;
flex-direction: row;
align-items: stretch;
background-color: #f2dbd8;
color: #000;
padding: 3px 0;
}
div.header > * {
background: inherit;
color: inherit;
border: none;
}
.name {
flex-grow: 1;
padding: 0 5px;
font: inherit;
font-family: monospace;
outline: none;
}
div.header button svg {
margin-bottom: -3px;
}
div.content {
display: flex;
flex-direction: column;
align-items: stretch;
min-height: 5px;
}
div.content.collapsed > * {
display: none;
}
svg {
height: 20px;
width: 20px;
}
`
this.shadowRoot.appendChild(style)
}
openMenu() {
this.menu.clear()
this.menu.add(this.text.addAbove, () => {
this.dispatchEvent(new CustomEvent(
'click-add-above', {bubbles: true}
))
})
this.menu.add(this.text.addBelow, () => {
this.dispatchEvent(new CustomEvent(
'click-add-below', {bubbles: true}
))
})
if (this.previousElementSibling) {
this.menu.add(this.text.moveUp, () => {
this.dispatchEvent(new CustomEvent(
'click-move-up', {bubbles: true}
))
})
}
if (this.nextElementSibling) {
this.menu.add(this.text.moveDown, () => {
this.dispatchEvent(new CustomEvent(
'click-move-down', {bubbles: true}
))
})
}
if (this.nextElementSibling || this.previousElementSibling) {
this.menu.add(this.text.delete, () => {
this.remove()
})
}
this.menu.open(this.menuBtn)
}
set codeMirror(value) {
this._codeMirror = value
const tagName = (
this.codeMirror ?
'm-editor-code-edit' : 'm-editor-text-edit'
)
this.editEl = document.createElement(tagName)
this.contentEl.replaceChildren(this.editEl)
}
get codeMirror() {
return this._codeMirror
}
set name(name) {
this.nameEl.value = name
this.setFileType(name)
}
get name() {
return this.nameEl.value
}
set data(data) {
this.editEl.value = data
}
get data() {
return this.editEl.value
}
set collapsed(value) {
const cl = this.contentEl.classList
if (value) {
cl.add('collapsed')
} else {
cl.remove('collapsed')
}
this.collapseBtn.innerHTML = (
value ?
this.icons.down : this.icons.up
)
}
get collapsed() {
return this.contentEl.classList.contains(
'collapsed'
)
}
setFileType(value) {
if (this.codeMirror && this.editEl) {
let fileType
if (value.endsWith('.js')) {
fileType = 'js'
} else if (value.endsWith('.html')) {
fileType = 'html'
} else if (value.endsWith('.css')) {
fileType = 'css'
} else if (value.endsWith('.json')) {
fileType = 'json'
} else if (value.endsWith('.md')) {
fileType = 'md'
}
this.editEl.fileType = fileType
}
}
get language() {
return this._language
}
set language(language) {
this._language = language
this.text = this.langEs ? this.textEs : this.textEn
}
get langEs() {
return /^es\b/.test(this.language)
}
}
text-edit.js
export class TextEdit extends HTMLElement {
constructor() {
super()
this.attachShadow({mode: 'open'})
this.stackEl = document.createElement('div')
this.stackEl.classList.add('stack')
this.textEl = document.createElement('textarea')
this.textEl.classList.add('text')
this.textEl.setAttribute('spellcheck', 'false')
this.textEl.rows = 1
this.stackEl.appendChild(this.textEl)
this.shadowRoot.appendChild(this.stackEl)
this.textEl.addEventListener('input', () => {
this.stackEl.dataset.copy = this.textEl.value
})
}
connectedCallback() {
// https://css-tricks.com/the-cleanest-trick-for-autogrowing-textareas/
const style = document.createElement('style')
style.textContent = `
:host {
display: flex;
flex-direction: column;
align-items: stretch;
margin: 5px 0;
}
div.stack {
display: grid;
}
div.stack::after {
content: attr(data-copy) " ";
visibility: hidden;
overflow: hidden;
}
div.stack::after, div.stack > textarea {
white-space: pre-wrap;
border: 1px solid #888;
padding: 3px;
font: inherit;
font-family: monospace;
grid-area: 1 / 1 / 2 / 2;
min-height: 1em;
border-radius: 2px;
resize: none;
}
`
this.shadowRoot.appendChild(style)
}
set value(value) {
this.textEl.value = value
this.stackEl.dataset.copy = this.textEl.value
}
get value() {
return this.textEl.value
}
}
list-editor.js
export class ListEditor extends HTMLElement {
constructor() {
super()
this.attachShadow({mode: 'open'})
this.loaded = false
this.el = document.createElement(
'm-editor-file-group'
)
}
connectedCallback() {
const style = document.createElement('style')
style.textContent = `
:host {
display: flex;
flex-direction: column;
align-items: stretch;
margin: 8px;
}
`
this.shadowRoot.append(style)
}
async load(files) {
this.el.codeMirror = true
for (const file of files) {
this.el.addFile(file)
}
this.loaded = true
this.shadowRoot.appendChild(this.el)
}
}
notebook-code.js
export class NotebookCode extends HTMLElement {
constructor() {
super()
this.attachShadow({mode: 'open'})
const toolbar = document.createElement('div')
toolbar.classList.add('toolbar')
const selectContainer = document.createElement('div')
const iconContainer = document.createElement('div')
const closeBtn = document.createElement(
'button'
)
closeBtn.innerHTML = this.icons.close
closeBtn.addEventListener('click', () => {
this.onHide()
})
iconContainer.append(closeBtn)
iconContainer.classList.add('icon-container')
toolbar.append(selectContainer, iconContainer)
const editorContainer = document.createElement('div')
editorContainer.classList.add('editor-container')
this.editor = document.createElement('m-editor-code-edit')
this.editor.fileType = 'md'
this.editor.lineWrapping = true
editorContainer.append(this.editor)
this.shadowRoot.append(toolbar, editorContainer)
}
connectedCallback() {
const style = document.createElement('style')
style.textContent = `
:host {
display: grid;
grid-template-rows: auto 1fr;
}
.toolbar {
background: #111;
color: #e7e7e7;
display: grid;
grid-template-columns: 1fr auto;
padding: 3px;
}
.icon-container {
display: flex;
}
.icon-container button {
background: inherit;
color: inherit;
border: none;
}
.icon-container svg {
height: 20px;
width: 20px;
}
.editor-container {
display: flex;
flex-direction: column;
align-items: flex;
overflow-y: scroll;
}
.editor-container m-editor-code-edit {
flex-grow: 1;
}
`
this.shadowRoot.append(style)
}
set value(value) {
this.editor.value = value
}
get value() {
return this.editor.value
}
icons = {
close: `
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 24 24">
<path d="M19 6.41L17.59 5L12 10.59L6.41 5L5 6.41L10.59 12L5 17.59L6.41 19L12 13.41L17.59 19L19 17.59L13.41 12z"/>
</svg>
`,
}
}
toolbar.js
export class Toolbar extends HTMLElement {
constructor() {
super()
this.attachShadow({mode: 'open'})
const selectContainer = document.createElement('div')
const iconContainer = document.createElement('div')
const codeBtn = document.createElement(
'button'
)
codeBtn.innerHTML = this.icons.code
codeBtn.addEventListener('click', () => {
this.onShowNotebookCode()
})
iconContainer.append(codeBtn)
iconContainer.classList.add('icon-container')
this.shadowRoot.append(selectContainer, iconContainer)
}
connectedCallback() {
const style = document.createElement('style')
style.textContent = `
:host {
background: #111;
color: #e7e7e7;
display: grid;
grid-template-columns: 1fr auto;
padding: 3px;
}
.icon-container {
display: flex;
}
.icon-container button {
background: inherit;
color: inherit;
border: none;
}
.icon-container svg {
height: 20px;
width: 20px;
}
`
this.shadowRoot.append(style)
}
icons = {
code: `
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 24 24">
<path d="M9.4 16.6L4.8 12l4.6-4.6L8 6l-6 6l6 6zm5.2 0l4.6-4.6l-4.6-4.6L16 6l6 6l-6 6z" />
</svg>
`,
}
}
app-view.js
import {Builder} from '/loader/builder.js'
export class AppView extends HTMLElement {
constructor() {
super()
this.attachShadow({mode: 'open'})
this.depsConfig = {bundleFiles: [], importFiles: []}
this.notebook = this.getBlockContent('notebook.md') ?? (
`\n\n\`app.js\`\n\n${this.fence(`document.body.innerText = 'hello'`)}`
)
this.loaded = false
this.toolbar = document.createElement('m-toolbar')
this.toolbar.onShowNotebookCode = () => {
this.showNotebookCode()
}
this.editor = document.createElement('m-list-editor')
this.viewFrame = document.createElement('iframe')
this.viewFrame.sandbox = 'allow-scripts'
this.shadowRoot.append(this.toolbar, this.editor, this.viewFrame)
this.shadowRoot.addEventListener('code-input', (e) => {
this.handleInput()
})
this.shadowRoot.addEventListener('input', (e) => {
this.handleInput()
})
addEventListener('message', e => {
if (e.source === this.viewFrame?.contentWindow) {
parent.postMessage(e.data, '*')
}
})
}
connectedCallback() {
const style = document.createElement('style')
const globalStyle = document.createElement('style')
globalStyle.textContent = `
body {
margin: 0;
padding: 0;
}
html, body {
margin: 0;
padding: 0;
}
html {
box-sizing: border-box;
}
*, *:before, *:after {
box-sizing: inherit;
}
`
document.head.append(globalStyle)
style.textContent = `
:host {
display: grid;
grid-template-rows: auto 1fr 1fr;
grid-template-columns: 1fr;
height: 100vh;
}
m-list-editor {
overflow-y: auto;
}
iframe {
width: 100%;
height: 100%;
padding: 0;
border: none;
}
.notebook-code {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
`
this.shadowRoot.append(style)
this.editor.load(this.readNotebookFiles(this.notebook))
this.renderView()
}
readNotebookFiles(notebook) {
const result = []
for (const block of readBlocksWithNames(notebook)) {
result.push({name: block.name, data: notebook.slice(...block.contentRange)})
}
return result
}
update() {
this.renderView()
}
handleInput(e) {
// TODO: use notebook.json to control frequency, automaticness, and spinner of updates
if (!this.inputTimeout) {
this.inputTimeout = setTimeout(() => {
this.inputTimeout = undefined
this.update()
}, 1500)
}
}
renderView() {
const viewFrame = document.createElement('iframe')
viewFrame.sandbox = 'allow-scripts'
this.shadowRoot.appendChild(viewFrame)
this.viewFrame.remove()
this.viewFrame = viewFrame
this.displayNotebook()
}
getBlockContent(blockName, subBlockName = undefined) {
for (const block of readBlocksWithNames(__source)) {
if (block.name === blockName) {
const blockSource = __source.slice(...block.contentRange)
if (subBlockName === undefined) {
return blockSource
} else {
for (const subBlock of readBlocksWithNames(blockSource)) {
if (subBlock.name === subBlockName)
return blockSource.slice(...subBlock.contentRange)
}
}
}
}
}
async getDepsConfig(notebook) {
const defaultDeps = {bundleFiles: [], importFiles: []}
for (const block of readBlocksWithNames(notebook)) {
if (block.name === 'notebook.json') {
return {...defaultDeps, ...JSON.parse(notebook.slice(...block.contentRange))}
}
}
return defaultDeps
}
async getDeps(notebook) {
const newDepsConfig = await this.getDepsConfig(notebook)
if (typeof this.deps === 'string' && JSON.stringify(newDepsConfig) === JSON.stringify(this.depsConfig ?? null)) {
return this.deps
} else {
const channel = new MessageChannel()
let loaded = false
const remotePromise = new Promise((resolve, _) => {
channel.port1.onmessage = (message) => {
channel.port1.close()
loaded = true
resolve(message.data)
}
parent.postMessage(['getDeps', notebook], '*', [channel.port2])
})
const localPromise = new Promise((resolve, _reject) => {
setTimeout(() => {
if (loaded) {
resolve(undefined)
} else {
const builder = new Builder({src: '', parentSrc: __source})
const deps = builder.getDeps()
resolve(deps)
}
}, 500)
})
const deps = await Promise.race([remotePromise, localPromise])
this.depsConfig = newDepsConfig
this.deps = deps
return deps
}
}
fence(text, info = '') {
const matches = Array.from(text.matchAll(new RegExp('^\\s*(`+)', 'gm')))
const maxCount = matches.map(m => m[1].length).toSorted((a, b) => a - b).at(-1) ?? 0
const quotes = '`'.repeat(Math.max(maxCount + 1, 3))
return `\n${quotes}${info}\n${text}\n${quotes}\n`
}
async buildNotebook(notebook, files) {
let result = ''
let position = 0
const remaining = [...files]
for (const block of readBlocksWithNames(notebook)) {
result += notebook.slice(position, block.blockRange[0])
const index = remaining.findIndex(({name}) => name === block.name)
const file = remaining[index]
if (file) {
remaining.splice(index, 1)
const info = (block.info ?? '').trim().length > 0 ? block.info.trim() : undefined
const extMatch = block.name.match(/\.([\w-]+)/)
const ext = extMatch ? extMatch[1] : undefined
result += `\`${block.name}\`\n` + this.fence(file.data, info ?? ext ?? '')
} else {
result += `\n`
}
position = block.blockRange[1]
}
result += notebook.slice(position)
if (remaining.length > 0) {
result = result.trimEnd()
}
for (const file of remaining) {
const extMatch = (file.name ?? '').match(/\.([\w-]+)/)
const ext = extMatch ? extMatch[1] : undefined
result += `\n\n\`${file.name}\`\n${this.fence(file.data, ext ?? '')}`
}
return result
}
async displayNotebook() {
const dataSrc = ''
const notebookContent = await this.buildNotebook(this.notebook, this.editor.el.files)
const deps = await this.getDeps(notebookContent)
const notebookSrc = `
**deps**
${deps}
---
**notebook**
${notebookContent}
`
const re = /(?:^|\n)\s*\n`entry.js`\n\s*\n```.*?\n(.*?)```\s*(?:\n|$)/s
const runEntry = `
const re = new RegExp(${JSON.stringify(re.source)}, ${JSON.stringify(re.flags)})
addEventListener('message', async e => {
if (e.data[0] === 'notebook') {
globalThis.__source = new TextDecoder().decode(e.data[1])
const entrySrc = globalThis.__source.match(re)[1]
await import(\`data:text/javascript;base64,\${btoa(entrySrc)}\`)
}
}, {once: true})
`.trim()
const src = `
<!doctype html>
<html>
<head>
<title>preview</title>
<script type="module">
${runEntry}
</script>
</head>
<body>
</body>
</html>
`
this.viewFrame.src = `data:text/html;base64,${btoa(src.trim())}`
// this.viewFrame.srcdoc = src.trim()
this.viewFrame.addEventListener('load', () => {
const messageText = `\n\n${notebookSrc}\n\n`
const messageData = new TextEncoder().encode(messageText)
this.viewFrame.contentWindow.postMessage(
['notebook', messageData],
'*',
[messageData.buffer]
)
}, {once: true})
}
async showNotebookCode() {
const value = await this.buildNotebook(this.notebook, this.editor.el.files)
if (!this.notebookCodeEl) {
this.notebookCodeEl = document.createElement('m-notebook-code')
this.notebookCodeEl.classList.add('notebook-code')
this.notebookCodeEl.initialValue = value
this.notebookCodeEl.value = value
this.notebookCodeEl.onHide = () => {
this.hideNotebookCode()
}
this.shadowRoot.append(this.notebookCodeEl)
}
}
async hideNotebookCode() {
if (this.notebookCodeEl) {
if (this.notebookCodeEl.value.trim() !== this.notebookCodeEl.initialValue.trim()) {
const oldEditor = this.editor
this.notebook = this.notebookCodeEl.value
this.editor = document.createElement('m-list-editor')
this.shadowRoot.replaceChild(this.editor, oldEditor)
this.editor.load(this.readNotebookFiles(this.notebook))
this.renderView()
}
this.notebookCodeEl.remove()
this.notebookCodeEl = undefined
}
}
}
app.js
import { ButtonGroup } from "/forms/button-group.js"
import { Dropdown } from "/menu/dropdown.js"
import { TextEdit } from "/text-edit.js"
import { CodeEdit } from "/code-edit/code-edit.js"
import { FileView } from "/file-view.js"
import { FileGroup } from "/file-group.js"
import { ListEditor } from "/list-editor.js"
import { NotebookCode } from "/notebook-code.js"
import { Toolbar } from "/toolbar.js"
import { AppView } from "/app-view.js"
customElements.define('m-forms-button-group', ButtonGroup)
customElements.define('m-menu-dropdown', Dropdown)
customElements.define('m-editor-text-edit', TextEdit)
customElements.define('m-editor-code-edit', CodeEdit)
customElements.define('m-editor-file-view', FileView)
customElements.define('m-editor-file-group', FileGroup)
customElements.define('m-list-editor', ListEditor)
customElements.define('m-notebook-code', NotebookCode)
customElements.define('m-toolbar', Toolbar)
customElements.define('m-app-view', AppView)
class App {
async run() {
document.body.appendChild(
document.createElement(
'm-app-view'
)
)
}
}
new App().run()
thumbnail.svg
<svg viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg">
<style>
svg {
background-color: #eee;
}
.color1 {
fill: #f2dbd8;
}
.color2 {
fill: #466d1d;
}
.text {
fill: #000;
opacity: 30%;
}
</style>
<rect x="0" y="0" width="128" height="18" class="color1" />
<rect x="5" y="4" width="45" height="10" class="text" />
<rect x="5" y="22" width="70" height="10" class="text" />
<rect x="20" y="37" width="89" height="10" class="text" />
<rect x="5" y="52" width="5" height="10" class="text" />
<g transform="translate(0 67)">
<rect x="0" y="0" width="128" height="18" class="color1" />
<rect x="5" y="4" width="45" height="10" class="text" />
<rect x="5" y="22" width="40" height="10" class="text" />
<rect x="20" y="37" width="40" height="10" class="text" />
<rect x="20" y="52" width="50" height="10" class="text" />
</g>
</svg>
Icon svg in icons
: google material-design-icons, Apache 2.0
Other content: Apache 2.0