ttrpg-tools/src/components/journal/JournalInput.tsx

441 lines
13 KiB
TypeScript
Raw Normal View History

/**
* JournalInput chat-style textarea with command dispatch and autocomplete.
*
* - Enter sends, Shift+Enter inserts newline
* - Plain text "narrative" type
* - `/roll 3d6kh1` "roll.request" type
* - `/link path#section` "article.reveal" type
* - `/` alone opens completions dropdown (populated from /__COMPLETIONS.json
* in CLI mode, or client-side scan of the in-memory file index in dev mode)
*/
import {
Component,
createSignal,
createEffect,
onMount,
onCleanup,
Show,
For,
Switch,
Match,
} from "solid-js";
import {
sendMessage,
revertLatest,
canRevert,
useJournalStream,
} from "../stores/journalStream";
import {
useJournalCompletions,
ensureCompletions,
type CompletionsState,
} from "./completions";
// ---- Helpers ----
interface CompletionItem {
label: string;
kind: "command" | "value" | "no-results";
insertText: string;
}
interface ParsedInput {
type: "narrative" | "roll.request" | "article.reveal";
payload: Record<string, unknown>;
error?: string;
}
function parseInput(raw: string): ParsedInput {
if (raw.startsWith("/roll ")) {
const notation = raw.slice("/roll ".length).trim();
if (!notation)
return {
type: "roll.request",
payload: {},
error: "Dice notation required",
};
return { type: "roll.request", payload: { notation, label: notation } };
}
if (raw.startsWith("/link ")) {
const arg = raw.slice("/link ".length).trim();
if (!arg)
return { type: "article.reveal", payload: {}, error: "Path required" };
const hashIdx = arg.indexOf("#");
const path = hashIdx === -1 ? arg : arg.slice(0, hashIdx);
const section =
hashIdx === -1 ? undefined : arg.slice(hashIdx + 1) || undefined;
return { type: "article.reveal", payload: { path, section } };
}
// /roll or /link with no space — need to complete, don't send
if (raw === "/roll" || raw === "/link") {
return { type: "narrative", payload: {}, error: "Complete the command" };
}
return { type: "narrative", payload: { text: raw } };
}
// ---- Component ----
export const JournalInput: Component = () => {
const stream = useJournalStream();
const comp = useJournalCompletions();
const [text, setText] = createSignal("");
const [error, setError] = createSignal<string | null>(null);
const [sending, setSending] = createSignal(false);
const [showCompletions, setShowCompletions] = createSignal(false);
const [selectedIdx, setSelectedIdx] = createSignal(0);
let textareaRef!: HTMLTextAreaElement;
let completionsRef!: HTMLDivElement;
// Ensure completions are loading on mount
onMount(() => {
void ensureCompletions();
});
// ---- Send ----
function handleSend() {
const raw = text().trim();
if (!raw) return;
const parsed = parseInput(raw);
if (parsed.error) {
setError(parsed.error);
return;
}
setError(null);
setSending(true);
const result = sendMessage(parsed.type, parsed.payload);
if (!result.success) {
setError(result.error);
} else {
setText("");
}
setSending(false);
textareaRef?.focus();
}
function handleRevert() {
revertLatest();
}
// ---- Scroll selected completion into view ----
createEffect(() => {
const idx = selectedIdx();
if (!showCompletions() || !completionsRef) return;
const el = completionsRef.querySelector(`[data-comp-idx="${idx}"]`);
if (el) el.scrollIntoView({ block: "nearest" });
});
// ---- Completions ----
function buildCompletions(): CompletionItem[] {
const raw = text().trim();
const data = comp.data;
const commands = [
{ label: "/roll", kind: "command" as const, insertText: "/roll " },
{ label: "/link", kind: "command" as const, insertText: "/link " },
];
// Show commands when user types / or starts typing a command name
if (raw.startsWith("/") && !raw.includes(" ")) {
const prefix = raw.toLowerCase();
const matches = commands.filter((c) =>
c.label.toLowerCase().startsWith(prefix),
);
return matches.length > 0
? matches
: [{ label: "Unknown command", kind: "no-results", insertText: "" }];
}
// After /roll — show dice suggestions
if (raw.startsWith("/roll ")) {
const prefix = raw.slice("/roll ".length).toLowerCase();
const matches = data.dice
.filter(
(d) =>
d.notation.toLowerCase().includes(prefix) ||
d.label.toLowerCase().includes(prefix),
)
.slice(0, 8);
if (matches.length === 0) {
return [{ label: "No dice found", kind: "no-results", insertText: "" }];
}
return matches.map((d) => ({
label: d.notation,
kind: "value" as const,
insertText: "/roll " + d.notation,
}));
}
// After /link — show article and heading suggestions
if (raw.startsWith("/link ")) {
const prefix = raw.slice("/link ".length).toLowerCase();
const matches = data.links
.filter(
(l) =>
l.path.toLowerCase().includes(prefix) ||
l.label.toLowerCase().includes(prefix),
)
.slice(0, 8);
if (matches.length === 0) {
return [
{ label: "No links found", kind: "no-results", insertText: "" },
];
}
return matches.map((l) => {
const insert = l.section
? `/link ${l.path}#${l.section}`
: `/link ${l.path}`;
return {
label: l.label,
kind: "value" as const,
insertText: insert,
};
});
}
return [];
}
function currentCompletions(): CompletionItem[] {
return buildCompletions();
}
function acceptCompletion(item: CompletionItem) {
if (item.kind === "no-results") return;
setText(item.insertText);
setShowCompletions(false);
textareaRef?.focus();
}
function selectCompletion(dir: "up" | "down") {
const comps = currentCompletions();
if (comps.length === 0) return;
setSelectedIdx((prev) => {
if (dir === "down") return (prev + 1) % comps.length;
return (prev - 1 + comps.length) % comps.length;
});
}
// ---- Keyboard ----
function handleKeyDown(e: KeyboardEvent) {
const comps = currentCompletions();
const open = showCompletions();
// When opening completions with Tab, if the text matches a command prefix
// and there are options, accept the first. Otherwise just show.
if (e.key === "Tab") {
if (open) {
e.preventDefault();
if (comps.length > 0 && comps[selectedIdx()].kind !== "no-results") {
acceptCompletion(comps[selectedIdx()]);
}
return;
}
// If not open and starts with /, open completions
const raw = text();
if (raw.startsWith("/")) {
e.preventDefault();
setShowCompletions(true);
setSelectedIdx(0);
return;
}
return;
}
if (open && comps.length > 0) {
if (e.key === "ArrowDown") {
e.preventDefault();
selectCompletion("down");
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
selectCompletion("up");
return;
}
if (e.key === "Enter" && comps[selectedIdx()].kind !== "no-results") {
e.preventDefault();
acceptCompletion(comps[selectedIdx()]);
return;
}
if (e.key === "Escape") {
setShowCompletions(false);
return;
}
}
// Send
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}
function handleInput(e: InputEvent) {
const input = e.currentTarget as HTMLTextAreaElement;
const raw = input.value;
setText(raw);
// Completions visibility — keep open while user types any /
if (raw.startsWith("/")) {
setShowCompletions(true);
setSelectedIdx(0);
} else {
setShowCompletions(false);
}
// Auto-resize
textareaRef.style.height = "auto";
textareaRef.style.height = Math.min(textareaRef.scrollHeight, 150) + "px";
}
// ---- Click outside ----
onMount(() => {
const handler = (e: MouseEvent) => {
if (completionsRef && !completionsRef.contains(e.target as Node)) {
setShowCompletions(false);
}
};
document.addEventListener("mousedown", handler);
onCleanup(() => document.removeEventListener("mousedown", handler));
});
const showRevert = () => canRevert() && stream.myName === "gm";
// Completions placeholder — shown in the dropdown area when no matches
function renderCompletionsDropdown() {
const comps = currentCompletions();
if (comps.length === 0) return null;
return (
<div
ref={completionsRef}
class="absolute bottom-full left-0 right-0 bg-white border border-gray-200 rounded-t shadow-lg mb-0.5 max-h-40 overflow-y-auto z-50"
>
<For each={comps}>
{(item, idx) => (
<div
data-comp-idx={idx()}
class={`flex items-center gap-2 px-3 py-1.5 cursor-pointer text-sm ${
item.kind === "no-results"
? "text-gray-400 italic cursor-default"
: idx() === selectedIdx()
? "bg-blue-50 text-blue-700"
: "hover:bg-gray-100 text-gray-700"
}`}
onClick={() => acceptCompletion(item)}
onMouseEnter={() =>
item.kind !== "no-results" && setSelectedIdx(idx())
}
>
<Show
when={item.kind !== "no-results"}
fallback={<span class="text-xs text-gray-300 shrink-0"></span>}
>
<span
class={`text-xs px-1 rounded shrink-0 ${
item.kind === "command"
? "bg-purple-100 text-purple-700"
: "bg-gray-100 text-gray-500"
}`}
>
{item.kind === "command" ? "cmd" : "val"}
</span>
</Show>
<span class="font-mono truncate flex-1">{item.label}</span>
</div>
)}
</For>
</div>
);
}
return (
<div class="border-t border-gray-200 bg-white relative">
{/* Completions loading / error / empty teaser */}
<Show when={showCompletions()}>
<Switch>
<Match when={comp.state.status === "loading"}>
<div
ref={completionsRef}
class="absolute bottom-full left-0 right-0 bg-white border border-gray-200 rounded-t shadow-lg mb-0.5 z-50 px-3 py-2 text-xs text-gray-400 italic"
>
Loading completions
</div>
</Match>
<Match when={comp.state.status === "error"}>
<div
ref={completionsRef}
class="absolute bottom-full left-0 right-0 bg-white border border-red-200 rounded-t shadow-lg mb-0.5 z-50 px-3 py-2 text-xs text-red-500"
>
{(comp.state as any).message || "Failed to load completions"}
</div>
</Match>
<Match
when={
comp.state.status === "empty" || comp.state.status === "loaded"
}
>
{renderCompletionsDropdown()}
</Match>
</Switch>
</Show>
{/* Textarea + actions */}
<div class="flex flex-col">
<textarea
ref={textareaRef}
value={text()}
onInput={handleInput}
onKeyDown={handleKeyDown}
placeholder="Type a message, or /roll or /link..."
rows={2}
class="w-full resize-none border-0 px-3 pt-2.5 pb-1 text-sm
text-gray-800 placeholder-gray-400 focus:outline-none
bg-transparent min-h-[60px]"
/>
{/* Bottom row: error + buttons */}
<div class="flex items-center justify-between px-2 pb-2">
<Show when={error()}>
<p class="text-red-500 text-xs truncate max-w-[60%]">{error()}</p>
</Show>
<div class="flex items-center gap-1 ml-auto">
<Show when={showRevert}>
<button
onClick={handleRevert}
class="text-xs text-gray-400 hover:text-red-500 px-1.5 py-0.5"
title="Revert last message"
>
</button>
</Show>
<button
onClick={handleSend}
disabled={sending() || !text().trim()}
class="bg-blue-600 text-white rounded px-3 py-1 text-xs
hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed
transition-colors"
>
Send
</button>
</div>
</div>
</div>
</div>
);
};