From 89d639e44e194e4bee524b29be7254a02f976881 Mon Sep 17 00:00:00 2001 From: retoor Date: Wed, 6 Aug 2025 15:33:13 +0200 Subject: [PATCH] New editor. --- src/snek/static/editor.js | 668 ++++++++++++++++++++++++++------------ 1 file changed, 468 insertions(+), 200 deletions(-) diff --git a/src/snek/static/editor.js b/src/snek/static/editor.js index 8ee3bd3..d1b3ae1 100644 --- a/src/snek/static/editor.js +++ b/src/snek/static/editor.js @@ -1,226 +1,494 @@ -import { NjetComponent} from "/njet.js" +import { NjetComponent } from "/njet.js" - class NjetEditor extends NjetComponent { - constructor() { - super(); - this.attachShadow({ mode: 'open' }); +class NjetEditor extends NjetComponent { + constructor() { + super(); + this.attachShadow({ mode: 'open' }); - const style = document.createElement('style'); - style.textContent = ` - #editor { - padding: 1rem; - outline: none; - white-space: pre-wrap; - line-height: 1.5; - height: 100%; - overflow-y: auto; - background: #1e1e1e; - color: #d4d4d4; - } - #command-line { - position: absolute; - bottom: 0; - left: 0; - width: 100%; - padding: 0.2rem 1rem; - background: #333; - color: #0f0; - display: none; - font-family: monospace; - } - `; + const style = document.createElement('style'); + style.textContent = ` + :host { + display: block; + position: relative; + height: 100%; + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + } + + #editor { + padding: 1rem; + outline: none; + white-space: pre-wrap; + line-height: 1.5; + height: calc(100% - 30px); + overflow-y: auto; + background: #1e1e1e; + color: #d4d4d4; + font-size: 14px; + caret-color: #fff; + } + + #editor.insert-mode { + caret-color: #4ec9b0; + } + + #editor.visual-mode { + caret-color: #c586c0; + } + + #editor::selection { + background: #264f78; + } + + #status-bar { + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 30px; + background: #007acc; + color: #fff; + display: flex; + align-items: center; + padding: 0 1rem; + font-size: 12px; + font-weight: 500; + } + + #mode-indicator { + text-transform: uppercase; + margin-right: 20px; + font-weight: bold; + } + + #command-line { + position: absolute; + bottom: 30px; + left: 0; + width: 100%; + padding: 0.3rem 1rem; + background: #2d2d2d; + color: #d4d4d4; + display: none; + font-family: inherit; + border-top: 1px solid #3e3e3e; + font-size: 14px; + } + + #command-input { + background: transparent; + border: none; + color: inherit; + outline: none; + font-family: inherit; + font-size: inherit; + width: calc(100% - 20px); + } + + .visual-selection { + background: #264f78 !important; + } + `; - this.editor = document.createElement('div'); - this.editor.id = 'editor'; - this.editor.contentEditable = true; - this.editor.innerText = `Welcome to VimEditor Component + this.editor = document.createElement('div'); + this.editor.id = 'editor'; + this.editor.contentEditable = true; + this.editor.spellcheck = false; + this.editor.innerText = `Welcome to VimEditor Component Line 2 here Another line Try i, Esc, v, :, yy, dd, 0, $, gg, G, and p`; - this.cmdLine = document.createElement('div'); - this.cmdLine.id = 'command-line'; - this.shadowRoot.append(style, this.editor, this.cmdLine); + this.cmdLine = document.createElement('div'); + this.cmdLine.id = 'command-line'; + + const cmdPrompt = document.createElement('span'); + cmdPrompt.textContent = ':'; + + this.cmdInput = document.createElement('input'); + this.cmdInput.id = 'command-input'; + this.cmdInput.type = 'text'; + + this.cmdLine.append(cmdPrompt, this.cmdInput); - this.mode = 'normal'; // normal | insert | visual | command - this.keyBuffer = ''; - this.lastDeletedLine = ''; - this.yankedLine = ''; + this.statusBar = document.createElement('div'); + this.statusBar.id = 'status-bar'; + + this.modeIndicator = document.createElement('span'); + this.modeIndicator.id = 'mode-indicator'; + this.modeIndicator.textContent = 'NORMAL'; + + this.statusBar.appendChild(this.modeIndicator); - this.editor.addEventListener('keydown', this.handleKeydown.bind(this)); - } + this.shadowRoot.append(style, this.editor, this.cmdLine, this.statusBar); - connectedCallback() { + this.mode = 'normal'; + this.keyBuffer = ''; + this.lastDeletedLine = ''; + this.yankedLine = ''; + this.visualStartOffset = null; + this.visualEndOffset = null; + + // Bind event handlers + this.handleKeydown = this.handleKeydown.bind(this); + this.handleCmdKeydown = this.handleCmdKeydown.bind(this); + this.updateVisualSelection = this.updateVisualSelection.bind(this); + + this.editor.addEventListener('keydown', this.handleKeydown); + this.cmdInput.addEventListener('keydown', this.handleCmdKeydown); + this.editor.addEventListener('beforeinput', this.handleBeforeInput.bind(this)); + } + + connectedCallback() { + this.editor.focus(); + } + + setMode(mode) { + this.mode = mode; + this.modeIndicator.textContent = mode.toUpperCase(); + + // Update editor classes + this.editor.classList.remove('insert-mode', 'visual-mode', 'normal-mode'); + this.editor.classList.add(`${mode}-mode`); + + if (mode === 'visual') { + this.visualStartOffset = this.getCaretOffset(); + this.editor.addEventListener('selectionchange', this.updateVisualSelection); + } else { + this.clearVisualSelection(); + this.editor.removeEventListener('selectionchange', this.updateVisualSelection); + } + + if (mode === 'command') { + this.cmdLine.style.display = 'block'; + this.cmdInput.value = ''; + this.cmdInput.focus(); + } else { + this.cmdLine.style.display = 'none'; + if (mode !== 'insert') { + // Keep focus on editor for all non-insert modes this.editor.focus(); } + } + } - getCaretOffset() { - let caretOffset = 0; - const sel = this.shadowRoot.getSelection(); - if (!sel || sel.rangeCount === 0) return 0; + updateVisualSelection() { + if (this.mode !== 'visual') return; + this.visualEndOffset = this.getCaretOffset(); + } - const range = sel.getRangeAt(0); - const preCaretRange = range.cloneRange(); - preCaretRange.selectNodeContents(this.editor); - preCaretRange.setEnd(range.endContainer, range.endOffset); - caretOffset = preCaretRange.toString().length; - return caretOffset; + clearVisualSelection() { + const sel = this.shadowRoot.getSelection(); + if (sel) sel.removeAllRanges(); + this.visualStartOffset = null; + this.visualEndOffset = null; + } + + getCaretOffset() { + const sel = this.shadowRoot.getSelection(); + if (!sel || sel.rangeCount === 0) return 0; + + const range = sel.getRangeAt(0); + const preCaretRange = range.cloneRange(); + preCaretRange.selectNodeContents(this.editor); + preCaretRange.setEnd(range.endContainer, range.endOffset); + return preCaretRange.toString().length; + } + + setCaretOffset(offset) { + const textContent = this.editor.innerText; + offset = Math.max(0, Math.min(offset, textContent.length)); + + const range = document.createRange(); + const sel = this.shadowRoot.getSelection(); + const walker = document.createTreeWalker( + this.editor, + NodeFilter.SHOW_TEXT, + null, + false + ); + + let currentOffset = 0; + let node; + + while ((node = walker.nextNode())) { + const nodeLength = node.textContent.length; + if (currentOffset + nodeLength >= offset) { + range.setStart(node, offset - currentOffset); + range.collapse(true); + sel.removeAllRanges(); + sel.addRange(range); + return; } + currentOffset += nodeLength; + } + + // If we couldn't find the position, set to end + if (this.editor.lastChild) { + range.selectNodeContents(this.editor.lastChild); + range.collapse(false); + sel.removeAllRanges(); + sel.addRange(range); + } + } - setCaretOffset(offset) { - const range = document.createRange(); - const sel = this.shadowRoot.getSelection(); - const walker = document.createTreeWalker(this.editor, NodeFilter.SHOW_TEXT, null, false); + handleBeforeInput(e) { + if (this.mode !== 'insert') { + e.preventDefault(); + } + } - let currentOffset = 0; - let node; - while ((node = walker.nextNode())) { - if (currentOffset + node.length >= offset) { - range.setStart(node, offset - currentOffset); - range.collapse(true); - sel.removeAllRanges(); - sel.addRange(range); - return; - } - currentOffset += node.length; + handleCmdKeydown(e) { + if (e.key === 'Enter') { + e.preventDefault(); + this.executeCommand(this.cmdInput.value); + this.setMode('normal'); + } else if (e.key === 'Escape') { + e.preventDefault(); + this.setMode('normal'); + } + } + + executeCommand(cmd) { + const trimmedCmd = cmd.trim(); + + // Handle basic vim commands + if (trimmedCmd === 'w' || trimmedCmd === 'write') { + console.log('Save command (not implemented)'); + } else if (trimmedCmd === 'q' || trimmedCmd === 'quit') { + console.log('Quit command (not implemented)'); + } else if (trimmedCmd === 'wq' || trimmedCmd === 'x') { + console.log('Save and quit command (not implemented)'); + } else if (/^\d+$/.test(trimmedCmd)) { + // Go to line number + const lineNum = parseInt(trimmedCmd, 10) - 1; + this.goToLine(lineNum); + } + } + + goToLine(lineNum) { + const lines = this.editor.innerText.split('\n'); + if (lineNum < 0 || lineNum >= lines.length) return; + + let offset = 0; + for (let i = 0; i < lineNum; i++) { + offset += lines[i].length + 1; + } + this.setCaretOffset(offset); + } + + getCurrentLineInfo() { + const text = this.editor.innerText; + const caretPos = this.getCaretOffset(); + const lines = text.split('\n'); + + let charCount = 0; + for (let i = 0; i < lines.length; i++) { + if (caretPos <= charCount + lines[i].length) { + return { + lineIndex: i, + lines: lines, + lineStartOffset: charCount, + positionInLine: caretPos - charCount + }; + } + charCount += lines[i].length + 1; + } + + return { + lineIndex: lines.length - 1, + lines: lines, + lineStartOffset: charCount - lines[lines.length - 1].length - 1, + positionInLine: 0 + }; + } + + handleKeydown(e) { + if (this.mode === 'insert') { + if (e.key === 'Escape') { + e.preventDefault(); + this.setMode('normal'); + // Move cursor one position left (vim behavior) + const offset = this.getCaretOffset(); + if (offset > 0) { + this.setCaretOffset(offset - 1); } } + return; + } - handleKeydown(e) { - const key = e.key; + if (this.mode === 'command') { + return; // Command mode input is handled by cmdInput + } - if (this.mode === 'insert') { - if (key === 'Escape') { - e.preventDefault(); - this.mode = 'normal'; - this.editor.blur(); - this.editor.focus(); - } - return; + if (this.mode === 'visual') { + if (e.key === 'Escape') { + e.preventDefault(); + this.setMode('normal'); + return; + } + + // Allow movement in visual mode + if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) { + return; // Let default behavior handle selection + } + + if (e.key === 'y') { + e.preventDefault(); + // Yank selected text + const sel = this.shadowRoot.getSelection(); + if (sel && sel.rangeCount > 0) { + this.yankedLine = sel.toString(); } - - if (this.mode === 'command') { - if (key === 'Enter' || key === 'Escape') { - e.preventDefault(); - this.cmdLine.style.display = 'none'; - this.mode = 'normal'; - this.keyBuffer = ''; - } - return; - } - - if (this.mode === 'visual') { - if (key === 'Escape') { - e.preventDefault(); - this.mode = 'normal'; - } - return; - } - - // Handle normal mode - this.keyBuffer += key; - - const text = this.editor.innerText; - const caretPos = this.getCaretOffset(); - const lines = text.split('\n'); - - let charCount = 0, lineIdx = 0; - for (let i = 0; i < lines.length; i++) { - if (caretPos <= charCount + lines[i].length) { - lineIdx = i; - break; - } - charCount += lines[i].length + 1; - } - - const offsetToLine = idx => - text.split('\n').slice(0, idx).reduce((acc, l) => acc + l.length + 1, 0); - - switch (this.keyBuffer) { - case 'i': - e.preventDefault(); - this.mode = 'insert'; - this.keyBuffer = ''; - break; - - case 'v': - e.preventDefault(); - this.mode = 'visual'; - this.keyBuffer = ''; - break; - - case ':': - e.preventDefault(); - this.mode = 'command'; - this.cmdLine.style.display = 'block'; - this.cmdLine.textContent = ':'; - this.keyBuffer = ''; - break; - - case 'yy': - e.preventDefault(); - this.yankedLine = lines[lineIdx]; - this.keyBuffer = ''; - break; - - case 'dd': - e.preventDefault(); - this.lastDeletedLine = lines[lineIdx]; - lines.splice(lineIdx, 1); - this.editor.innerText = lines.join('\n'); - this.setCaretOffset(offsetToLine(lineIdx)); - this.keyBuffer = ''; - break; - - case 'p': - e.preventDefault(); - const lineToPaste = this.yankedLine || this.lastDeletedLine; - if (lineToPaste) { - lines.splice(lineIdx + 1, 0, lineToPaste); - this.editor.innerText = lines.join('\n'); - this.setCaretOffset(offsetToLine(lineIdx + 1)); - } - this.keyBuffer = ''; - break; - - case '0': - e.preventDefault(); - this.setCaretOffset(offsetToLine(lineIdx)); - this.keyBuffer = ''; - break; - - case '$': - e.preventDefault(); - this.setCaretOffset(offsetToLine(lineIdx) + lines[lineIdx].length); - this.keyBuffer = ''; - break; - - case 'gg': - e.preventDefault(); - this.setCaretOffset(0); - this.keyBuffer = ''; - break; - - case 'G': - e.preventDefault(); - this.setCaretOffset(text.length); - this.keyBuffer = ''; - break; - - case 'Escape': - e.preventDefault(); - this.mode = 'normal'; - this.keyBuffer = ''; - this.cmdLine.style.display = 'none'; - break; - - default: - // allow up to 2 chars for combos - if (this.keyBuffer.length > 2) this.keyBuffer = ''; - break; + this.setMode('normal'); + return; + } + + if (e.key === 'd' || e.key === 'x') { + e.preventDefault(); + // Delete selected text + const sel = this.shadowRoot.getSelection(); + if (sel && sel.rangeCount > 0) { + this.lastDeletedLine = sel.toString(); + document.execCommand('delete'); } + this.setMode('normal'); + return; } } - customElements.define('njet-editor', NjetEditor); -export {NjetEditor} + // Normal mode handling + e.preventDefault(); + + // Special keys that should be handled immediately + if (e.key === 'Escape') { + this.keyBuffer = ''; + this.setMode('normal'); + return; + } + + // Build key buffer for commands + this.keyBuffer += e.key; + + const lineInfo = this.getCurrentLineInfo(); + const { lineIndex, lines, lineStartOffset, positionInLine } = lineInfo; + + // Process commands + switch (this.keyBuffer) { + case 'i': + this.keyBuffer = ''; + this.setMode('insert'); + break; + + case 'a': + this.keyBuffer = ''; + this.setCaretOffset(this.getCaretOffset() + 1); + this.setMode('insert'); + break; + + case 'v': + this.keyBuffer = ''; + this.setMode('visual'); + break; + + case ':': + this.keyBuffer = ''; + this.setMode('command'); + break; + + case 'yy': + this.keyBuffer = ''; + this.yankedLine = lines[lineIndex]; + break; + + case 'dd': + this.keyBuffer = ''; + this.lastDeletedLine = lines[lineIndex]; + lines.splice(lineIndex, 1); + if (lines.length === 0) lines.push(''); + this.editor.innerText = lines.join('\n'); + this.setCaretOffset(lineStartOffset); + break; + + case 'p': + this.keyBuffer = ''; + const lineToPaste = this.yankedLine || this.lastDeletedLine; + if (lineToPaste) { + lines.splice(lineIndex + 1, 0, lineToPaste); + this.editor.innerText = lines.join('\n'); + this.setCaretOffset(lineStartOffset + lines[lineIndex].length + 1); + } + break; + + case '0': + this.keyBuffer = ''; + this.setCaretOffset(lineStartOffset); + break; + + case '$': + this.keyBuffer = ''; + this.setCaretOffset(lineStartOffset + lines[lineIndex].length); + break; + + case 'gg': + this.keyBuffer = ''; + this.setCaretOffset(0); + break; + + case 'G': + this.keyBuffer = ''; + this.setCaretOffset(this.editor.innerText.length); + break; + + case 'h': + case 'ArrowLeft': + this.keyBuffer = ''; + const currentOffset = this.getCaretOffset(); + if (currentOffset > 0) { + this.setCaretOffset(currentOffset - 1); + } + break; + + case 'l': + case 'ArrowRight': + this.keyBuffer = ''; + this.setCaretOffset(this.getCaretOffset() + 1); + break; + + case 'j': + case 'ArrowDown': + this.keyBuffer = ''; + if (lineIndex < lines.length - 1) { + const nextLineStart = lineStartOffset + lines[lineIndex].length + 1; + const nextLineLength = lines[lineIndex + 1].length; + const newPosition = Math.min(positionInLine, nextLineLength); + this.setCaretOffset(nextLineStart + newPosition); + } + break; + + case 'k': + case 'ArrowUp': + this.keyBuffer = ''; + if (lineIndex > 0) { + let prevLineStart = 0; + for (let i = 0; i < lineIndex - 1; i++) { + prevLineStart += lines[i].length + 1; + } + const prevLineLength = lines[lineIndex - 1].length; + const newPosition = Math.min(positionInLine, prevLineLength); + this.setCaretOffset(prevLineStart + newPosition); + } + break; + + default: + // Clear buffer if it gets too long or contains invalid sequences + if (this.keyBuffer.length > 2 || + (this.keyBuffer.length === 2 && !['dd', 'yy', 'gg'].includes(this.keyBuffer))) { + this.keyBuffer = ''; + } + break; + } + } +} + +customElements.define('njet-editor', NjetEditor); +export { NjetEditor }