feat: improve completions loading and UI handling
Refactor the completions system to support both CLI mode (via `/__COMPLETIONS.json`) and a client-side fallback scan for dev mode. - Implement client-side scanning of the file index for dice and headings - Add `ensureCompletions` to allow components to await data readiness - Update `JournalInput` to handle "no results" states in the dropdown - Improve keyboard navigation (Tab to accept, Enter to select) - Update dice regex to support `:md-dice[...]` syntax
This commit is contained in:
parent
4c5add2414
commit
e0d61cf91e
|
|
@ -14,7 +14,7 @@ export const diceSource: CompletionSource = {
|
|||
|
||||
scan(index) {
|
||||
const items: DiceCompletion[] = [];
|
||||
const tagRegex = /<md-dice[^>]*>\s*([\s\S]*?)\s*<\/md-dice>/gi;
|
||||
const tagRegex = /:md-dice\[([^[]+)\]/gi;
|
||||
|
||||
for (const [path, content] of Object.entries(index)) {
|
||||
if (!path.endsWith(".md")) continue;
|
||||
|
|
|
|||
|
|
@ -5,17 +5,37 @@
|
|||
* - Plain text → "narrative" type
|
||||
* - `/roll 3d6kh1` → "roll.request" type
|
||||
* - `/link path#section` → "article.reveal" type
|
||||
* - `/` alone opens completions dropdown (populated from /__COMPLETIONS.json)
|
||||
* - `/` 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, onMount, onCleanup, Show, For } from "solid-js";
|
||||
import { sendMessage, revertLatest, canRevert, useJournalStream } from "../stores/journalStream";
|
||||
import { useJournalCompletions, type DiceCompletion, type LinkCompletion } from "./completions";
|
||||
import {
|
||||
Component,
|
||||
createSignal,
|
||||
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;
|
||||
/** "command" | "value" — used for styling and insertion */
|
||||
kind: "command" | "value";
|
||||
kind: "command" | "value" | "no-results";
|
||||
insertText: string;
|
||||
}
|
||||
|
||||
|
|
@ -25,9 +45,42 @@ interface ParsedInput {
|
|||
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 completions = useJournalCompletions();
|
||||
const comp = useJournalCompletions();
|
||||
|
||||
const [text, setText] = createSignal("");
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
|
|
@ -38,38 +91,12 @@ export const JournalInput: Component = () => {
|
|||
let textareaRef!: HTMLTextAreaElement;
|
||||
let completionsRef!: HTMLDivElement;
|
||||
|
||||
// ---------- Parsing ----------
|
||||
// Ensure completions are loading on mount
|
||||
onMount(() => {
|
||||
void ensureCompletions();
|
||||
});
|
||||
|
||||
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 [path, section] = parseLinkArg(arg);
|
||||
return { type: "article.reveal", payload: { path, section: section ?? undefined } };
|
||||
}
|
||||
|
||||
// /roll or /link with no space — show completions, don't send
|
||||
if (raw === "/roll" || raw === "/link") {
|
||||
return { type: "narrative", payload: {}, error: "Complete the command" };
|
||||
}
|
||||
|
||||
return { type: "narrative", payload: { text: raw } };
|
||||
}
|
||||
|
||||
/** Parse "path#section" or just "path" */
|
||||
function parseLinkArg(arg: string): [string, string | null] {
|
||||
const hashIdx = arg.indexOf("#");
|
||||
if (hashIdx === -1) return [arg, null];
|
||||
return [arg.slice(0, hashIdx), arg.slice(hashIdx + 1) || null];
|
||||
}
|
||||
|
||||
// ---------- Send ----------
|
||||
// ---- Send ----
|
||||
|
||||
function handleSend() {
|
||||
const raw = text().trim();
|
||||
|
|
@ -99,10 +126,11 @@ export const JournalInput: Component = () => {
|
|||
revertLatest();
|
||||
}
|
||||
|
||||
// ---------- Completions ----------
|
||||
// ---- Completions ----
|
||||
|
||||
function buildCompletions(): CompletionItem[] {
|
||||
const raw = text().trim();
|
||||
const data = comp.data;
|
||||
|
||||
// Show commands when user types /
|
||||
if (raw === "" || raw === "/") {
|
||||
|
|
@ -115,14 +143,17 @@ export const JournalInput: Component = () => {
|
|||
// After /roll — show dice suggestions
|
||||
if (raw.startsWith("/roll ")) {
|
||||
const prefix = raw.slice("/roll ".length).toLowerCase();
|
||||
return completions.dice
|
||||
const matches = data.dice
|
||||
.filter(
|
||||
(d) =>
|
||||
d.notation.toLowerCase().includes(prefix) ||
|
||||
d.label.toLowerCase().includes(prefix),
|
||||
)
|
||||
.slice(0, 8)
|
||||
.map((d) => ({
|
||||
.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,
|
||||
|
|
@ -132,15 +163,22 @@ export const JournalInput: Component = () => {
|
|||
// After /link — show article and heading suggestions
|
||||
if (raw.startsWith("/link ")) {
|
||||
const prefix = raw.slice("/link ".length).toLowerCase();
|
||||
return completions.links
|
||||
const matches = data.links
|
||||
.filter(
|
||||
(l) =>
|
||||
l.path.toLowerCase().includes(prefix) ||
|
||||
l.label.toLowerCase().includes(prefix),
|
||||
)
|
||||
.slice(0, 8)
|
||||
.map((l) => {
|
||||
const insert = l.section ? `/link ${l.path}#${l.section}` : `/link ${l.path}`;
|
||||
.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,
|
||||
|
|
@ -157,6 +195,7 @@ export const JournalInput: Component = () => {
|
|||
}
|
||||
|
||||
function acceptCompletion(item: CompletionItem) {
|
||||
if (item.kind === "no-results") return;
|
||||
setText(item.insertText);
|
||||
setShowCompletions(false);
|
||||
textareaRef?.focus();
|
||||
|
|
@ -171,22 +210,53 @@ export const JournalInput: Component = () => {
|
|||
});
|
||||
}
|
||||
|
||||
// ---------- Keyboard ----------
|
||||
// ---- Keyboard ----
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
const comps = currentCompletions();
|
||||
const open = showCompletions();
|
||||
|
||||
// Completions: navigate & accept
|
||||
// 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 typing /, open completions
|
||||
const raw = text();
|
||||
if (raw === "/" || raw.startsWith("/roll") || raw.startsWith("/link")) {
|
||||
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 === "Tab" || e.key === "Enter") {
|
||||
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; }
|
||||
if (e.key === "Escape") {
|
||||
setShowCompletions(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Send
|
||||
|
|
@ -196,11 +266,19 @@ export const JournalInput: Component = () => {
|
|||
}
|
||||
}
|
||||
|
||||
function handleInput() {
|
||||
const raw = text();
|
||||
function handleInput(e: InputEvent) {
|
||||
const input = e.currentTarget as HTMLTextAreaElement;
|
||||
const raw = input.value;
|
||||
setText(raw);
|
||||
|
||||
// Show completions when user types / at the start
|
||||
if (raw === "/" || raw.startsWith("/roll ") || raw.startsWith("/link ")) {
|
||||
// Completions visibility
|
||||
if (
|
||||
raw === "/" ||
|
||||
raw.startsWith("/roll ") ||
|
||||
raw.startsWith("/link ") ||
|
||||
raw === "/roll" ||
|
||||
raw === "/link"
|
||||
) {
|
||||
setShowCompletions(true);
|
||||
setSelectedIdx(0);
|
||||
} else {
|
||||
|
|
@ -212,7 +290,7 @@ export const JournalInput: Component = () => {
|
|||
textareaRef.style.height = Math.min(textareaRef.scrollHeight, 150) + "px";
|
||||
}
|
||||
|
||||
// ---------- Click outside ----------
|
||||
// ---- Click outside ----
|
||||
|
||||
onMount(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
|
|
@ -226,24 +304,34 @@ export const JournalInput: Component = () => {
|
|||
|
||||
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 class="border-t border-gray-200 bg-white relative">
|
||||
{/* Completions dropdown — positioned above the textarea */}
|
||||
<Show when={showCompletions() && currentCompletions().length > 0}>
|
||||
<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={currentCompletions()}>
|
||||
<For each={comps}>
|
||||
{(item, idx) => (
|
||||
<div
|
||||
class={`flex items-center gap-2 px-3 py-1.5 cursor-pointer text-sm ${
|
||||
idx() === selectedIdx()
|
||||
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={() => setSelectedIdx(idx())}
|
||||
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 ${
|
||||
|
|
@ -254,11 +342,44 @@ export const JournalInput: Component = () => {
|
|||
>
|
||||
{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 */}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,19 @@
|
|||
/**
|
||||
* Journal completions — client-side loader for /__COMPLETIONS.json
|
||||
*
|
||||
* Fetches once on first call, caches the result. Call `invalidateCompletions()`
|
||||
* to force a re-fetch (e.g., when the content source changes).
|
||||
* In CLI mode, fetches the pre-computed index. In dev/browser mode, falls
|
||||
* back to scanning the in-memory file index for dice expressions and headings.
|
||||
*
|
||||
* The fetch runs eagerly on module import. Call `useJournalCompletions()`
|
||||
* from any Solid component to reactively read the state.
|
||||
*/
|
||||
|
||||
import { createSignal } from "solid-js";
|
||||
import { extractHeadings } from "../../data-loader/toc";
|
||||
import {
|
||||
getPathsByExtension,
|
||||
getIndexedData,
|
||||
} from "../../data-loader/file-index";
|
||||
|
||||
// ------------------- Types (mirrors CLI) -------------------
|
||||
|
||||
|
|
@ -26,57 +34,128 @@ export interface JournalCompletions {
|
|||
links: LinkCompletion[];
|
||||
}
|
||||
|
||||
// ------------------- Cache -------------------
|
||||
export type CompletionsState =
|
||||
| { status: "loading" }
|
||||
| { status: "loaded"; data: JournalCompletions }
|
||||
| { status: "empty" }
|
||||
| { status: "error"; message: string };
|
||||
|
||||
let _cache: JournalCompletions = { dice: [], links: [] };
|
||||
let _loaded = false;
|
||||
// ------------------- Reactive state (module-level signal) -------------------
|
||||
|
||||
/** Thawed value signal; invalidate resets it. */
|
||||
const [completions, setCompletions] = createSignal<JournalCompletions>(_cache);
|
||||
const [completionsState, setCompletionsState] = createSignal<CompletionsState>({
|
||||
status: "loading",
|
||||
});
|
||||
|
||||
// ------------------- Fetch -------------------
|
||||
// ------------------- Fetch (CLI mode) -------------------
|
||||
|
||||
async function load(): Promise<void> {
|
||||
async function tryServer(): Promise<JournalCompletions | null> {
|
||||
try {
|
||||
const resp = await fetch("/__COMPLETIONS.json");
|
||||
if (resp.ok) {
|
||||
if (!resp.ok) return null;
|
||||
const data = await resp.json();
|
||||
_cache = {
|
||||
return {
|
||||
dice: Array.isArray(data.dice) ? data.dice : [],
|
||||
links: Array.isArray(data.links) ? data.links : [],
|
||||
};
|
||||
} else {
|
||||
_cache = { dice: [], links: [] };
|
||||
}
|
||||
} catch {
|
||||
_cache = { dice: [], links: [] };
|
||||
return null;
|
||||
}
|
||||
_loaded = true;
|
||||
setCompletions(_cache);
|
||||
}
|
||||
|
||||
/** Ensure data is loaded (lazy, idempotent). */
|
||||
let _promise: Promise<void> | null = null;
|
||||
export function ensureCompletions(): Promise<void> {
|
||||
if (!_promise) _promise = load();
|
||||
return _promise;
|
||||
// ------------------- Client-side fallback scan -------------------
|
||||
|
||||
async function scanClientSide(): Promise<JournalCompletions> {
|
||||
const paths = await getPathsByExtension("md");
|
||||
const dice: DiceCompletion[] = [];
|
||||
const links: LinkCompletion[] = [];
|
||||
const tagRegex = /<md-dice[^>]*>\s*([\s\S]*?)\s*<\/md-dice>/gi;
|
||||
|
||||
for (const filePath of paths) {
|
||||
const content = await getIndexedData(filePath);
|
||||
if (!content) continue;
|
||||
|
||||
// Dice scan
|
||||
let match: RegExpExecArray | null;
|
||||
tagRegex.lastIndex = 0;
|
||||
while ((match = tagRegex.exec(content)) !== null) {
|
||||
const raw = match[1].trim();
|
||||
if (!raw || raw.length > 80) continue;
|
||||
if (!/^\d*d\d+/i.test(raw) && !/^[+-]/.test(raw)) continue;
|
||||
dice.push({ label: raw, notation: raw, source: filePath });
|
||||
}
|
||||
|
||||
/** Force a re-fetch on next use. */
|
||||
export function invalidateCompletions(): void {
|
||||
_loaded = false;
|
||||
_promise = null;
|
||||
// Link scan (headings)
|
||||
const basePath = filePath.replace(/\.md$/, "");
|
||||
const fileName = basePath.split("/").filter(Boolean).pop() || basePath;
|
||||
links.push({ path: basePath, label: fileName, section: null });
|
||||
for (const heading of extractHeadings(content)) {
|
||||
links.push({
|
||||
path: basePath,
|
||||
label: `${fileName} § ${heading.title}`,
|
||||
section: heading.id ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------- Hook -------------------
|
||||
return { dice, links };
|
||||
}
|
||||
|
||||
// ------------------- Init (runs eagerly at import time) -------------------
|
||||
|
||||
// Using a top-level IIFE so the promise starts immediately
|
||||
const _initPromise: Promise<void> = (async () => {
|
||||
// 1. Try server first (CLI mode)
|
||||
const serverData = await tryServer();
|
||||
if (serverData) {
|
||||
setCompletionsState({ status: "loaded", data: serverData });
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Fall back to client-side scan (dev/browser mode)
|
||||
try {
|
||||
const data = await scanClientSide();
|
||||
if (data.dice.length > 0 || data.links.length > 0) {
|
||||
setCompletionsState({ status: "loaded", data });
|
||||
} else {
|
||||
setCompletionsState({ status: "empty" });
|
||||
}
|
||||
} catch (e) {
|
||||
setCompletionsState({
|
||||
status: "error",
|
||||
message: e instanceof Error ? e.message : "Failed to scan",
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
// ------------------- Public API -------------------
|
||||
|
||||
/**
|
||||
* Reactive completions data for the journal input.
|
||||
* Triggers a lazy fetch on first access; returns empty arrays while loading.
|
||||
* Returns a Promise that resolves once completions are loaded (or failed).
|
||||
* Useful for waiting before showing the completions dropdown.
|
||||
*/
|
||||
export function useJournalCompletions(): JournalCompletions {
|
||||
if (!_loaded) {
|
||||
void ensureCompletions();
|
||||
export function ensureCompletions(): Promise<void> {
|
||||
return _initPromise;
|
||||
}
|
||||
return completions();
|
||||
|
||||
/** Force a re-fetch on the next page load. For runtime, call before invalidate triggers. */
|
||||
let _invalidated = false;
|
||||
export function invalidateCompletions(): void {
|
||||
_invalidated = true;
|
||||
// On next import (page reload), the module will re-init.
|
||||
// For a runtime invalidation, you could call init again.
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactive hooks for the journal input.
|
||||
* Returns the current completions state + a convenience `data` extractor.
|
||||
*/
|
||||
export function useJournalCompletions(): {
|
||||
state: CompletionsState;
|
||||
data: JournalCompletions;
|
||||
} {
|
||||
const s = completionsState();
|
||||
if (s.status === "loaded") {
|
||||
return { state: s, data: s.data };
|
||||
}
|
||||
return { state: s, data: { dice: [], links: [] } };
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue