Files
PubScript/packages/editor/src/nodes/MentionPicker.tsx
T
Schwitzz 4efa71515e feat(entity): Chunk #4 data layer + entity UI (characters & world-building)
Data layer:
- Fuzzy scorer (packages/core/src/fuzzy.ts) with index recovery + unit tests
- Backlink computation (packages/core/src/backlinks.ts) incl. entity-level backlinks
- Consistency checker (packages/core/src/consistency.ts) incl. reciprocity/scope + unit tests
- StorageAdapter contract extended with listEntities/searchEntities/saveEntity/deleteEntity;
  implemented in Local (Dexie) and Server adapters; contract tests cover entity CRUD + ?q= search
- WorldItem.fields now supports list values (keyPeople, rivals); expanded RelationshipKind
- @mention autocomplete extension + picker in the editor package

Entity UI (apps/web/src/features/entities/):
- Generic field renderer (EntityFields) driven by ENTITY_FIELDS/FieldDef
- Tabbed directory with fuzzy search + entity cards (EntitiesView)
- Create/edit/delete drawer (EntityEditor) reusing shared fields
- Relationship editor + force-directed SVG relationship map
- Entity engine stylesheet; wired into nav

Store: project-store entity methods (load/create/update/delete) over the StorageAdapter contract.

CI-green: typecheck, lint, 65 tests, and web build all pass.
2026-10-25 22:42:55 -05:00

183 lines
5.7 KiB
TypeScript

/**
* MentionPicker — the React surface for the `@` autocomplete.
*
* It watches the editor for a live `@query` (via `editor.on("update")`), runs
* the injected `MentionProvider.search`, and renders a floating list positioned
* at the cursor. Arrow keys move the highlight, Enter/Click insert the mention,
* Escape closes. The actual insertion goes through the editor's `insertMention`
* command (see `mention.ts`).
*
* Render this once, next to `<EditorContent>`, passing the editor and a
* provider. The provider is supplied by the app so this package never touches
* the entity store directly.
*/
import { useEffect, useRef, useState } from "react";
import type { Editor } from "@tiptap/core";
import { mentionPickerState, findMentionTrigger, type MentionProvider } from "../mention.js";
interface MentionPickerProps {
editor: Editor | null;
provider: MentionProvider | null;
}
interface Candidate {
id: string;
label: string;
subtitle?: string;
}
// The `insertMention` command is custom (not in TipTap's RawCommands), so we
// reach it through a typed cast.
type InsertMention = (id: string, label: string) => boolean;
export function MentionPicker({ editor, provider }: MentionPickerProps) {
const [open, setOpen] = useState(false);
const [candidates, setCandidates] = useState<Candidate[]>([]);
const [selectedIndex, setSelectedIndex] = useState(0);
const [pos, setPos] = useState({ x: 0, y: 0 });
const listRef = useRef<HTMLUListElement>(null);
// Detect `@query` on every editor update and drive the picker state.
useEffect(() => {
if (!editor || !provider) return;
const onUpdate = () => {
const { from } = editor.state.selection;
const atPos = findMentionTrigger(editor.state.doc, from);
if (atPos < 0) {
setOpen(false);
mentionPickerState.open = false;
return;
}
const queryText = editor.state.doc.textBetween(atPos + 1, from) ?? "";
const coords = editor.view.coordsAtPos(from);
setPos({ x: coords.left, y: coords.bottom });
setOpen(true);
mentionPickerState.open = true;
mentionPickerState.query = queryText;
mentionPickerState.atPos = atPos;
mentionPickerState.from = from;
mentionPickerState.selectedIndex = 0;
const matches = provider.search(queryText);
setCandidates(matches);
setSelectedIndex(0);
};
editor.on("update", onUpdate);
return () => {
editor.off("update", onUpdate);
};
}, [editor, provider]);
// Auto-scroll the highlighted item into view.
useEffect(() => {
listRef.current?.children[selectedIndex]?.scrollIntoView({ block: "nearest" });
}, [selectedIndex]);
// Keep focus on the list so keyboard events are captured while open.
useEffect(() => {
if (open) listRef.current?.focus();
}, [open]);
const insert = (c: Candidate) => {
const cmd = (editor?.commands as unknown as { insertMention: InsertMention }).insertMention;
if (cmd) cmd(c.id, c.label);
setOpen(false);
mentionPickerState.open = false;
};
const onKeyDown = (e: React.KeyboardEvent) => {
e.stopPropagation();
if (e.key === "ArrowDown") {
e.preventDefault();
setSelectedIndex((i) => (i + 1) % Math.max(1, candidates.length));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setSelectedIndex((i) => (i - 1 + candidates.length) % Math.max(1, candidates.length));
} else if (e.key === "Enter") {
e.preventDefault();
const sel = candidates[selectedIndex];
if (sel) insert(sel);
} else if (e.key === "Escape") {
e.preventDefault();
setOpen(false);
mentionPickerState.open = false;
}
};
if (!open || !provider) return null;
return (
<div
className="ps-mention-picker"
style={{
position: "fixed",
left: pos.x,
top: pos.y,
zIndex: 1000,
minWidth: 220,
maxWidth: 320,
maxHeight: 280,
overflowY: "auto",
boxSizing: "border-box",
background: "var(--ps-surface, #fff)",
color: "var(--ps-text, #1a1a1a)",
border: "1px solid var(--ps-border, #d8d8d8)",
borderRadius: 8,
boxShadow: "0 6px 20px rgba(0,0,0,0.15)",
}}
onKeyDown={onKeyDown}
>
<ul
ref={listRef}
tabIndex={-1}
style={{ listStyle: "none", margin: 0, padding: 4, outline: "none" }}
>
{candidates.length === 0 ? (
<li className="ps-mention-empty" style={{ padding: "10px 12px", color: "var(--ps-muted, #888)" }}>
No matches
</li>
) : (
candidates.map((c, i) => (
<li
key={c.id}
className={`ps-mention-item${i === selectedIndex ? " is-selected" : ""}`}
onMouseDown={(e) => {
e.preventDefault();
insert(c);
}}
onMouseEnter={() => setSelectedIndex(i)}
style={{
padding: "8px 12px",
borderRadius: 6,
cursor: "pointer",
background: i === selectedIndex ? "var(--ps-accent, #0f3d3e)" : "transparent",
color: i === selectedIndex ? "#fff" : "inherit",
}}
>
<div style={{ fontWeight: 600 }}>{c.label}</div>
{c.subtitle && (
<div
style={{
fontSize: 12,
opacity: i === selectedIndex ? 0.9 : 0.65,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{c.subtitle}
</div>
)}
</li>
))
)}
</ul>
</div>
);
}
export default MentionPicker;