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:
Schwitzz
2026-10-25 22:42:55 -05:00
parent 81a44b6e95
commit 4efa71515e
36 changed files with 2764 additions and 82 deletions
+115
View File
@@ -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;