Move canned command data (terminal-commands.js), the achievement system (terminal-achievements.js), and the vim simulator (terminal-vim.js) out of terminal.js, which keeps the core terminal loop. Load order is fixed in index.html with the modules before terminal.js. Also share expand/collapse helpers with the hero click handler and drop the real fetch I/O in favour of simulated curl/wget output.
445 lines
13 KiB
JavaScript
445 lines
13 KiB
JavaScript
// Simulated vim editor for the fake terminal.
|
|
// The overlay is appended to <body> while active; on exit the terminal's
|
|
// content is reset and focus returns to it.
|
|
//
|
|
// Parameters:
|
|
// content .terminal-content element (reset on exit)
|
|
// terminal .terminal-display element (refocused on exit)
|
|
// mobileInput the hidden <input>; hidden while vim owns the keyboard
|
|
// filename argument to `vim <file>` (may be '' for the splash)
|
|
// setVimMode callback(active) so the host keeps its own state flag
|
|
|
|
function launchVimSimulator({ content, terminal, mobileInput, filename, setVimMode }) {
|
|
let vimBuffer = ['Welcome to vim (simulated)!'];
|
|
let vimLineNum = 0;
|
|
let vimStatus = '~';
|
|
let vimCol = 0;
|
|
let showSplash = false;
|
|
setVimMode(true);
|
|
// Hide the mobile input so it doesn't intercept vim keystrokes
|
|
mobileInput.style.display = 'none';
|
|
|
|
// Create a full-screen vim overlay
|
|
const vimOverlay = document.createElement('div');
|
|
vimOverlay.style.position = 'fixed';
|
|
vimOverlay.style.inset = '0';
|
|
vimOverlay.style.background = '#0a0a0c';
|
|
vimOverlay.style.fontFamily = "'SF Mono', 'Fira Code', 'Cascadia Code', monospace";
|
|
vimOverlay.style.fontSize = '0.85rem';
|
|
vimOverlay.style.lineHeight = '1.5';
|
|
vimOverlay.style.color = '#22c55e';
|
|
vimOverlay.style.display = 'flex';
|
|
vimOverlay.style.flexDirection = 'column';
|
|
vimOverlay.style.zIndex = '10000';
|
|
vimOverlay.style.padding = '12px 24px';
|
|
vimOverlay.style.overflow = 'hidden';
|
|
|
|
document.body.appendChild(vimOverlay);
|
|
|
|
const lineNums = [];
|
|
const lineTexts = [];
|
|
const vimLines = [];
|
|
const linesContainer = document.createElement('div');
|
|
linesContainer.style.flexGrow = '1';
|
|
linesContainer.style.minHeight = '0';
|
|
linesContainer.style.overflowY = 'auto';
|
|
linesContainer.style.display = 'flex';
|
|
linesContainer.style.flexDirection = 'column';
|
|
linesContainer.style.scrollbarWidth = 'none';
|
|
linesContainer.style.msOverflowStyle = 'none';
|
|
|
|
function createLineElement() {
|
|
const line = document.createElement('div');
|
|
line.style.whiteSpace = 'pre';
|
|
line.style.flexShrink = '0';
|
|
const numSpan = document.createElement('span');
|
|
numSpan.style.color = '#666';
|
|
numSpan.style.marginRight = '8px';
|
|
numSpan.style.userSelect = 'none';
|
|
numSpan.style.display = 'inline-block';
|
|
numSpan.style.width = '3ch';
|
|
const textSpan = document.createElement('span');
|
|
line.appendChild(numSpan);
|
|
line.appendChild(textSpan);
|
|
return line;
|
|
}
|
|
|
|
function createSplashLine(text) {
|
|
const line = document.createElement('div');
|
|
line.style.whiteSpace = 'pre';
|
|
line.style.flexShrink = '0';
|
|
const textSpan = document.createElement('span');
|
|
line.appendChild(textSpan);
|
|
return { line, textSpan };
|
|
}
|
|
|
|
if (!filename) {
|
|
showSplash = true;
|
|
const splashText = [
|
|
'',
|
|
' VIM - Vi sIgnificantly worse M',
|
|
'',
|
|
' version 0.0.0-alpha',
|
|
' by disappointed programmers et al.',
|
|
' Modified by https://www.reddit.com/r/vim/',
|
|
' Vim is open source and you will figure it out',
|
|
'',
|
|
' Please send help.',
|
|
'',
|
|
' type :q<Enter> to escape this regret',
|
|
' type :help<Enter> or <F1> for on-line help',
|
|
' type :help version0<Enter> for version info',
|
|
'',
|
|
];
|
|
|
|
for (const text of splashText) {
|
|
const { line, textSpan } = createSplashLine(text);
|
|
linesContainer.appendChild(line);
|
|
lineTexts.push(textSpan);
|
|
vimLines.push(line);
|
|
}
|
|
|
|
vimBuffer = splashText;
|
|
vimLineNum = 0;
|
|
vimCol = 0;
|
|
} else {
|
|
const line = createLineElement();
|
|
linesContainer.appendChild(line);
|
|
lineNums.push(line.children[0]);
|
|
lineTexts.push(line.children[1]);
|
|
vimLines.push(line);
|
|
vimBuffer = ['Welcome to ' + filename + '!'];
|
|
vimLineNum = 0;
|
|
vimCol = 0;
|
|
}
|
|
|
|
vimOverlay.appendChild(linesContainer);
|
|
|
|
// Status bar at the bottom
|
|
const statusLine = document.createElement('div');
|
|
statusLine.style.marginTop = 'auto';
|
|
statusLine.style.borderTop = '1px solid #444';
|
|
statusLine.style.paddingTop = '4px';
|
|
statusLine.style.color = '#ccc';
|
|
statusLine.style.flexShrink = '0';
|
|
vimOverlay.appendChild(statusLine);
|
|
|
|
// File info at the very bottom
|
|
const fileInfo = document.createElement('div');
|
|
fileInfo.style.color = '#888';
|
|
fileInfo.style.flexShrink = '0';
|
|
vimOverlay.appendChild(fileInfo);
|
|
|
|
function escapeHtml(str) {
|
|
const div = document.createElement('div');
|
|
div.textContent = str;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
function vimRender() {
|
|
// Ensure we have enough line elements
|
|
while (vimLines.length < vimBuffer.length) {
|
|
let newLine;
|
|
if (showSplash) {
|
|
const { line, textSpan } = createSplashLine('');
|
|
newLine = line;
|
|
linesContainer.appendChild(line);
|
|
lineTexts.push(textSpan);
|
|
} else {
|
|
newLine = createLineElement();
|
|
linesContainer.appendChild(newLine);
|
|
lineNums.push(newLine.children[0]);
|
|
lineTexts.push(newLine.children[1]);
|
|
}
|
|
vimLines.push(newLine);
|
|
}
|
|
|
|
// Update all visible lines
|
|
for (let i = 0; i < vimBuffer.length; i++) {
|
|
if (showSplash) {
|
|
lineTexts[i].textContent = vimBuffer[i] || '';
|
|
} else {
|
|
lineNums[i].textContent = (i + 1);
|
|
const lineText = vimBuffer[i] || '';
|
|
if (i === vimLineNum && (vimStatus === '-- INSERT --' || vimStatus === '~')) {
|
|
const col = vimCol;
|
|
const before = lineText.slice(0, col);
|
|
const after = lineText.slice(col);
|
|
if (after.length > 0) {
|
|
const cursorChar = after.charAt(0);
|
|
lineTexts[i].innerHTML = escapeHtml(before) + '<span class="vim-block-cursor">' + escapeHtml(cursorChar) + '</span>' + escapeHtml(after.slice(1));
|
|
} else {
|
|
lineTexts[i].innerHTML = escapeHtml(before) + '<span class="vim-block-cursor"> </span>';
|
|
}
|
|
} else {
|
|
lineTexts[i].textContent = lineText;
|
|
}
|
|
}
|
|
vimLines[i].style.display = '';
|
|
}
|
|
|
|
// Hide unused line elements
|
|
for (let i = vimBuffer.length; i < vimLines.length; i++) {
|
|
vimLines[i].style.display = 'none';
|
|
}
|
|
|
|
// Auto-scroll when line is above or below the viewport
|
|
const lineEl = vimLines[vimLineNum];
|
|
if (lineEl && lineEl.style.display !== 'none') {
|
|
const lineBottom = lineEl.offsetTop + lineEl.offsetHeight;
|
|
const lineTop = lineEl.offsetTop;
|
|
if (lineBottom > linesContainer.scrollTop + linesContainer.clientHeight) {
|
|
linesContainer.scrollTop = lineBottom - linesContainer.clientHeight;
|
|
} else if (lineTop < linesContainer.scrollTop + 12) {
|
|
linesContainer.scrollTop = lineTop - 12;
|
|
}
|
|
}
|
|
|
|
if (showSplash) {
|
|
statusLine.textContent = '';
|
|
fileInfo.textContent = '"VIM - Vi sIgnificantly worse M"';
|
|
} else {
|
|
statusLine.textContent = vimStatus;
|
|
fileInfo.textContent = '"' + filename + '"';
|
|
}
|
|
}
|
|
|
|
function vimExit(message) {
|
|
document.removeEventListener('keydown', vimHandler);
|
|
setVimMode(false);
|
|
mobileInput.style.display = '';
|
|
vimOverlay.remove();
|
|
content.innerHTML = '';
|
|
if (message) {
|
|
const msgLine = document.createElement('div');
|
|
msgLine.style.whiteSpace = 'pre-wrap';
|
|
msgLine.style.color = '#ccc';
|
|
msgLine.textContent = message;
|
|
content.appendChild(msgLine);
|
|
}
|
|
const newLine = document.createElement('div');
|
|
newLine.innerHTML = '<span class="terminal-prompt">$</span> ';
|
|
content.appendChild(newLine);
|
|
const newCursor = document.createElement('span');
|
|
newCursor.className = 'terminal-cursor';
|
|
newLine.appendChild(newCursor);
|
|
content.scrollTop = content.scrollHeight;
|
|
terminal.focus();
|
|
}
|
|
|
|
const vimHandler = (e) => {
|
|
e.preventDefault();
|
|
|
|
if (showSplash) {
|
|
if (e.key === 'q' && e.shiftKey) {
|
|
vimExit();
|
|
return;
|
|
}
|
|
if (e.key === 'Escape' || e.key === 'q') {
|
|
vimExit();
|
|
return;
|
|
}
|
|
vimRender();
|
|
return;
|
|
}
|
|
|
|
if (vimStatus.startsWith(':')) {
|
|
if (e.key === 'Enter') {
|
|
const cmd = vimStatus.slice(1).trim();
|
|
if (cmd === 'q' || cmd === 'q!') {
|
|
vimExit();
|
|
return;
|
|
}
|
|
if (cmd === 'wq' || cmd === 'x') {
|
|
vimExit('File saved (simulated).');
|
|
return;
|
|
}
|
|
if (cmd === 'w') {
|
|
vimStatus = 'File written (simulated).';
|
|
vimRender();
|
|
setTimeout(() => { vimStatus = ':'; vimRender(); }, 1000);
|
|
return;
|
|
}
|
|
if (cmd.startsWith('%s/')) {
|
|
vimStatus = 'Pattern not found (simulated).';
|
|
vimRender();
|
|
setTimeout(() => { vimStatus = ':'; vimRender(); }, 1000);
|
|
return;
|
|
}
|
|
if (cmd === 'help') {
|
|
vimStatus = ':q quit :w save :wq save & quit :q! force quit dd delete line i insert o open line';
|
|
vimRender();
|
|
setTimeout(() => { vimStatus = ':'; vimRender(); }, 3000);
|
|
return;
|
|
}
|
|
vimStatus = 'Unknown command: ' + cmd;
|
|
vimRender();
|
|
setTimeout(() => { vimStatus = ':'; vimRender(); }, 2000);
|
|
return;
|
|
}
|
|
if (e.key === 'Escape') {
|
|
vimStatus = '~';
|
|
vimRender();
|
|
return;
|
|
}
|
|
if (e.key === 'Backspace') {
|
|
vimStatus = vimStatus.slice(0, -1);
|
|
vimRender();
|
|
return;
|
|
}
|
|
if (e.key.length === 1) {
|
|
vimStatus += e.key;
|
|
vimRender();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (vimStatus === '-- INSERT --') {
|
|
if (e.key === 'Escape') {
|
|
vimStatus = '~';
|
|
vimRender();
|
|
return;
|
|
}
|
|
if (e.key.length === 1 && !e.ctrlKey && !e.altKey && !e.metaKey) {
|
|
vimBuffer[vimLineNum] = vimBuffer[vimLineNum].slice(0, vimCol) + e.key + vimBuffer[vimLineNum].slice(vimCol);
|
|
vimCol++;
|
|
vimRender();
|
|
return;
|
|
}
|
|
if (e.key === 'Enter') {
|
|
vimBuffer.splice(vimLineNum + 1, 0, '');
|
|
vimLineNum++;
|
|
vimCol = 0;
|
|
vimRender();
|
|
return;
|
|
}
|
|
if (e.key === 'Backspace') {
|
|
if (vimCol > 0) {
|
|
vimCol--;
|
|
vimBuffer[vimLineNum] = vimBuffer[vimLineNum].slice(0, vimCol) + vimBuffer[vimLineNum].slice(vimCol + 1);
|
|
}
|
|
vimRender();
|
|
return;
|
|
}
|
|
if (e.key === 'ArrowUp') {
|
|
if (vimLineNum > 0) { vimLineNum--; vimCol = Math.min(vimCol, (vimBuffer[vimLineNum] || '').length); vimRender(); }
|
|
return;
|
|
}
|
|
if (e.key === 'ArrowDown') {
|
|
if (vimLineNum < vimBuffer.length - 1) { vimLineNum++; vimCol = Math.min(vimCol, (vimBuffer[vimLineNum] || '').length); vimRender(); }
|
|
return;
|
|
}
|
|
if (e.key === 'ArrowLeft') {
|
|
if (vimCol > 0) { vimCol--; vimRender(); }
|
|
return;
|
|
}
|
|
if (e.key === 'ArrowRight') {
|
|
const maxCol = (vimBuffer[vimLineNum] || '').length;
|
|
if (vimCol < maxCol) { vimCol++; vimRender(); }
|
|
return;
|
|
}
|
|
vimRender();
|
|
return;
|
|
}
|
|
|
|
// Normal mode (~)
|
|
if (e.key === 'Escape') {
|
|
vimStatus = '~';
|
|
vimRender();
|
|
return;
|
|
}
|
|
if (e.key === 'i') {
|
|
vimStatus = '-- INSERT --';
|
|
vimRender();
|
|
return;
|
|
}
|
|
if (e.key === 'I') {
|
|
vimCol = 0;
|
|
vimStatus = '-- INSERT --';
|
|
vimRender();
|
|
return;
|
|
}
|
|
if (e.key === 'a') {
|
|
vimCol = Math.min(vimCol + 1, (vimBuffer[vimLineNum] || '').length);
|
|
vimStatus = '-- INSERT --';
|
|
vimRender();
|
|
return;
|
|
}
|
|
if (e.key === 'A') {
|
|
vimCol = (vimBuffer[vimLineNum] || '').length;
|
|
vimStatus = '-- INSERT --';
|
|
vimRender();
|
|
return;
|
|
}
|
|
if (e.key === 'o') {
|
|
vimBuffer.splice(vimLineNum + 1, 0, '');
|
|
vimLineNum++;
|
|
vimCol = 0;
|
|
vimStatus = '-- INSERT --';
|
|
vimRender();
|
|
return;
|
|
}
|
|
if (e.key === 'O') {
|
|
vimBuffer.splice(vimLineNum, 0, '');
|
|
vimLineNum++;
|
|
vimCol = 0;
|
|
vimStatus = '-- INSERT --';
|
|
vimRender();
|
|
return;
|
|
}
|
|
if (e.key === 'x') {
|
|
if (vimBuffer[vimLineNum]) {
|
|
vimBuffer[vimLineNum] = vimBuffer[vimLineNum].slice(0, -1);
|
|
}
|
|
vimRender();
|
|
return;
|
|
}
|
|
// "dd" — delete the current line (Shift+D, or Alt+D as a fallback).
|
|
// The buffer always keeps at least one (empty) line so insert
|
|
// mode can never index a missing line.
|
|
if ((e.key === 'D' && e.shiftKey) || (e.key === 'd' && e.altKey)) {
|
|
vimBuffer.splice(vimLineNum, 1);
|
|
if (vimBuffer.length === 0) vimBuffer.push('');
|
|
if (vimLineNum >= vimBuffer.length) vimLineNum = vimBuffer.length - 1;
|
|
vimCol = Math.min(vimCol, (vimBuffer[vimLineNum] || '').length);
|
|
vimRender();
|
|
return;
|
|
}
|
|
if (e.key === 'ArrowUp') {
|
|
if (vimLineNum > 0) { vimLineNum--; vimCol = Math.min(vimCol, (vimBuffer[vimLineNum] || '').length); vimRender(); }
|
|
return;
|
|
}
|
|
if (e.key === 'ArrowDown') {
|
|
if (vimLineNum < vimBuffer.length - 1) { vimLineNum++; vimCol = Math.min(vimCol, (vimBuffer[vimLineNum] || '').length); vimRender(); }
|
|
return;
|
|
}
|
|
if (e.key === 'ArrowLeft') {
|
|
if (vimCol > 0) { vimCol--; vimRender(); }
|
|
return;
|
|
}
|
|
if (e.key === 'ArrowRight') {
|
|
const maxCol = (vimBuffer[vimLineNum] || '').length;
|
|
if (vimCol < maxCol) { vimCol++; vimRender(); }
|
|
return;
|
|
}
|
|
if (e.key === 'G') {
|
|
vimLineNum = vimBuffer.length - 1;
|
|
vimRender();
|
|
return;
|
|
}
|
|
if (e.key === 'g') {
|
|
vimLineNum = 0;
|
|
vimRender();
|
|
return;
|
|
}
|
|
if (e.key === ':') {
|
|
vimStatus = ':';
|
|
vimRender();
|
|
return;
|
|
}
|
|
vimRender();
|
|
};
|
|
|
|
document.addEventListener('keydown', vimHandler);
|
|
}
|