Add interactive terminal commands
This commit is contained in:
@@ -15,6 +15,8 @@
|
||||
onHoverButton: (idx: number) => void;
|
||||
onLinkClick: (idx: number) => void;
|
||||
terminalSettings: { showCursor: boolean };
|
||||
interactiveInput?: boolean;
|
||||
onCommand?: (command: string) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -27,8 +29,81 @@
|
||||
onHoverButton,
|
||||
onLinkClick,
|
||||
terminalSettings,
|
||||
interactiveInput = false,
|
||||
onCommand,
|
||||
}: Props = $props();
|
||||
|
||||
let inputEl = $state<HTMLInputElement>();
|
||||
let commandValue = $state("");
|
||||
let history = $state<string[]>([]);
|
||||
let historyIndex = $state(-1);
|
||||
let inputFocused = $state(false);
|
||||
let caretPos = $state(0);
|
||||
|
||||
const beforeText = $derived(commandValue.slice(0, caretPos));
|
||||
const cursorChar = $derived(commandValue.slice(caretPos, caretPos + 1) || " ");
|
||||
const afterRest = $derived(commandValue.slice(caretPos + 1));
|
||||
const afterFull = $derived(commandValue.slice(caretPos));
|
||||
|
||||
function syncCaret() {
|
||||
caretPos = inputEl?.selectionStart ?? commandValue.length;
|
||||
}
|
||||
|
||||
function recall(value: string) {
|
||||
commandValue = value;
|
||||
caretPos = value.length;
|
||||
}
|
||||
|
||||
function submitCommand() {
|
||||
const value = commandValue;
|
||||
commandValue = "";
|
||||
caretPos = 0;
|
||||
if (value.trim()) history = [...history, value];
|
||||
historyIndex = -1;
|
||||
onCommand?.(value);
|
||||
queueMicrotask(() => inputEl?.focus());
|
||||
}
|
||||
|
||||
function handleInputKeydown(event: KeyboardEvent) {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
submitCommand();
|
||||
} else if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (history.length === 0) return;
|
||||
const nextIndex =
|
||||
historyIndex === -1
|
||||
? history.length - 1
|
||||
: Math.max(0, historyIndex - 1);
|
||||
historyIndex = nextIndex;
|
||||
recall(history[nextIndex]);
|
||||
} else if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (historyIndex === -1) return;
|
||||
const nextIndex = historyIndex + 1;
|
||||
if (nextIndex >= history.length) {
|
||||
historyIndex = -1;
|
||||
recall("");
|
||||
} else {
|
||||
historyIndex = nextIndex;
|
||||
recall(history[nextIndex]);
|
||||
}
|
||||
} else {
|
||||
event.stopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
function focusCommandInput(event: MouseEvent) {
|
||||
if (!interactiveInput) return;
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.closest("a, button, input, textarea, .tui-button, .tui-link"))
|
||||
return;
|
||||
inputEl?.focus();
|
||||
}
|
||||
|
||||
// Group consecutive inline items together
|
||||
type ProcessedGroup =
|
||||
| { kind: "single"; index: number; displayed: DisplayedLine }
|
||||
@@ -76,7 +151,8 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="tui-body" bind:this={ref}>
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||
<div class="tui-body" bind:this={ref} onclick={focusCommandInput}>
|
||||
{#each processedGroups as group, gi (gi)}
|
||||
{#if group.kind === "inline"}
|
||||
<div class="tui-inline-group">
|
||||
@@ -127,7 +203,41 @@
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
{#if terminalSettings.showCursor && !isTyping && displayedLines.length > 0}
|
||||
{#if interactiveInput}
|
||||
{#if !isTyping}
|
||||
<div class="tui-line prompt tui-command-line">
|
||||
<span class="prompt">
|
||||
<span class="user">{user.username}</span><span class="at"
|
||||
>@</span
|
||||
><span class="host">{user.hostname}</span>
|
||||
<span class="separator">:</span><span class="path">~</span><span
|
||||
class="symbol">$</span
|
||||
>
|
||||
</span>
|
||||
<span class="tui-input-wrap">
|
||||
<input
|
||||
class="tui-command-input"
|
||||
bind:this={inputEl}
|
||||
bind:value={commandValue}
|
||||
onkeydown={handleInputKeydown}
|
||||
onkeyup={syncCaret}
|
||||
oninput={syncCaret}
|
||||
onclick={syncCaret}
|
||||
onfocus={() => {
|
||||
inputFocused = true;
|
||||
syncCaret();
|
||||
}}
|
||||
onblur={() => (inputFocused = false)}
|
||||
spellcheck="false"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
autocorrect="off"
|
||||
aria-label="Terminal command input"
|
||||
/><span class="tui-input-mirror" aria-hidden="true">{beforeText}{#if inputFocused}<span class="cursor-block blink">{cursorChar}</span>{afterRest}{:else}{afterFull}{/if}</span>
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
{:else if terminalSettings.showCursor && !isTyping && displayedLines.length > 0}
|
||||
<div class="tui-line prompt">
|
||||
<span class="prompt">
|
||||
<span class="user">{user.username}</span><span class="at"
|
||||
@@ -141,3 +251,63 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.tui-command-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tui-input-wrap {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin-left: 0.4rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tui-command-input {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
color: transparent;
|
||||
font: inherit;
|
||||
line-height: inherit;
|
||||
caret-color: transparent;
|
||||
}
|
||||
|
||||
.tui-command-input:focus,
|
||||
.tui-command-input:focus-visible {
|
||||
border: none;
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.tui-input-mirror {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
white-space: pre;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
color: var(--terminal-text);
|
||||
}
|
||||
|
||||
.cursor-block {
|
||||
display: inline-block;
|
||||
height: 1.2em;
|
||||
line-height: 1.2em;
|
||||
color: var(--terminal-bg);
|
||||
background: var(--terminal-accent, var(--terminal-primary));
|
||||
}
|
||||
|
||||
.cursor-block.blink {
|
||||
animation: cursorBlink 1s step-end infinite;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user