nose-pluto/src/js/editor.ts
2025-10-01 22:16:38 -07:00

209 lines
6.3 KiB
TypeScript

import { scrollback } from "./dom"
import { send } from "./websocket"
import { focusInput } from "./focus"
const INDENT_SIZE = 2
export function initEditor() {
document.addEventListener("input", handleAdjustHeight)
focusTextareaOnCreation()
}
function handleAdjustHeight(e: Event) {
const target = e.target as HTMLElement
if (target?.matches(".editor"))
adjustHeight(target as HTMLTextAreaElement)
}
function adjustHeight(editor: HTMLTextAreaElement) {
editor.style.height = "auto"
editor.style.height = editor.scrollHeight + "px"
}
function focusTextareaOnCreation() {
const observer = new MutationObserver(mutations => {
for (const m of mutations)
for (const node of Array.from(m.addedNodes))
if (node instanceof HTMLElement && node.childNodes[0] instanceof HTMLElement && node.childNodes[0].matches("textarea")) {
const editor = node.childNodes[0]
editor.focus()
editor.addEventListener("keydown", keydownHandler)
return
}
})
observer.observe(scrollback, { childList: true })
}
function keydownHandler(e: KeyboardEvent) {
const editor = e.target as HTMLTextAreaElement
if (e.key === "Tab") {
e.preventDefault()
if (e.shiftKey)
removeTab(editor)
else
insertTab(editor)
} else if (e.ctrlKey && e.key === "c") {
focusInput()
} else if ((e.ctrlKey && e.key === "s") || (e.ctrlKey && e.key === "Enter")) {
e.preventDefault()
send({
id: editor.dataset.path,
type: "save-file",
data: editor.value
})
} else if (e.key === "{") {
if (editor.selectionStart !== editor.selectionEnd) {
insertAroundSelection(editor, '{', '}')
e.preventDefault()
} else {
setTimeout(() => insertAfterCaret(editor, "}"), 0)
}
} else if (e.key === "}" && isNextChar(editor, '}')) {
moveOneRight(editor)
e.preventDefault()
} else if (e.key === "[") {
if (editor.selectionStart !== editor.selectionEnd) {
insertAroundSelection(editor, '[', ']')
e.preventDefault()
} else {
setTimeout(() => insertAfterCaret(editor, "]"), 0)
}
} else if (e.key === "]" && isNextChar(editor, ']')) {
moveOneRight(editor)
e.preventDefault()
} else if (e.key === "(") {
if (editor.selectionStart !== editor.selectionEnd) {
insertAroundSelection(editor, '(', ')')
e.preventDefault()
} else {
setTimeout(() => insertAfterCaret(editor, ")"), 0)
}
} else if (e.key === ")" && isNextChar(editor, ')')) {
moveOneRight(editor)
e.preventDefault()
} else if (e.key === '"') {
if (isNextChar(editor, '"')) {
moveOneRight(editor)
e.preventDefault()
} else if (editor.selectionStart !== editor.selectionEnd) {
insertAroundSelection(editor, '"', '"')
e.preventDefault()
} else {
setTimeout(() => insertAfterCaret(editor, '"'), 0)
}
} else if (e.key === "Enter") {
indentNewlineForBraces(e, editor)
}
}
function moveOneRight(editor: HTMLTextAreaElement) {
const pos = editor.selectionStart
editor.selectionStart = editor.selectionEnd = pos + 1
}
function insertTab(editor: HTMLTextAreaElement) {
const start = editor.selectionStart
const end = editor.selectionEnd
const lineStart = editor.value.lastIndexOf("\n", start - 1) + 1
editor.value = editor.value.slice(0, lineStart) + " " + editor.value.slice(lineStart)
editor.selectionStart = start + INDENT_SIZE
editor.selectionEnd = end + INDENT_SIZE
}
function removeTab(editor: HTMLTextAreaElement) {
const start = editor.selectionStart
const end = editor.selectionEnd
const lineStart = editor.value.lastIndexOf("\n", start - 1) + 1
if (editor.value.slice(lineStart, lineStart + 2) === " ") {
editor.value = editor.value.slice(0, lineStart) + editor.value.slice(lineStart + 2)
editor.selectionStart = start - INDENT_SIZE
editor.selectionEnd = end - INDENT_SIZE
}
}
function insertAfterCaret(editor: HTMLTextAreaElement, char: string) {
const pos = editor.selectionStart
editor.value = editor.value.slice(0, pos) + char + editor.value.slice(pos)
editor.selectionStart = editor.selectionEnd = pos
}
function isNextChar(editor: HTMLTextAreaElement, char: string): boolean {
return editor.value[editor.selectionStart] === char
}
function indentNewlineForBraces(e: KeyboardEvent, editor: HTMLTextAreaElement) {
e.preventDefault()
if (isBetween(editor, "{", "}") || isBetween(editor, "[", "]")) {
setTimeout(() => insertMoreIndentedNewline(editor), 0)
} else {
setTimeout(() => insertIndentedNewline(editor), 0)
}
}
function isBetween(editor: HTMLTextAreaElement, start: string, end: string): boolean {
const pos = editor.selectionStart
const val = editor.value
if (pos <= 0 || pos >= val.length) return false
return val[pos - 1] === start && val[pos] === end
}
function insertIndentedNewline(editor: HTMLTextAreaElement) {
const pos = editor.selectionStart
const before = editor.value.slice(0, pos)
const prevLineStart = before.lastIndexOf("\n", pos - 1) + 1
const prevLine = before.slice(prevLineStart)
let leading = 0
while (prevLine[leading] === " ") leading++
const indent = " ".repeat(leading)
const insert = "\n" + indent
editor.value = editor.value.slice(0, pos) + insert + editor.value.slice(pos)
const newPos = pos + insert.length
editor.selectionStart = editor.selectionEnd = newPos
adjustHeight(editor)
}
function insertAroundSelection(editor: HTMLTextAreaElement, before: string, after: string) {
const start = editor.selectionStart
const end = editor.selectionEnd
editor.value = editor.value.slice(0, start) + before + editor.value.slice(start, end) + after + editor.value.slice(end)
}
function insertMoreIndentedNewline(editor: HTMLTextAreaElement) {
const pos = editor.selectionStart
const before = editor.value.slice(0, pos)
const prevLineStart = before.lastIndexOf("\n", pos - 1) + 1
const prevLine = before.slice(prevLineStart)
let leading = 0
while (prevLine[leading] === " ") leading++
const oldIndent = " ".repeat(leading)
const newIndent = " ".repeat(leading + INDENT_SIZE)
const insert = "\n" + newIndent + "\n" + oldIndent
editor.value = editor.value.slice(0, pos) + insert + editor.value.slice(pos)
const newPos = pos + insert.length
editor.selectionStart = editor.selectionEnd = newPos - 1 - oldIndent.length
adjustHeight(editor)
}