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.
This commit is contained in:
@@ -44,6 +44,10 @@ export { serializeToMarkdown, parseMarkdown, markdownInputRules, markdownPasteRu
|
||||
|
||||
export { throttle, createIdleDetector } from "./perf.js";
|
||||
|
||||
export { MentionAutocomplete, mentionPickerState, findMentionTrigger } from "./mention.js";
|
||||
export type { MentionEntity, MentionProvider, MentionPickerState } from "./mention.js";
|
||||
export { MentionPicker } from "./nodes/MentionPicker.js";
|
||||
|
||||
// Re-export the TipTap render surface and editor type so consumers of the
|
||||
// package never import TipTap directly.
|
||||
export { EditorContent } from "@tiptap/react";
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Mention autocomplete — the `@` trigger that opens an entity picker and
|
||||
* inserts a `CharacterMention` node.
|
||||
*
|
||||
* The editor package owns the low-level mechanics (detecting `@query`,
|
||||
* positioning the picker, inserting the mention atom). The *data* — which
|
||||
* entities to search — is injected by the app through a `MentionProvider`, so
|
||||
* this package never imports the app's entity store.
|
||||
*
|
||||
* The picker is a React component (`MentionPicker`) that reads a small
|
||||
* module-level `mentionPickerState` object shared with this extension. The
|
||||
* extension exposes an `insertMention` command; the picker calls it on Enter.
|
||||
*/
|
||||
import { Extension, type Editor, type RawCommands } from "@tiptap/core";
|
||||
import type { Node } from "@tiptap/pm/model";
|
||||
import { CHARACTER_MENTION } from "./constants.js";
|
||||
|
||||
/** A single entity option shown in the autocomplete picker. */
|
||||
export interface MentionEntity {
|
||||
id: string;
|
||||
label: string;
|
||||
/** Optional subtitle (role / one-line bio) shown under the name. */
|
||||
subtitle?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The search provider the app injects. Given a query, return matching
|
||||
* entities sorted by relevance (best first). An empty query should return the
|
||||
* full list (or a capped prefix) so the picker shows candidates immediately.
|
||||
*/
|
||||
export interface MentionProvider {
|
||||
search: (query: string) => MentionEntity[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared picker state between the extension and the React picker. Kept at module
|
||||
* scope because the TipTap extension is a singleton and the picker is rendered
|
||||
* by the app alongside the editor — there is no cleaner channel between them.
|
||||
*/
|
||||
export interface MentionPickerState {
|
||||
open: boolean;
|
||||
query: string;
|
||||
/** Doc position of the `@` trigger (where the mention will be inserted). */
|
||||
atPos: number;
|
||||
/** Cursor position (end of the query). */
|
||||
from: number;
|
||||
/** Currently highlighted result index. */
|
||||
selectedIndex: number;
|
||||
}
|
||||
|
||||
export const mentionPickerState: MentionPickerState = {
|
||||
open: false,
|
||||
query: "",
|
||||
atPos: 0,
|
||||
from: 0,
|
||||
selectedIndex: 0,
|
||||
};
|
||||
|
||||
/**
|
||||
* Walk backwards from `from` over the doc text looking for the `@` trigger.
|
||||
* Returns the trigger position, or `-1` when the text before the cursor is not
|
||||
* a live mention query (no `@`, or a space/newline interrupts it).
|
||||
*/
|
||||
export function findMentionTrigger(doc: Node, from: number): number {
|
||||
let atPos = from;
|
||||
while (atPos > 0) {
|
||||
const ch = doc.textBetween(atPos - 1, atPos) ?? "";
|
||||
if (ch === "@") return atPos - 1;
|
||||
if (ch === " " || ch === "\n" || ch === "\t") return -1;
|
||||
atPos -= 1;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* The autocomplete extension. Adds the `insertMention` command used by the
|
||||
* picker and (optionally) a keyboard shortcut to open the picker manually.
|
||||
*/
|
||||
export const MentionAutocomplete = Extension.create({
|
||||
name: "mentionAutocomplete",
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
/** The trigger character. */
|
||||
trigger: "@",
|
||||
};
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
/**
|
||||
* Insert a `CharacterMention` atom at the current trigger, replacing the
|
||||
* `@query` text between the trigger and the cursor.
|
||||
*/
|
||||
insertMention:
|
||||
(id: string, label: string) =>
|
||||
({ editor }: { editor: Editor }) => {
|
||||
const { state, schema } = editor;
|
||||
const { from } = state.selection;
|
||||
const atPos = findMentionTrigger(state.doc, from);
|
||||
if (atPos < 0) return false;
|
||||
|
||||
const nodeType = schema.nodes[CHARACTER_MENTION];
|
||||
if (!nodeType) return false;
|
||||
|
||||
const mention = nodeType.create({ id, label });
|
||||
const tr = state.tr.deleteRange(atPos, from).insert(atPos, mention);
|
||||
editor.view.dispatch(tr);
|
||||
return true;
|
||||
},
|
||||
} as unknown as Partial<RawCommands>;
|
||||
},
|
||||
});
|
||||
|
||||
export default MentionAutocomplete;
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* 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;
|
||||
@@ -23,6 +23,7 @@ import SceneBreakNode from "./nodes/SceneBreak.js";
|
||||
import FootnoteInline from "./nodes/FootnoteInline.js";
|
||||
import FootnoteArea from "./nodes/FootnoteArea.js";
|
||||
import CharacterMention from "./nodes/CharacterMention.js";
|
||||
import { MentionAutocomplete } from "./mention.js";
|
||||
|
||||
import {
|
||||
SCENE_BREAK,
|
||||
@@ -49,6 +50,7 @@ export const pubScriptExtensions = [
|
||||
FootnoteInline,
|
||||
FootnoteArea,
|
||||
CharacterMention,
|
||||
MentionAutocomplete,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user