301e842c5c
- Consistency tab in EntitiesView surfacing findInconsistencies() results - ContextRail + MentionPopover wired into WritingView for entity context/hover - CommandPalette indexes entities and chapters for cross-entity search - link field type for entity references in character fields - findDeadCharacterMentions consistency check + tests - Expand seed with a location, note, and a natural Elias Vell mention so the dead-character-after-death case is actually exercised - Author docs/DATA_MODEL.md documenting core schemas, types, storage contract - Rename CharacterMention id attr -> entityId to match core backlinks/consistency
116 lines
3.7 KiB
TypeScript
116 lines
3.7 KiB
TypeScript
/**
|
|
* 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({ entityId: 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;
|