shrimp/src/editor/editor.tsx

141 lines
4.0 KiB
TypeScript

import { EditorView } from '@codemirror/view'
import { asciiEscapeToHtml, assertNever, log, toElement } from '#utils/utils'
import { Signal } from '#utils/signal'
import { getContent } from '#editor/plugins/persistence'
import type { HtmlEscapedString } from 'hono/utils/html'
import { connectToNose, noseSignals } from '#editor/noseClient'
import type { Value } from 'reefvm'
import { Compartment } from '@codemirror/state'
import { lineNumbers } from '@codemirror/view'
import { shrimpSetup } from '#editor/plugins/shrimpSetup'
import '#editor/editor.css'
const lineNumbersCompartment = new Compartment()
connectToNose()
export const outputSignal = new Signal<Value | string>()
export const errorSignal = new Signal<string>()
export const multilineModeSignal = new Signal<boolean>()
export const Editor = () => {
return (
<>
<div
ref={(ref: Element) => {
if (ref?.querySelector('.cm-editor')) return
const view = new EditorView({
parent: ref,
doc: getContent(),
extensions: shrimpSetup(lineNumbersCompartment),
})
multilineModeSignal.connect((isMultiline) => {
view.dispatch({
effects: lineNumbersCompartment.reconfigure(isMultiline ? lineNumbers() : []),
})
})
requestAnimationFrame(() => view.focus())
}}
/>
<div id="status-bar">
<div className="left"></div>
<div className="right"></div>
</div>
<div id="output"></div>
<div id="error"></div>
</>
)
}
noseSignals.connect((message) => {
if (message.type === 'error') {
log.error(`Nose error: ${message.data}`)
errorSignal.emit(`Nose error: ${message.data}`)
} else if (message.type === 'reef-output') {
const x = outputSignal.emit(message.data)
} else if (message.type === 'connected') {
outputSignal.emit(`╞ Connected to Nose VM`)
}
})
outputSignal.connect((value) => {
const el = document.querySelector('#output')!
el.innerHTML = ''
el.innerHTML = asciiEscapeToHtml(valueToString(value))
})
errorSignal.connect((error) => {
const el = document.querySelector('#output')!
el.innerHTML = ''
el.classList.add('error')
el.innerHTML = asciiEscapeToHtml(error)
})
type StatusBarMessage = {
side: 'left' | 'right'
message: string | Promise<HtmlEscapedString>
className: string
order?: number
}
export const statusBarSignal = new Signal<StatusBarMessage>()
statusBarSignal.connect(async ({ side, message, className, order }) => {
document.querySelector(`#status-bar .${className}`)?.remove()
const sideEl = document.querySelector(`#status-bar .${side}`)!
const messageEl = (
<div data-order={order ?? 0} className={className}>
{await message}
</div>
)
// Now go through the nodes and put it in the right spot based on order. Higher number means further right
const nodes = Array.from(sideEl.childNodes)
const index = nodes.findIndex((node) => {
if (!(node instanceof HTMLElement)) return false
return Number(node.dataset.order) > (order ?? 0)
})
if (index === -1) {
sideEl.appendChild(toElement(messageEl))
} else {
sideEl.insertBefore(toElement(messageEl), nodes[index]!)
}
})
const valueToString = (value: Value | string): string => {
if (typeof value === 'string') {
return value
}
switch (value.type) {
case 'null':
return 'null'
case 'boolean':
return value.value ? 'true' : 'false'
case 'number':
return value.value.toString()
case 'string':
return value.value
case 'array':
return `${value.value.map(valueToString).join('\n')}`
case 'dict': {
const entries = Array.from(value.value.entries()).map(
([key, val]) => `"${key}": ${valueToString(val)}`,
)
return `{${entries.join(', ')}}`
}
case 'regex':
return `/${value.value.source}/`
case 'function':
return `<function>`
case 'native':
return `<function ${value.fn.name}>`
default:
assertNever(value)
return `<unknown value type: ${(value as any).type}>`
}
}