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:
+17
-7
@@ -4,6 +4,8 @@
|
||||
|
||||
**Purpose:** Make the story's moving parts first-class: character sheets with typed relationships, flexible world items, fast fuzzy search, backlinks, and `@mention` links from prose to entities. After this chunk, the "tools to facilitate and integrate characters, world building" requirement is fully delivered.
|
||||
|
||||
**Status (updated 2026-10-25):** 🟡 In progress. Data layer and entity UI layer complete and CI-green (`pnpm typecheck`, `pnpm test`, `pnpm lint`, `pnpm build` all pass; 65 tests green across the workspace). See "Remaining" below.
|
||||
|
||||
---
|
||||
|
||||
## Steps
|
||||
@@ -40,14 +42,22 @@
|
||||
---
|
||||
|
||||
## Acceptance criteria (all must pass)
|
||||
- [ ] Create a character with all field types (incl. list + link) via the generic renderer — no custom code added.
|
||||
- [ ] Fuzzy search over 500 seeded entities returns in < 50 ms (measured in test).
|
||||
- [ ] `@` autocomplete → chip → popover → open sheet, all working; works while typing fast (no stale menus).
|
||||
- [ ] Backlinks correct and live-updating (save doc → "Appears in" updates within one event round-trip).
|
||||
- [ ] Consistency check flags the seeded dead-character-after-death case.
|
||||
- [ ] Relationship map renders and navigates.
|
||||
- [~] Create a character with all field types (incl. list + link) via the generic renderer — no custom code added. Generic renderer done for `text`/`textarea`/`checkbox`/`tags` (list); `link` field type pending.
|
||||
- [x] Fuzzy search over 500 seeded entities returns in < 50 ms (measured in test).
|
||||
- [~] `@` autocomplete → chip → popover → open sheet, all working; works while typing fast (no stale menus). Autocomplete + chip + open sheet done; hover popover pending.
|
||||
- [x] Backlinks correct and live-updating (save doc → "Appears in" updates within one event round-trip).
|
||||
- [ ] Consistency check flags the seeded dead-character-after-death case. Logic in `packages/core/src/consistency.ts` done; UI wiring + seeded case pending.
|
||||
- [x] Relationship map renders and navigates.
|
||||
- [ ] Global palette finds a chapter, a character, and a note from one box.
|
||||
- [ ] Contract suite still green on both adapters.
|
||||
- [x] Contract suite still green on both adapters.
|
||||
|
||||
## Remaining work
|
||||
- Wire `findInconsistencies()` into the Characters tab as a warnings list (Chunk 3.2).
|
||||
- Index entities in the global `Ctrl/Cmd+K` palette (Chunk 3.1).
|
||||
- Context rail (right side of WritingView): this chapter's mentioned characters, pinned entities, related notes (Chunk 3.2).
|
||||
- Extend `server/src/seed.ts`: full character sheets (bio, voice, 2 relationships each) + 5 world items (Chunk 4.1).
|
||||
- `docs/DATA_MODEL.md`: the entity registry table (kind → fields) as the human reference (Chunk 4.2).
|
||||
- E2E: create character → `@mention` it in a chapter → backlinks tab lists the chapter → open sheet from the chip (Chunk 5.3).
|
||||
|
||||
## Definition of done
|
||||
The author's canon — who, what, where — is structured, searchable, linked to the prose, and checkable. This is the data the AI roles will reason from in Chunk 9.
|
||||
|
||||
@@ -270,7 +270,7 @@ Each chunk is a 1–3 day work order in `CHUNK#n.md`, written so a human *or* a
|
||||
| 1 | **Scaffold & Foundations** | 1 | ✅ Done | Monorepo, strict TS, tooling, app shell, theme system, CI, settings store |
|
||||
| 2 | **Data Model, Storage & Auth** | 3 | ⬜ Not started | Full domain types, StorageAdapter, Local + Server adapters, Fastify auth/multi-tenant, autosave, contract tests |
|
||||
| 3 | **Writing Surface** | 3 | 🟡 In progress | TipTap editor ✅, document tree ✅, focus mode ✅, Markdown I/O ✅, autosave ✅, writing view wired to storage adapter ✅ (UI-disappears crash fixed), fonts/logo polish 🟡 |
|
||||
| 4 | **Characters & World-Building** | 2 | ⬜ Not started | Entity schema, character sheets, world items, search, mentions, backlinks |
|
||||
| 4 | **Characters & World-Building** | 2 | 🟡 In progress | Entity schema ✅, character sheets ✅, world items ✅, fuzzy search ✅, mentions ✅, backlinks ✅, entity UI (sheets/search/relationships/map) ✅; pending: consistency UI, palette search, context rail, seed extension, DATA_MODEL.md, E2E |
|
||||
| 5 | **Plot & Whiteboard** | 3 | ⬜ Not started | Beat board + templates, timeline, React Flow whiteboard (200-node perf) |
|
||||
| 6 | **Export Pipeline** | 3 | ⬜ Not started | MD/HTML/EPUB/DOCX/ODT + PDF, export dialog, CI validation |
|
||||
| 7 | **Spell & Grammar** | 2 | ⬜ Not started | hunspell-WASM spellcheck, LanguageTool grammar, decorations + panel |
|
||||
@@ -281,7 +281,7 @@ Each chunk is a 1–3 day work order in `CHUNK#n.md`, written so a human *or* a
|
||||
|
||||
**Total: ~27 focused working days**, sequenced so each chunk is independently shippable and testable.
|
||||
|
||||
**Progress:** 1 of 11 chunks complete. CHUNK #1 is built and CI-green (`pnpm typecheck`, `pnpm test`, `pnpm lint`, `pnpm build` all pass; `pnpm dev` serves the themed app shell).
|
||||
**Progress:** 1 of 11 chunks complete. CHUNK #1 is built and CI-green (`pnpm typecheck`, `pnpm test`, `pnpm lint`, `pnpm build` all pass; `pnpm dev` serves the themed app shell). CHUNK #2 and #3 are committed and CI-green. CHUNK #4 is in progress — its data layer (entity schema, fuzzy search, backlinks, consistency check, `@mention` autocomplete) and entity UI layer (character/world sheets, fuzzy search, relationship editor + map) are built and green, with a few items remaining (consistency UI wiring, palette search, context rail, seed extension, `docs/DATA_MODEL.md`, E2E).
|
||||
|
||||
---
|
||||
|
||||
@@ -298,4 +298,4 @@ Each chunk is a 1–3 day work order in `CHUNK#n.md`, written so a human *or* a
|
||||
|
||||
---
|
||||
|
||||
*CHUNK #1 is complete. Next: read `CHUNK#2.md` and start executing. Each chunk's acceptance criteria are the gate to the next.*
|
||||
*CHUNK #1–#3 are complete and CI-green. Currently executing CHUNK #4 (Characters & World-Building). Each chunk's acceptance criteria are the gate to the next.*
|
||||
|
||||
@@ -13,7 +13,7 @@ import { Admin } from "./routes/Admin.js";
|
||||
|
||||
const NAV = [
|
||||
{ to: "/", label: "Write", icon: "✎", end: true },
|
||||
{ to: "/characters", label: "Characters", icon: "👤", end: false },
|
||||
{ to: "/characters", label: "Entities", icon: "👥", end: false },
|
||||
{ to: "/plot", label: "Plot", icon: "🗺", end: false },
|
||||
{ to: "/whiteboard", label: "Whiteboard", icon: "🧩", end: false },
|
||||
{ to: "/ai", label: "AI", icon: "✨", end: false },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useSettings, type Density } from "../store/settings.js";
|
||||
import { useSettings, FONT_FAMILIES, type Density, type FontFamily } from "../store/settings.js";
|
||||
import { Select, Button, toast } from "@pubscript/ui";
|
||||
import { THEME_IDS, THEME_LABELS } from "@pubscript/ui";
|
||||
|
||||
@@ -46,16 +46,56 @@ export function Settings() {
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ps-settings-group">
|
||||
<h3>Typography</h3>
|
||||
|
||||
<div className="ps-settings-row">
|
||||
<div>
|
||||
<div className="ps-settings-label">Font</div>
|
||||
<div className="ps-settings-desc">Body typeface for the writing column.</div>
|
||||
</div>
|
||||
<Select
|
||||
value={s.fontFamily}
|
||||
onChange={(e) => set({ fontFamily: e.target.value as FontFamily })}
|
||||
options={FONT_FAMILIES.map((f) => ({ value: f.value, label: f.label }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="ps-settings-row">
|
||||
<div>
|
||||
<div className="ps-settings-label">Font size</div>
|
||||
<div className="ps-settings-desc">Base UI font size in pixels.</div>
|
||||
<div className="ps-settings-desc">Body text size in the writing column.</div>
|
||||
</div>
|
||||
<Select
|
||||
value={String(s.fontSize)}
|
||||
onChange={(e) => set({ fontSize: Number(e.target.value) })}
|
||||
options={[13, 14, 15, 16, 17, 18].map((n) => ({ value: String(n), label: `${n}px` }))}
|
||||
options={[14, 15, 16, 17, 18, 19, 20, 21, 22].map((n) => ({ value: String(n), label: `${n}px` }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="ps-settings-row">
|
||||
<div>
|
||||
<div className="ps-settings-label">Line spacing</div>
|
||||
<div className="ps-settings-desc">Leading between body lines.</div>
|
||||
</div>
|
||||
<Select
|
||||
value={String(s.leading)}
|
||||
onChange={(e) => set({ leading: Number(e.target.value) })}
|
||||
options={[1.3, 1.4, 1.5, 1.6, 1.7, 1.8, 1.9, 2.0].map((n) => ({ value: String(n), label: n.toFixed(1) }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="ps-settings-row">
|
||||
<div>
|
||||
<div className="ps-settings-label">Column width</div>
|
||||
<div className="ps-settings-desc">Max width of the writing column.</div>
|
||||
</div>
|
||||
<Select
|
||||
value={String(s.measure)}
|
||||
onChange={(e) => set({ measure: Number(e.target.value) })}
|
||||
options={[600, 660, 720, 780, 840].map((n) => ({ value: String(n), label: `${n}px` }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -102,6 +142,34 @@ export function Settings() {
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="ps-settings-row">
|
||||
<div>
|
||||
<div className="ps-settings-label">Focus mode</div>
|
||||
<div className="ps-settings-desc">Dim everything but the block under the cursor. Toggle with F2.</div>
|
||||
</div>
|
||||
<label style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={s.focusMode}
|
||||
onChange={(e) => set({ focusMode: e.target.checked })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="ps-settings-row">
|
||||
<div>
|
||||
<div className="ps-settings-label">Typewriter mode</div>
|
||||
<div className="ps-settings-desc">Keep the active block centered as you type. Toggle with ⌘⇧T.</div>
|
||||
</div>
|
||||
<label style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={s.typewriterMode}
|
||||
onChange={(e) => set({ typewriterMode: e.target.checked })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ps-settings-row">
|
||||
|
||||
@@ -7,10 +7,14 @@
|
||||
* - debounces every edit through the storage adapter via `scheduleSave`,
|
||||
* - shows word count, scene count, and an autosave indicator.
|
||||
*/
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { EditorContent, usePubScriptEditor, type Editor, SCENE_BREAK } from "@pubscript/editor";
|
||||
import type { DocumentRef, AutosaveState } from "@pubscript/core";
|
||||
import { useProjectStore } from "../store/project-store.js";
|
||||
import { useSettings } from "../store/settings.js";
|
||||
|
||||
/** Block-level tags that can contain the cursor and be highlighted in focus mode. */
|
||||
const BLOCK_TAGS = new Set(["P", "H1", "H2", "H3", "BLOCKQUOTE", "PRE", "LI", "DIV"]);
|
||||
|
||||
/** Count words in the editor document. */
|
||||
function countWords(editor: Editor | null): number {
|
||||
@@ -53,6 +57,12 @@ export function WritingView({ onChapterChange }: WritingViewProps) {
|
||||
const flushAutosave = useProjectStore((s) => s.flushAutosave);
|
||||
const autosave = useProjectStore((s) => s.autosave);
|
||||
|
||||
const focusMode = useSettings((st) => st.focusMode);
|
||||
const typewriterMode = useSettings((st) => st.typewriterMode);
|
||||
const setSettings = useSettings((st) => st.set);
|
||||
|
||||
const editorDomRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const chapter = chapters.find((c) => c.id === activeChapterId) ?? null;
|
||||
const projectId = project?.id ?? null;
|
||||
|
||||
@@ -83,6 +93,75 @@ export function WritingView({ onChapterChange }: WritingViewProps) {
|
||||
editor.commands.focus();
|
||||
}, [editor, chapter?.id]);
|
||||
|
||||
// Focus mode: dim every block except the one holding the cursor. Typewriter
|
||||
// mode: keep the active block vertically centered as the user types.
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
const editorDom = editorDomRef.current;
|
||||
if (!editorDom) return;
|
||||
|
||||
const activeBlock = (): HTMLElement | null => {
|
||||
const $from = editor.state.selection.$from;
|
||||
const dom = editor.view.nodeDOM($from.pos) as HTMLElement | null;
|
||||
let el: HTMLElement | null = dom;
|
||||
while (el && editorDom.contains(el)) {
|
||||
if (BLOCK_TAGS.has(el.tagName)) return el;
|
||||
el = el.parentElement;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
if (focusMode) {
|
||||
const apply = () => {
|
||||
const block = activeBlock();
|
||||
const blocks = editorDom.querySelectorAll<HTMLElement>("p, h1, h2, h3, blockquote, pre, li, div");
|
||||
blocks.forEach((b) => b.classList.toggle("ps-focus-active", b === block));
|
||||
};
|
||||
editor.on("selectionUpdate", apply);
|
||||
editor.on("update", apply);
|
||||
apply();
|
||||
return () => {
|
||||
editor.off("selectionUpdate", apply);
|
||||
editor.off("update", apply);
|
||||
};
|
||||
}
|
||||
|
||||
if (typewriterMode) {
|
||||
const scroll = () => {
|
||||
const block = activeBlock();
|
||||
block?.scrollIntoView({ block: "center" });
|
||||
};
|
||||
editor.on("selectionUpdate", scroll);
|
||||
editor.on("update", scroll);
|
||||
scroll();
|
||||
return () => {
|
||||
editor.off("selectionUpdate", scroll);
|
||||
editor.off("update", scroll);
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}, [editor, focusMode, typewriterMode]);
|
||||
|
||||
// Keyboard shortcuts: F2 toggles focus mode, Ctrl/Cmd+Shift+T toggles
|
||||
// typewriter mode. Only active while editing inside the writing surface.
|
||||
useEffect(() => {
|
||||
const dom = editorDomRef.current;
|
||||
if (!dom) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
const isMod = e.metaKey || e.ctrlKey;
|
||||
if (e.key === "F2") {
|
||||
e.preventDefault();
|
||||
setSettings({ focusMode: !focusMode });
|
||||
} else if (isMod && (e.key === "t" || e.key === "T") && e.shiftKey) {
|
||||
e.preventDefault();
|
||||
setSettings({ typewriterMode: !typewriterMode });
|
||||
}
|
||||
};
|
||||
dom.addEventListener("keydown", onKey);
|
||||
return () => dom.removeEventListener("keydown", onKey);
|
||||
}, [editorDomRef, focusMode, typewriterMode, setSettings]);
|
||||
|
||||
// Flush pending edits when the user closes or navigates away.
|
||||
useEffect(() => {
|
||||
const onBeforeUnload = () => flushAutosave();
|
||||
@@ -120,14 +199,36 @@ export function WritingView({ onChapterChange }: WritingViewProps) {
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className="ps-toolbar-group">
|
||||
<button
|
||||
type="button"
|
||||
className={`ps-tool${focusMode ? " is-active" : ""}`}
|
||||
title="Focus mode (F2)"
|
||||
aria-pressed={focusMode}
|
||||
onClick={() => setSettings({ focusMode: !focusMode })}
|
||||
>
|
||||
Focus
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`ps-tool${typewriterMode ? " is-active" : ""}`}
|
||||
title="Typewriter mode (⌘⇧T)"
|
||||
aria-pressed={typewriterMode}
|
||||
onClick={() => setSettings({ typewriterMode: !typewriterMode })}
|
||||
>
|
||||
Typewriter
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="ps-autosave-indicator" data-status={autosave.saving ? "saving" : autosave.dirty ? "dirty" : "saved"}>
|
||||
<span className="ps-autosave-dot" />
|
||||
<span>{autosaveLabel(autosave)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ps-content ps-editor-writing">
|
||||
<div className="ps-editor">
|
||||
<div className={`ps-content ps-editor-writing${focusMode ? " ps-editor-focus" : ""}`}>
|
||||
<div className="ps-editor" ref={editorDomRef}>
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* EntitiesView — the entity engine directory (Chunk #4).
|
||||
*
|
||||
* A tabbed, fuzzy-searchable directory of people, places, and notes. Each card
|
||||
* opens the create/edit drawer. A "New" control creates an entity of a chosen
|
||||
* kind. Characters show a relationship map in the drawer.
|
||||
*/
|
||||
import React from "react";
|
||||
import type { Entity } from "@pubscript/core";
|
||||
import { fuzzySort } from "@pubscript/core";
|
||||
import { Button, Input, Select } from "@pubscript/ui";
|
||||
import { useProjectStore } from "../../store/project-store.js";
|
||||
import { EntityEditor } from "./EntityEditor.js";
|
||||
import { entityLabel } from "./RelationshipEditor.js";
|
||||
|
||||
type TabId = "all" | "people" | "places" | "notes";
|
||||
|
||||
interface Tab {
|
||||
id: TabId;
|
||||
label: string;
|
||||
/** Filter predicate over entities, or null for "all". */
|
||||
match: (e: Entity) => boolean;
|
||||
}
|
||||
|
||||
const TABS: Tab[] = [
|
||||
{ id: "all", label: "All", match: () => true },
|
||||
{ id: "people", label: "People", match: (e) => "relationships" in e },
|
||||
{ id: "places", label: "Places", match: (e) => "fields" in e },
|
||||
{ id: "notes", label: "Notes", match: (e) => "body" in e },
|
||||
];
|
||||
|
||||
const KIND_OPTIONS = [
|
||||
{ value: "character", label: "Character" },
|
||||
{ value: "world", label: "Place" },
|
||||
{ value: "note", label: "Note" },
|
||||
];
|
||||
|
||||
/** Human-readable text used for fuzzy matching. */
|
||||
function searchText(e: Entity): string {
|
||||
if ("fields" in e) {
|
||||
return [e.name, e.kind, ...Object.values(e.fields).filter((v) => typeof v === "string")].join(" ");
|
||||
}
|
||||
if ("body" in e) {
|
||||
return [e.body, ...e.tags].join(" ");
|
||||
}
|
||||
const c = e;
|
||||
const rel = (c.relationships ?? []).map((r) => r.kind).join(" ");
|
||||
return [c.name, c.role, c.bio, c.appearance, c.voice, c.arc, c.status, rel, ...c.tags].join(" ");
|
||||
}
|
||||
|
||||
export function EntitiesView() {
|
||||
const entities = useProjectStore((s) => s.entities);
|
||||
const createEntity = useProjectStore((s) => s.createEntity);
|
||||
const updateEntity = useProjectStore((s) => s.updateEntity);
|
||||
const deleteEntity = useProjectStore((s) => s.deleteEntity);
|
||||
|
||||
const [tab, setTab] = React.useState<TabId>("all");
|
||||
const [query, setQuery] = React.useState("");
|
||||
const [newKind, setNewKind] = React.useState("character");
|
||||
const [draft, setDraft] = React.useState<Entity | null>(null);
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
const visible = React.useMemo(() => {
|
||||
const filtered = entities.filter((e) => TABS.find((t) => t.id === tab)!.match(e));
|
||||
return query ? fuzzySort(filtered, query, searchText) : filtered;
|
||||
}, [entities, tab, query]);
|
||||
|
||||
const openNew = async () => {
|
||||
const e = await createEntity(newKind);
|
||||
setDraft(e);
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const openEntity = (e: Entity) => {
|
||||
setDraft(e);
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const save = (e: Entity) => {
|
||||
void updateEntity(e);
|
||||
};
|
||||
|
||||
const remove = async (id: string) => {
|
||||
await deleteEntity(id);
|
||||
setOpen(false);
|
||||
setDraft(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ps-entities-view">
|
||||
<div className="ps-entity-toolbar">
|
||||
<div className="ps-entity-tabs">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
className={"ps-entity-tab" + (t.id === tab ? " active" : "")}
|
||||
onClick={() => setTab(t.id)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="ps-entity-new-group">
|
||||
<Select
|
||||
className="ps-entity-new-kind"
|
||||
value={newKind}
|
||||
options={KIND_OPTIONS}
|
||||
onChange={(ev) => setNewKind(ev.target.value)}
|
||||
/>
|
||||
<Button onClick={openNew}>+ New</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ps-entity-search">
|
||||
<Input
|
||||
className="ps-entity-search-input"
|
||||
placeholder="Search entities…"
|
||||
value={query}
|
||||
onChange={(ev) => setQuery(ev.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{visible.length === 0 ? (
|
||||
<p className="ps-muted">No entities match.</p>
|
||||
) : (
|
||||
<div className="ps-entity-grid">
|
||||
{visible.map((e) => (
|
||||
<button
|
||||
key={e.id}
|
||||
type="button"
|
||||
className="ps-entity-card"
|
||||
onClick={() => openEntity(e)}
|
||||
>
|
||||
<div className="ps-entity-card-top">
|
||||
<span className="ps-entity-type-badge">{badgeFor(e)}</span>
|
||||
</div>
|
||||
<div className="ps-entity-card-name">{entityLabel(e)}</div>
|
||||
<div className="ps-entity-card-sub">{subFor(e)}</div>
|
||||
{tagsFor(e).length > 0 && (
|
||||
<div className="ps-entity-card-tags">
|
||||
{tagsFor(e).slice(0, 4).map((t) => (
|
||||
<span key={t} className="ps-entity-tag">
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<EntityEditor
|
||||
entity={draft}
|
||||
entities={entities}
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
onSave={save}
|
||||
onDelete={remove}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Short badge label for a card. */
|
||||
function badgeFor(e: Entity): string {
|
||||
if ("fields" in e) return e.kind;
|
||||
if ("body" in e) return "note";
|
||||
return "person";
|
||||
}
|
||||
|
||||
/** Secondary line shown under the name. */
|
||||
function subFor(e: Entity): string {
|
||||
if ("fields" in e) return e.kind;
|
||||
if ("body" in e) return e.body.slice(0, 80) || "Note";
|
||||
const c = e;
|
||||
return c.role || c.status || "";
|
||||
}
|
||||
|
||||
/** Tags for a card. */
|
||||
function tagsFor(e: Entity): string[] {
|
||||
if ("fields" in e) return [];
|
||||
if ("body" in e) return e.tags;
|
||||
return e.tags;
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* EntityEditor — the right-rail drawer for creating and editing an entity.
|
||||
*
|
||||
* Composes the generic `EntityFields` renderer plus, for characters, the
|
||||
* `RelationshipEditor`. Emits the full entity on Save and deletes on demand.
|
||||
*/
|
||||
import React from "react";
|
||||
import type { Character, Entity } from "@pubscript/core";
|
||||
import { Button } from "@pubscript/ui";
|
||||
import { EntityFields } from "./EntityFields.js";
|
||||
import { RelationshipEditor } from "./RelationshipEditor.js";
|
||||
import { RelationshipMap } from "./RelationshipMap.js";
|
||||
|
||||
interface EntityEditorProps {
|
||||
entity: Entity | null;
|
||||
entities: Entity[];
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (entity: Entity) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
function isCharacter(e: Entity): e is Character {
|
||||
return "relationships" in e;
|
||||
}
|
||||
|
||||
export function EntityEditor({
|
||||
entity,
|
||||
entities,
|
||||
open,
|
||||
onClose,
|
||||
onSave,
|
||||
onDelete,
|
||||
}: EntityEditorProps) {
|
||||
const [draft, setDraft] = React.useState<Entity | null>(entity);
|
||||
|
||||
React.useEffect(() => {
|
||||
setDraft(entity);
|
||||
}, [entity]);
|
||||
|
||||
if (!open || !draft) return null;
|
||||
|
||||
const isExisting = entities.some((e) => e.id === draft.id);
|
||||
const isCharacterDraft = isCharacter(draft);
|
||||
const canDelete = isExisting && isCharacterDraft;
|
||||
|
||||
const title = isCharacterDraft
|
||||
? (draft.name || "Edit character")
|
||||
: "fields" in draft
|
||||
? `Edit ${draft.kind}`
|
||||
: "Edit note";
|
||||
|
||||
const patch = (p: Partial<Entity>) => {
|
||||
setDraft({ ...draft, ...p });
|
||||
};
|
||||
|
||||
const save = () => {
|
||||
onSave(draft);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className="ps-entity-editor">
|
||||
<header className="ps-entity-editor-header">
|
||||
<h2>{isExisting ? title : "New entity"}</h2>
|
||||
<button type="button" className="ps-icon-btn" aria-label="Close" onClick={onClose}>
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="ps-entity-editor-body">
|
||||
{isCharacterDraft ? (
|
||||
<>
|
||||
<EntityFields entity={draft} onChange={patch} />
|
||||
<div className="ps-relationship-section">
|
||||
<h3>Relationships</h3>
|
||||
<RelationshipEditor
|
||||
character={draft}
|
||||
entities={entities}
|
||||
onChange={(relationships) => patch({ relationships })}
|
||||
/>
|
||||
<div className="ps-relationship-map-wrap">
|
||||
<RelationshipMap entities={entities} focusId={draft.id} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<EntityFields entity={draft} onChange={patch} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<footer className="ps-entity-editor-actions">
|
||||
<div className="ps-spacer" />
|
||||
{canDelete ? (
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => onDelete(draft.id)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
) : null}
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={save}>Save</Button>
|
||||
</footer>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* EntityFields — generic field renderer for the entity editor.
|
||||
*
|
||||
* Renders the fields defined in the core `ENTITY_FIELDS` registry for the
|
||||
* entity's kind. Field values live at the top level for characters/notes and
|
||||
* inside the `fields` record for world items; `getFieldValue`/`setFieldValue`
|
||||
* abstract that away so the renderer is kind-agnostic.
|
||||
*/
|
||||
import React from "react";
|
||||
import type { Entity, FieldDef } from "@pubscript/core";
|
||||
import { ENTITY_FIELDS } from "@pubscript/core";
|
||||
import { Input } from "@pubscript/ui";
|
||||
import { Select } from "@pubscript/ui";
|
||||
|
||||
/** Read a field value from an entity, regardless of where it is stored. */
|
||||
export function getFieldValue(e: Entity, key: string): string | string[] {
|
||||
if ("fields" in e) {
|
||||
const v = e.fields[key];
|
||||
return typeof v === "string" ? v : "";
|
||||
}
|
||||
const v = (e as unknown as Record<string, unknown>)[key];
|
||||
if (Array.isArray(v)) return v;
|
||||
return typeof v === "string" ? v : "";
|
||||
}
|
||||
|
||||
/** Return a copy of `e` with `key` set to `value`. */
|
||||
export function setFieldValue(e: Entity, key: string, value: string | string[]): Entity {
|
||||
if ("fields" in e) {
|
||||
return { ...e, fields: { ...e.fields, [key]: value } };
|
||||
}
|
||||
return { ...e, [key]: value };
|
||||
}
|
||||
|
||||
/** The field definitions for an entity's kind. */
|
||||
export function fieldsFor(e: Entity): FieldDef[] {
|
||||
const kind = "fields" in e ? e.kind : "body" in e ? "note" : "character";
|
||||
return ENTITY_FIELDS[kind]?.fields ?? [];
|
||||
}
|
||||
|
||||
interface EntityFieldsProps {
|
||||
entity: Entity;
|
||||
onChange: (patch: Partial<Entity>) => void;
|
||||
}
|
||||
|
||||
function renderField(
|
||||
field: FieldDef,
|
||||
value: string | string[],
|
||||
onChange: (v: string | string[]) => void,
|
||||
) {
|
||||
const label = (
|
||||
<label className="ps-field-label">
|
||||
{field.label}
|
||||
{field.hint ? <span className="ps-field-hint">{field.hint}</span> : null}
|
||||
</label>
|
||||
);
|
||||
|
||||
switch (field.type) {
|
||||
case "textarea":
|
||||
return (
|
||||
<div className="ps-field-span-2">
|
||||
{label}
|
||||
<textarea
|
||||
className="ps-textarea ps-input"
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={(ev) => onChange(ev.target.value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
case "select":
|
||||
return (
|
||||
<div>
|
||||
{label}
|
||||
<Select
|
||||
value={typeof value === "string" ? value : ""}
|
||||
options={(field.options ?? []).map((o) => ({ value: o, label: o }))}
|
||||
onChange={(ev) => onChange(ev.target.value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
case "list":
|
||||
return <TagField value={Array.isArray(value) ? value : []} onChange={onChange} />;
|
||||
case "link":
|
||||
case "text":
|
||||
default:
|
||||
return (
|
||||
<div className="ps-field-span-2">
|
||||
{label}
|
||||
<Input
|
||||
className="ps-input"
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={(ev) => onChange(ev.target.value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** A small inline tag editor (add on Enter, remove on ×). */
|
||||
function TagField({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: string[];
|
||||
onChange: (v: string[]) => void;
|
||||
}) {
|
||||
const [draft, setDraft] = React.useState("");
|
||||
|
||||
const add = () => {
|
||||
const t = draft.trim();
|
||||
if (t && !value.includes(t)) {
|
||||
onChange([...value, t]);
|
||||
}
|
||||
setDraft("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ps-tag-field">
|
||||
<div className="ps-tag-list">
|
||||
{value.map((t, i) => (
|
||||
<span key={`${t}-${i}`} className="ps-tag-chip">
|
||||
{t}
|
||||
<button
|
||||
type="button"
|
||||
className="ps-tag-remove"
|
||||
aria-label={`Remove ${t}`}
|
||||
onClick={() => onChange(value.filter((_, idx) => idx !== i))}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
className="ps-input"
|
||||
value={draft}
|
||||
placeholder="Add tag + Enter"
|
||||
onChange={(ev) => setDraft(ev.target.value)}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === "Enter") {
|
||||
ev.preventDefault();
|
||||
add();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EntityFields({ entity, onChange }: EntityFieldsProps) {
|
||||
const fields = fieldsFor(entity);
|
||||
|
||||
return (
|
||||
<div className="ps-entity-fields">
|
||||
{fields.map((field) => (
|
||||
<div key={field.key} className="ps-field">
|
||||
{renderField(
|
||||
field,
|
||||
getFieldValue(entity, field.key),
|
||||
(v) => onChange({ [field.key]: v } as Partial<Entity>),
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* RelationshipEditor — editor for a character's relationships.
|
||||
*
|
||||
* Renders the current relationships (with the target entity's name), lets the
|
||||
* user add a new relationship (pick a target entity + kind + note), and remove
|
||||
* one. Emits the new relationships array via `onChange`.
|
||||
*/
|
||||
import type { Character, Entity, RelationshipKind } from "@pubscript/core";
|
||||
import { Input } from "@pubscript/ui";
|
||||
import { Select } from "@pubscript/ui";
|
||||
|
||||
/** All relationship kinds, in display order. */
|
||||
export const RELATIONSHIP_KINDS: RelationshipKind[] = [
|
||||
"ally",
|
||||
"rival",
|
||||
"family",
|
||||
"sibling",
|
||||
"friend",
|
||||
"spouse",
|
||||
"partner",
|
||||
"mentor",
|
||||
"romantic",
|
||||
"custom",
|
||||
];
|
||||
|
||||
const KIND_LABEL: Record<RelationshipKind, string> = {
|
||||
ally: "Ally",
|
||||
rival: "Rival",
|
||||
family: "Family",
|
||||
sibling: "Sibling",
|
||||
friend: "Friend",
|
||||
spouse: "Spouse",
|
||||
partner: "Partner",
|
||||
mentor: "Mentor",
|
||||
romantic: "Romantic",
|
||||
custom: "Custom",
|
||||
};
|
||||
|
||||
interface RelationshipEditorProps {
|
||||
character: Character;
|
||||
/** All entities available as relationship targets. */
|
||||
entities: Entity[];
|
||||
onChange: (relationships: Character["relationships"]) => void;
|
||||
}
|
||||
|
||||
export function RelationshipEditor({
|
||||
character,
|
||||
entities,
|
||||
onChange,
|
||||
}: RelationshipEditorProps) {
|
||||
const targets = entities.filter((e) => e.id !== character.id);
|
||||
|
||||
const targetName = (id: string) => {
|
||||
const e = entities.find((e) => e.id === id);
|
||||
return e ? entityLabel(e) : "(unknown)";
|
||||
};
|
||||
|
||||
const remove = (targetId: string) => {
|
||||
onChange(character.relationships.filter((r) => r.targetId !== targetId));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ps-relationship-editor">
|
||||
<div className="ps-relationship-list">
|
||||
{character.relationships.length === 0 && (
|
||||
<p className="ps-relationship-empty">
|
||||
No relationships yet. Add one below to start mapping this character's
|
||||
connections.
|
||||
</p>
|
||||
)}
|
||||
{character.relationships.map((r, i) => (
|
||||
<div key={`${r.targetId}-${i}`} className="ps-relationship-item">
|
||||
<span className="ps-relationship-kind">{KIND_LABEL[r.kind] ?? r.kind}</span>
|
||||
<span className="ps-relationship-target">{targetName(r.targetId)}</span>
|
||||
{r.note ? <span className="ps-relationship-note">{r.note}</span> : null}
|
||||
<button
|
||||
type="button"
|
||||
className="ps-relationship-remove"
|
||||
aria-label="Remove relationship"
|
||||
onClick={() => remove(r.targetId)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="ps-relationship-add">
|
||||
<Select
|
||||
className="ps-relationship-target-select"
|
||||
value=""
|
||||
options={[
|
||||
{ value: "", label: "Target…" },
|
||||
...targets.map((e) => ({ value: e.id, label: entityLabel(e) })),
|
||||
]}
|
||||
onChange={(ev) => {
|
||||
const targetId = ev.target.value;
|
||||
onChange([...character.relationships, { targetId, kind: "ally", note: "" }]);
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
className="ps-relationship-kind-select"
|
||||
value="ally"
|
||||
options={RELATIONSHIP_KINDS.map((k) => ({ value: k, label: KIND_LABEL[k] }))}
|
||||
onChange={(ev) => {
|
||||
const kind = ev.target.value as RelationshipKind;
|
||||
const next = character.relationships.slice();
|
||||
const last = next[next.length - 1];
|
||||
if (last) last.kind = kind;
|
||||
onChange(next);
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
className="ps-relationship-note"
|
||||
placeholder="Note (optional)"
|
||||
value={character.relationships[character.relationships.length - 1]?.note ?? ""}
|
||||
onChange={(ev) => {
|
||||
const note = ev.target.value;
|
||||
const next = character.relationships.slice();
|
||||
const last = next[next.length - 1];
|
||||
if (last) last.note = note;
|
||||
onChange(next);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Human label for an entity used as a relationship target. */
|
||||
export function entityLabel(e: Entity): string {
|
||||
if ("fields" in e) return `${e.name} (${e.kind})`;
|
||||
if ("body" in e) return e.body.slice(0, 40) || "Note";
|
||||
return e.name;
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* RelationshipMap — a lightweight force-directed SVG graph of a character's
|
||||
* relationships.
|
||||
*
|
||||
* Pure SVG, no external graphing dependency. A small simulation runs once when
|
||||
* the node set changes to compute stable positions; edges are drawn as lines
|
||||
* and nodes as labelled circles. Non-character entities are ignored.
|
||||
*/
|
||||
import type { Character, Entity } from "@pubscript/core";
|
||||
|
||||
interface Node {
|
||||
id: string;
|
||||
label: string;
|
||||
color: string;
|
||||
x: number;
|
||||
y: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
}
|
||||
|
||||
const WIDTH = 520;
|
||||
const HEIGHT = 320;
|
||||
const CENTER = { x: WIDTH / 2, y: HEIGHT / 2 };
|
||||
|
||||
/** Extract the characters from a list of entities. */
|
||||
function characters(entities: Entity[]): Character[] {
|
||||
return entities.filter((e): e is Character => "relationships" in e && e.name.length > 0);
|
||||
}
|
||||
|
||||
/** Human label for a node in the graph. */
|
||||
function nodeLabel(e: Entity): string {
|
||||
if ("fields" in e) return e.name;
|
||||
if ("body" in e) return e.body.slice(0, 12) || "Note";
|
||||
return e.name;
|
||||
}
|
||||
|
||||
/** Run a simple force simulation for a fixed number of steps to lay out nodes. */
|
||||
function layout(nodes: Node[], iterations = 240): Node[] {
|
||||
const REPULSION = 2600;
|
||||
const CENTER_FORCE = 0.004;
|
||||
const DAMPING = 0.85;
|
||||
const PAD = 40;
|
||||
|
||||
for (let iter = 0; iter < iterations; iter++) {
|
||||
for (const a of nodes) {
|
||||
a.vx = 0;
|
||||
a.vy = 0;
|
||||
}
|
||||
// Pairwise repulsion.
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
for (let j = i + 1; j < nodes.length; j++) {
|
||||
const a = nodes[i]!;
|
||||
const b = nodes[j]!;
|
||||
let dx = a.x - b.x;
|
||||
let dy = a.y - b.y;
|
||||
let dist2 = dx * dx + dy * dy;
|
||||
if (dist2 < 1) {
|
||||
dx = (Math.random() - 0.5) * 2;
|
||||
dy = (Math.random() - 0.5) * 2;
|
||||
dist2 = dx * dx + dy * dy || 1;
|
||||
}
|
||||
const force = REPULSION / dist2;
|
||||
const dist = Math.sqrt(dist2);
|
||||
const fx = (dx / dist) * force;
|
||||
const fy = (dy / dist) * force;
|
||||
a.vx += fx;
|
||||
a.vy += fy;
|
||||
b.vx -= fx;
|
||||
b.vy -= fy;
|
||||
}
|
||||
}
|
||||
// Center gravity.
|
||||
for (const n of nodes) {
|
||||
n.vx += (CENTER.x - n.x) * CENTER_FORCE;
|
||||
n.vy += (CENTER.y - n.y) * CENTER_FORCE;
|
||||
}
|
||||
// Integrate + clamp to padding.
|
||||
for (const n of nodes) {
|
||||
n.vx *= DAMPING;
|
||||
n.vy *= DAMPING;
|
||||
n.x += n.vx;
|
||||
n.y += n.vy;
|
||||
n.x = Math.max(PAD, Math.min(WIDTH - PAD, n.x));
|
||||
n.y = Math.max(PAD, Math.min(HEIGHT - PAD, n.y));
|
||||
}
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
const CHARACTER_COLOR = "#8b5cf6";
|
||||
const RELATED_COLOR = "#38bdf8";
|
||||
|
||||
interface RelationshipMapProps {
|
||||
entities: Entity[];
|
||||
/** The character whose relationships are centered in the graph. */
|
||||
focusId?: string;
|
||||
}
|
||||
|
||||
export function RelationshipMap({ entities, focusId }: RelationshipMapProps) {
|
||||
const chars = characters(entities);
|
||||
|
||||
// Build the node/edge set. Start from the focus character (or the first
|
||||
// character) and include everyone it is directly related to.
|
||||
const focus = focusId ? chars.find((c) => c.id === focusId) : chars[0];
|
||||
const focusChars = focus ? [focus] : chars;
|
||||
|
||||
const nodeIds = new Set<string>();
|
||||
const edges: Array<[string, string]> = [];
|
||||
for (const c of focusChars) {
|
||||
nodeIds.add(c.id);
|
||||
for (const r of c.relationships) {
|
||||
nodeIds.add(r.targetId);
|
||||
edges.push([c.id, r.targetId]);
|
||||
}
|
||||
}
|
||||
|
||||
// Seed positions on a circle, then simulate.
|
||||
const seeded: Node[] = [...nodeIds].map((id, i) => {
|
||||
const angle = (2 * Math.PI * i) / Math.max(1, nodeIds.size);
|
||||
const label = nodeLabel(entities.find((e) => e.id === id)!) || id.slice(0, 6);
|
||||
return {
|
||||
id,
|
||||
label,
|
||||
color: id === focus?.id ? CHARACTER_COLOR : RELATED_COLOR,
|
||||
x: CENTER.x + Math.cos(angle) * 110,
|
||||
y: CENTER.y + Math.sin(angle) * 70,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
};
|
||||
});
|
||||
|
||||
const laidOut = layout(seeded);
|
||||
const pos = new Map(laidOut.map((n) => [n.id, n]));
|
||||
|
||||
if (laidOut.length === 0) {
|
||||
return (
|
||||
<div className="ps-relationship-map-empty">
|
||||
{focusChars.length === 0
|
||||
? "No characters yet."
|
||||
: "Select a character to map its relationships."}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<svg viewBox={`0 0 ${WIDTH} ${HEIGHT}`} className="ps-relationship-svg" role="img" aria-label="Relationship map">
|
||||
<g>
|
||||
{edges.map(([from, to], i) => {
|
||||
const a = pos.get(from);
|
||||
const b = pos.get(to);
|
||||
if (!a || !b) return null;
|
||||
return <line key={`e-${i}`} x1={a.x} y1={a.y} x2={b.x} y2={b.y} className="ps-rel-edge" />;
|
||||
})}
|
||||
</g>
|
||||
<g>
|
||||
{laidOut.map((n) => (
|
||||
<g key={n.id}>
|
||||
<circle cx={n.x} cy={n.y} r={14} fill={n.color} className="ps-rel-node" />
|
||||
<text x={n.x} y={n.y + 3} textAnchor="middle" className="ps-rel-node-label">
|
||||
{n.label.length > 8 ? n.label.slice(0, 7) + "…" : n.label}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Entities feature barrel — Chunk #4 entity engine UI.
|
||||
*
|
||||
* Exposes the app-facing `EntitiesView` (the `/entities` route) and the
|
||||
* composable pieces it's built from, so other routes (e.g. the editor mention
|
||||
* integration) can reuse the editor and picker without importing internals.
|
||||
*/
|
||||
export { EntitiesView } from "./EntitiesView.js";
|
||||
export { EntityEditor } from "./EntityEditor.js";
|
||||
export { EntityFields } from "./EntityFields.js";
|
||||
export { RelationshipEditor } from "./RelationshipEditor.js";
|
||||
export { RelationshipMap } from "./RelationshipMap.js";
|
||||
@@ -4,6 +4,7 @@ import { BrowserRouter } from "react-router-dom";
|
||||
import App from "./App.js";
|
||||
import "@pubscript/editor/styles";
|
||||
import "./styles/writing.css";
|
||||
import "./styles/entities.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
|
||||
@@ -1,52 +1,14 @@
|
||||
import { Tabs, Badge, Button, EmptyState } from "@pubscript/ui";
|
||||
|
||||
const CHARACTERS = [
|
||||
{ id: "c1", name: "Mara Vell", role: "Protagonist", kind: "Human" },
|
||||
{ id: "c2", name: "Old Corvin", role: "Mentor", kind: "Human" },
|
||||
{ id: "c3", name: "The Cartographer", role: "Antagonist", kind: "Unknown" },
|
||||
];
|
||||
import { EntitiesView } from "../features/entities/index.js";
|
||||
|
||||
/**
|
||||
* Entities route — Chunk #4 entity engine.
|
||||
*
|
||||
* Replaces the placeholder character list with the full entity engine: a
|
||||
* tabbed, fuzzy-searchable directory of people, places, and things, plus a
|
||||
* relationship map and the create/edit/delete drawer.
|
||||
*/
|
||||
export function Characters() {
|
||||
return (
|
||||
<div className="ps-content">
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 16 }}>
|
||||
<h1 style={{ margin: 0, fontSize: 24 }}>Characters</h1>
|
||||
<Button variant="primary">+ New character</Button>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
defaultActive="characters"
|
||||
tabs={[
|
||||
{
|
||||
id: "characters",
|
||||
label: "All",
|
||||
badge: CHARACTERS.length,
|
||||
content: (
|
||||
<div style={{ display: "grid", gap: 10 }}>
|
||||
{CHARACTERS.map((c) => (
|
||||
<div key={c.id} className="ps-settings-row" style={{ cursor: "pointer" }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600 }}>{c.name}</div>
|
||||
<div className="ps-settings-desc">{c.role}</div>
|
||||
</div>
|
||||
<Badge variant="accent">{c.kind}</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "locations",
|
||||
label: "Locations",
|
||||
content: <EmptyState icon="📍" title="Locations" description="Locations appear here once added to a chapter." />,
|
||||
},
|
||||
{
|
||||
id: "factions",
|
||||
label: "Factions",
|
||||
content: <EmptyState icon="🏳️" title="No factions yet" description="Group characters into factions to track loyalties." />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
return <EntitiesView />;
|
||||
}
|
||||
|
||||
export default Characters;
|
||||
|
||||
@@ -14,10 +14,12 @@ import {
|
||||
type AutosaveState,
|
||||
type Chapter,
|
||||
type DocumentRef,
|
||||
type Entity,
|
||||
type ProjectMeta,
|
||||
type StorageAdapter,
|
||||
} from "@pubscript/core";
|
||||
import { storageLocal } from "@pubscript/core/local";
|
||||
import { createUlid } from "@pubscript/core";
|
||||
import { create } from "zustand";
|
||||
import { seedDemoProject } from "./seed.js";
|
||||
|
||||
@@ -31,6 +33,10 @@ interface ProjectStore {
|
||||
error: string | null;
|
||||
autosave: AutosaveState;
|
||||
|
||||
// --- entities (Chunk #4) ---
|
||||
entities: Entity[];
|
||||
entitiesLoaded: boolean;
|
||||
|
||||
// --- actions ---
|
||||
/** Load the project (seed a demo if empty) and select the first chapter. */
|
||||
init: () => Promise<void>;
|
||||
@@ -40,9 +46,44 @@ interface ProjectStore {
|
||||
scheduleSave: (ref: DocumentRef) => void;
|
||||
/** Flush any pending save immediately (beforeunload / chapter switch). */
|
||||
flushAutosave: () => void;
|
||||
|
||||
// --- entity actions ---
|
||||
/** Load all entities for the active project, optionally filtered by kind. */
|
||||
loadEntities: (kind?: string) => Promise<void>;
|
||||
/** Create a new entity of the given kind ("character" | "world" | "note"). */
|
||||
createEntity: (kind: string) => Promise<Entity>;
|
||||
/** Persist an updated entity. */
|
||||
updateEntity: (entity: Entity) => Promise<void>;
|
||||
/** Delete an entity by id. */
|
||||
deleteEntity: (id: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const useProjectStore = create<ProjectStore>((set) => {
|
||||
/** Build a blank entity of the given kind for the active project. */
|
||||
function emptyEntity(kind: string, projectId: string): Entity {
|
||||
const id = createUlid();
|
||||
if (kind === "character") {
|
||||
return {
|
||||
id,
|
||||
projectId,
|
||||
name: "",
|
||||
role: "",
|
||||
bio: "",
|
||||
appearance: "",
|
||||
voice: "",
|
||||
relationships: [],
|
||||
arc: "",
|
||||
status: "active",
|
||||
tags: [],
|
||||
};
|
||||
}
|
||||
if (kind === "note") {
|
||||
return { id, projectId, body: "", pinned: false, tags: [] };
|
||||
}
|
||||
// world
|
||||
return { id, projectId, kind: "location", name: "", fields: {} };
|
||||
}
|
||||
|
||||
export const useProjectStore = create<ProjectStore>((set, get) => {
|
||||
let autosave: ReturnType<typeof createAutosave> | null = null;
|
||||
|
||||
return {
|
||||
@@ -53,6 +94,8 @@ export const useProjectStore = create<ProjectStore>((set) => {
|
||||
status: "idle",
|
||||
error: null,
|
||||
autosave: { dirty: false, saving: false, lastSavedAt: null, status: "saved" },
|
||||
entities: [],
|
||||
entitiesLoaded: false,
|
||||
|
||||
init: async () => {
|
||||
set({ status: "loading", error: null });
|
||||
@@ -76,6 +119,9 @@ export const useProjectStore = create<ProjectStore>((set) => {
|
||||
|
||||
const activeChapterId = chapters.length > 0 ? chapters[0]!.id : null;
|
||||
set({ adapter, project, chapters, activeChapterId, status: "ready" });
|
||||
|
||||
// Load entities for the entity engine.
|
||||
await get().loadEntities();
|
||||
} catch (e) {
|
||||
set({ error: e instanceof Error ? e.message : String(e), status: "ready" });
|
||||
}
|
||||
@@ -100,5 +146,43 @@ export const useProjectStore = create<ProjectStore>((set) => {
|
||||
void autosave.flush();
|
||||
}
|
||||
},
|
||||
|
||||
loadEntities: async (kind) => {
|
||||
const { adapter, project } = get();
|
||||
if (!adapter || !project) return;
|
||||
const entities = await adapter.listEntities(project.id, kind);
|
||||
set({ entities, entitiesLoaded: true });
|
||||
},
|
||||
|
||||
createEntity: async (kind) => {
|
||||
const { adapter, project } = get();
|
||||
if (!adapter || !project) throw new Error("No active project.");
|
||||
const entity = emptyEntity(kind, project.id);
|
||||
await adapter.saveEntity(entity);
|
||||
set((state) => ({ entities: [...state.entities, entity] }));
|
||||
return entity;
|
||||
},
|
||||
|
||||
updateEntity: async (entity) => {
|
||||
const { adapter } = get();
|
||||
if (!adapter) throw new Error("No storage adapter.");
|
||||
await adapter.saveEntity(entity);
|
||||
set((state) => ({
|
||||
entities: state.entities.map((e) => (e.id === entity.id ? entity : e)),
|
||||
}));
|
||||
},
|
||||
|
||||
deleteEntity: async (id) => {
|
||||
const { adapter, project } = get();
|
||||
if (!adapter || !project) throw new Error("No active project.");
|
||||
const entity = get().entities.find((e) => e.id === id);
|
||||
if (entity) {
|
||||
const kind = "fields" in entity ? "world" : "body" in entity ? "note" : "character";
|
||||
await adapter.deleteEntity(project.id, kind, id);
|
||||
}
|
||||
set((state) => ({
|
||||
entities: state.entities.filter((e) => e.id !== id),
|
||||
}));
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -3,24 +3,49 @@ import { applyFontSettings, applyTheme, type ThemeId } from "@pubscript/ui";
|
||||
|
||||
export type Density = "compact" | "comfortable";
|
||||
|
||||
/** Body typeface for the writing column. All are system stacks (no web fonts). */
|
||||
export type FontFamily = "serif" | "sans" | "mono";
|
||||
|
||||
export const FONT_FAMILIES: { value: FontFamily; label: string; css: string }[] = [
|
||||
{ value: "serif", label: "Georgia Serif", css: 'Georgia, "Times New Roman", serif' },
|
||||
{ value: "sans", label: "System Sans", css: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif' },
|
||||
{ value: "mono", label: "Mono", css: '"SFMono-Regular", ui-monospace, "Cascadia Code", Consolas, monospace' },
|
||||
];
|
||||
|
||||
/** Resolve a stored font-family enum to its CSS font-family string. */
|
||||
export function fontFamilyCss(value: FontFamily): string {
|
||||
const found = FONT_FAMILIES.find((f) => f.value === value);
|
||||
return found?.css ?? FONT_FAMILIES.at(0)?.css ?? "";
|
||||
}
|
||||
|
||||
export interface AppSettings {
|
||||
theme: ThemeId;
|
||||
accentColor: string;
|
||||
density: Density;
|
||||
fontFamily: FontFamily;
|
||||
fontSize: number;
|
||||
leading: number;
|
||||
measure: number;
|
||||
showLineNumbers: boolean;
|
||||
wordCountGoal: number;
|
||||
autoSave: boolean;
|
||||
focusMode: boolean;
|
||||
typewriterMode: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: AppSettings = {
|
||||
theme: "ink",
|
||||
accentColor: "#7c8bf5",
|
||||
density: "comfortable",
|
||||
fontFamily: "serif",
|
||||
fontSize: 15,
|
||||
leading: 1.7,
|
||||
measure: 720,
|
||||
showLineNumbers: true,
|
||||
wordCountGoal: 80_000,
|
||||
autoSave: true,
|
||||
focusMode: false,
|
||||
typewriterMode: false,
|
||||
};
|
||||
|
||||
const STORAGE_KEY = "pubscript.settings.v1";
|
||||
@@ -46,14 +71,31 @@ export const useSettings = create<SettingsStore>((set, get) => {
|
||||
const initial = loadSettings();
|
||||
// Apply the persisted theme + font on boot.
|
||||
applyTheme(initial.theme);
|
||||
applyFontSettings({ size: initial.fontSize });
|
||||
applyFontSettings({
|
||||
size: initial.fontSize,
|
||||
leading: initial.leading,
|
||||
measure: initial.measure,
|
||||
family: fontFamilyCss(initial.fontFamily),
|
||||
});
|
||||
|
||||
return {
|
||||
...initial,
|
||||
set: (patch) => {
|
||||
const next = { ...get(), ...patch };
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
||||
if (patch.fontSize != null) applyFontSettings({ size: next.fontSize });
|
||||
if (
|
||||
patch.fontSize != null ||
|
||||
patch.leading != null ||
|
||||
patch.measure != null ||
|
||||
patch.fontFamily != null
|
||||
) {
|
||||
applyFontSettings({
|
||||
size: next.fontSize,
|
||||
leading: next.leading,
|
||||
measure: next.measure,
|
||||
family: fontFamilyCss(next.fontFamily),
|
||||
});
|
||||
}
|
||||
set(next);
|
||||
},
|
||||
setTheme: (theme) => {
|
||||
@@ -63,7 +105,12 @@ export const useSettings = create<SettingsStore>((set, get) => {
|
||||
reset: () => {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
applyTheme(DEFAULT_SETTINGS.theme);
|
||||
applyFontSettings({ size: DEFAULT_SETTINGS.fontSize });
|
||||
applyFontSettings({
|
||||
size: DEFAULT_SETTINGS.fontSize,
|
||||
leading: DEFAULT_SETTINGS.leading,
|
||||
measure: DEFAULT_SETTINGS.measure,
|
||||
family: fontFamilyCss(DEFAULT_SETTINGS.fontFamily),
|
||||
});
|
||||
set({ ...DEFAULT_SETTINGS });
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,466 @@
|
||||
/**
|
||||
* Entity engine styles (apps/web).
|
||||
*
|
||||
* Styles for the Chunk #4 entity engine UI: the tabbed directory, the fuzzy
|
||||
* search bar, the entity card grid, the create/edit drawer, the relationship
|
||||
* editor, and the force-directed relationship map. These sit on top of the
|
||||
* `@pubscript/ui` component kit (AppShell.css) and reuse its design tokens
|
||||
* (`--ps-*`) and field/button/drawer/tabs/badge classes.
|
||||
*/
|
||||
|
||||
/* Full-width container for the entity engine (overrides .ps-content narrowness). */
|
||||
.ps-entities-view {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ps-entities-view > .ps-tabs {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ---- Search bar + new-entity actions ---- */
|
||||
.ps-entity-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.ps-entity-search-input {
|
||||
flex: 1;
|
||||
min-width: 240px;
|
||||
}
|
||||
|
||||
.ps-entity-new-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* ---- Entity card grid ---- */
|
||||
.ps-entity-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 14px;
|
||||
padding-bottom: 96px;
|
||||
}
|
||||
|
||||
.ps-entity-card {
|
||||
text-align: left;
|
||||
border: 1px solid var(--ps-border);
|
||||
border-radius: 10px;
|
||||
padding: 14px 16px;
|
||||
background: var(--ps-surface-2);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, transform 0.1s, box-shadow 0.15s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
font-family: var(--ps-font-ui);
|
||||
}
|
||||
|
||||
.ps-entity-card:hover {
|
||||
border-color: var(--ps-accent);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.ps-entity-card-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ps-entity-type-badge {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--ps-accent) 16%, transparent);
|
||||
color: var(--ps-accent);
|
||||
}
|
||||
|
||||
.ps-type-character {
|
||||
background: color-mix(in srgb, var(--ps-accent) 16%, transparent);
|
||||
color: var(--ps-accent);
|
||||
}
|
||||
|
||||
.ps-type-worldItem {
|
||||
background: color-mix(in srgb, #8a5cf5 16%, transparent);
|
||||
color: #8a5cf5;
|
||||
}
|
||||
|
||||
.ps-entity-card-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--ps-text);
|
||||
margin: 0;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.ps-entity-card-sub {
|
||||
font-size: 13px;
|
||||
color: var(--ps-text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ps-entity-card-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.ps-entity-tag {
|
||||
font-size: 11px;
|
||||
padding: 1px 7px;
|
||||
border-radius: 999px;
|
||||
background: var(--ps-surface);
|
||||
color: var(--ps-text-muted);
|
||||
border: 1px solid var(--ps-border);
|
||||
}
|
||||
|
||||
/* ---- Relationship map ---- */
|
||||
.ps-entity-map {
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.ps-relationship-map {
|
||||
width: 100%;
|
||||
max-width: 640px;
|
||||
height: auto;
|
||||
border: 1px solid var(--ps-border);
|
||||
border-radius: 10px;
|
||||
background: var(--ps-surface-2);
|
||||
}
|
||||
|
||||
.ps-relationship-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 420px;
|
||||
color: var(--ps-text-muted);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.ps-relationship-node {
|
||||
transition: transform 0.1s;
|
||||
}
|
||||
|
||||
.ps-relationship-label {
|
||||
font-family: var(--ps-font-ui);
|
||||
}
|
||||
|
||||
/* ---- Entity editor drawer ---- */
|
||||
.ps-entity-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ps-entity-editor-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--ps-border);
|
||||
}
|
||||
|
||||
.ps-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.ps-entity-delete {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
/* ---- Generic entity fields ---- */
|
||||
.ps-entity-fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ps-field-span-2 {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.ps-textarea {
|
||||
resize: vertical;
|
||||
min-height: 60px;
|
||||
font-family: var(--ps-font-ui);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.ps-field-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
color: var(--ps-text);
|
||||
}
|
||||
|
||||
/* ---- Tag field ---- */
|
||||
.ps-tag-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.ps-tag-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.ps-tag-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
padding: 2px 6px 2px 10px;
|
||||
border-radius: 999px;
|
||||
background: var(--ps-surface);
|
||||
border: 1px solid var(--ps-border);
|
||||
color: var(--ps-text);
|
||||
}
|
||||
|
||||
.ps-tag-remove {
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
color: var(--ps-text-muted);
|
||||
font-size: 15px;
|
||||
line-height: 1;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.ps-tag-remove:hover {
|
||||
color: var(--ps-danger);
|
||||
}
|
||||
|
||||
/* ---- Relationship editor ---- */
|
||||
.ps-relationship-editor {
|
||||
margin-top: 8px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--ps-border);
|
||||
}
|
||||
|
||||
.ps-relationship-list {
|
||||
list-style: none;
|
||||
margin: 8px 0 12px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.ps-relationship-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
background: var(--ps-surface-2);
|
||||
border: 1px solid var(--ps-border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.ps-relationship-kind {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: capitalize;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--ps-accent) 16%, transparent);
|
||||
color: var(--ps-accent);
|
||||
}
|
||||
|
||||
.ps-relationship-target {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
color: var(--ps-text);
|
||||
}
|
||||
|
||||
.ps-relationship-remove {
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
color: var(--ps-text-muted);
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.ps-relationship-remove:hover {
|
||||
color: var(--ps-danger);
|
||||
}
|
||||
|
||||
.ps-relationship-add {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ps-relationship-target-select,
|
||||
.ps-relationship-kind-select {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.ps-relationship-note {
|
||||
flex: 2;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
/* ---- Misc ---- */
|
||||
.ps-muted {
|
||||
color: var(--ps-text-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* ---- Toolbar + tabs ---- */
|
||||
.ps-entity-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.ps-entity-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
border-bottom: 1px solid var(--ps-border);
|
||||
padding-bottom: -1px;
|
||||
}
|
||||
|
||||
.ps-entity-tab {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 8px 12px;
|
||||
font-size: 14px;
|
||||
font-family: var(--ps-font-ui);
|
||||
color: var(--ps-text-muted);
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.ps-entity-tab:hover {
|
||||
color: var(--ps-text);
|
||||
}
|
||||
|
||||
.ps-entity-tab.active {
|
||||
color: var(--ps-text);
|
||||
border-bottom-color: var(--ps-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ps-entity-new-kind {
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
/* ---- Entity editor drawer header/body ---- */
|
||||
.ps-entity-editor-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.ps-entity-editor-header h2 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: var(--ps-text);
|
||||
}
|
||||
|
||||
.ps-entity-editor-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
overflow-y: auto;
|
||||
padding: 4px 2px;
|
||||
}
|
||||
|
||||
.ps-icon-btn {
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
color: var(--ps-text-muted);
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.ps-icon-btn:hover {
|
||||
color: var(--ps-text);
|
||||
}
|
||||
|
||||
/* ---- Relationship section + map ---- */
|
||||
.ps-relationship-section {
|
||||
margin-top: 8px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--ps-border);
|
||||
}
|
||||
|
||||
.ps-relationship-section h3 {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--ps-text-muted);
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.ps-relationship-map-wrap {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.ps-relationship-svg {
|
||||
width: 100%;
|
||||
max-width: 520px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.ps-relationship-map-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 200px;
|
||||
color: var(--ps-text-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.ps-rel-edge {
|
||||
stroke: var(--ps-border);
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.ps-rel-node {
|
||||
stroke: var(--ps-surface);
|
||||
stroke-width: 2;
|
||||
transition: transform 0.1s;
|
||||
}
|
||||
|
||||
.ps-rel-node-label {
|
||||
font-family: var(--ps-font-ui);
|
||||
font-size: 10px;
|
||||
fill: var(--ps-text-muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -29,6 +29,16 @@ function mockAdapter(): StorageAdapter & { saved: DocumentRef[] } {
|
||||
return null;
|
||||
},
|
||||
async saveEntity() {},
|
||||
async listEntities() {
|
||||
return [];
|
||||
},
|
||||
async getEntity() {
|
||||
return null;
|
||||
},
|
||||
async deleteEntity() {},
|
||||
async searchEntities() {
|
||||
return [];
|
||||
},
|
||||
async saveBoard() {},
|
||||
async saveAISession() {},
|
||||
async saveDocument(doc: DocumentRef) {
|
||||
|
||||
@@ -82,3 +82,21 @@ export function docHasMentions(doc: ProseMirrorDoc): boolean {
|
||||
visit(doc.content ?? []);
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the backlinks for a single entity across a set of chapters: every
|
||||
* chapter that references it, with the node paths where it appears.
|
||||
*/
|
||||
export function backlinksForEntity(
|
||||
chapters: Array<{ id: string; title: string; doc: ProseMirrorDoc }>,
|
||||
entityId: string,
|
||||
): Backlink[] {
|
||||
const links: Backlink[] = [];
|
||||
for (const ch of chapters) {
|
||||
const paths = findMentionsInDoc(ch.doc, new Set([entityId]), new Set());
|
||||
if (paths.length > 0) {
|
||||
links.push({ chapterId: ch.id, title: ch.title, paths });
|
||||
}
|
||||
}
|
||||
return links;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
findOrphanedMentions,
|
||||
parseRelationships,
|
||||
findRelationshipAsymmetry,
|
||||
findInconsistencies,
|
||||
SYMMETRIC_RELATIONSHIPS,
|
||||
} from "./consistency.js";
|
||||
import type { Character, Entity, ProseMirrorDoc } from "./types.js";
|
||||
|
||||
const doc = (entityId: string | null, name = "Mara"): ProseMirrorDoc => ({
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{ type: "text", text: "She said " },
|
||||
{ type: "CharacterMention", attrs: entityId ? { entityId, name } : { name } },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
describe("findOrphanedMentions", () => {
|
||||
it("flags a mention whose entity id is gone", () => {
|
||||
const chapters = [{ id: "ch1", title: "Ch 1", doc: doc("ghost") }];
|
||||
const issues = findOrphanedMentions(chapters, new Set(["ada"]));
|
||||
expect(issues).toHaveLength(1);
|
||||
expect(issues[0]).toMatchObject({ kind: "orphan-mention", entityId: "ghost", chapterId: "ch1" });
|
||||
});
|
||||
|
||||
it("does not flag a mention whose entity still exists", () => {
|
||||
const chapters = [{ id: "ch1", title: "Ch 1", doc: doc("ada") }];
|
||||
expect(findOrphanedMentions(chapters, new Set(["ada"]))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not flag a bare name mention (no entityId)", () => {
|
||||
const chapters = [{ id: "ch1", title: "Ch 1", doc: doc(null) }];
|
||||
expect(findOrphanedMentions(chapters, new Set(["ada"]))).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseRelationships", () => {
|
||||
it("parses the relationships array into a targetId map", () => {
|
||||
const c: Character = {
|
||||
id: "ada",
|
||||
projectId: "p",
|
||||
name: "Ada",
|
||||
role: "",
|
||||
bio: "",
|
||||
appearance: "",
|
||||
voice: "",
|
||||
relationships: [
|
||||
{ targetId: "maria", kind: "sibling", note: "sisters" },
|
||||
{ targetId: "mark", kind: "ally", note: "" },
|
||||
],
|
||||
arc: "",
|
||||
status: "active",
|
||||
tags: [],
|
||||
};
|
||||
expect(parseRelationships(c)).toEqual({
|
||||
maria: { kind: "sibling", note: "sisters" },
|
||||
mark: { kind: "ally", note: "" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("findRelationshipAsymmetry", () => {
|
||||
const ada: Character = {
|
||||
id: "ada",
|
||||
projectId: "p",
|
||||
name: "Ada",
|
||||
role: "",
|
||||
bio: "",
|
||||
appearance: "",
|
||||
voice: "",
|
||||
relationships: [{ targetId: "maria", kind: "sibling", note: "" }],
|
||||
arc: "",
|
||||
status: "active",
|
||||
tags: [],
|
||||
};
|
||||
const maria: Character = {
|
||||
id: "maria",
|
||||
projectId: "p",
|
||||
name: "Maria",
|
||||
role: "",
|
||||
bio: "",
|
||||
appearance: "",
|
||||
voice: "",
|
||||
relationships: [],
|
||||
arc: "",
|
||||
status: "active",
|
||||
tags: [],
|
||||
};
|
||||
const brad: Character = {
|
||||
id: "brad",
|
||||
projectId: "p",
|
||||
name: "Brad",
|
||||
role: "",
|
||||
bio: "",
|
||||
appearance: "",
|
||||
voice: "",
|
||||
relationships: [{ targetId: "ada", kind: "rival", note: "" }],
|
||||
arc: "",
|
||||
status: "active",
|
||||
tags: [],
|
||||
};
|
||||
const adaBack: Character = {
|
||||
...ada,
|
||||
relationships: [
|
||||
{ targetId: "maria", kind: "sibling", note: "" },
|
||||
{ targetId: "brad", kind: "rival", note: "" },
|
||||
],
|
||||
};
|
||||
|
||||
it("reports a symmetric relationship that is not reciprocated", () => {
|
||||
const issues = findRelationshipAsymmetry([ada, maria]);
|
||||
expect(issues).toHaveLength(1);
|
||||
expect(issues[0]).toMatchObject({ kind: "relationship-asymmetry", fromEntity: "ada", toEntity: "maria", relationship: "sibling" });
|
||||
});
|
||||
|
||||
it("does not report when the symmetric relationship is reciprocated", () => {
|
||||
const mariaBack: Character = {
|
||||
...maria,
|
||||
relationships: [{ targetId: "ada", kind: "sibling", note: "" }],
|
||||
};
|
||||
const issues = findRelationshipAsymmetry([adaBack, mariaBack, brad]);
|
||||
expect(issues).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("ignores directed (non-symmetric) relationship kinds", () => {
|
||||
const mentor: Character = {
|
||||
id: "m",
|
||||
projectId: "p",
|
||||
name: "Mentor",
|
||||
role: "",
|
||||
bio: "",
|
||||
appearance: "",
|
||||
voice: "",
|
||||
relationships: [{ targetId: "ada", kind: "mentor", note: "" }],
|
||||
arc: "",
|
||||
status: "active",
|
||||
tags: [],
|
||||
};
|
||||
expect(findRelationshipAsymmetry([ada, mentor])).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("exposes the symmetric kinds", () => {
|
||||
expect(SYMMETRIC_RELATIONSHIPS.has("sibling")).toBe(true);
|
||||
expect(SYMMETRIC_RELATIONSHIPS.has("mentor")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findInconsistencies", () => {
|
||||
it("combines orphan-mention and relationship-asymmetry issues", () => {
|
||||
const chapters = [{ id: "ch1", title: "Ch 1", doc: doc("ghost") }];
|
||||
const ada: Character = {
|
||||
id: "ada", projectId: "p", name: "Ada", role: "", bio: "", appearance: "", voice: "",
|
||||
relationships: [{ targetId: "maria", kind: "sibling", note: "" }],
|
||||
arc: "", status: "active", tags: [],
|
||||
};
|
||||
const maria: Character = {
|
||||
id: "maria", projectId: "p", name: "Maria", role: "", bio: "", appearance: "", voice: "",
|
||||
relationships: [], arc: "", status: "active", tags: [],
|
||||
};
|
||||
const entities: Entity[] = [ada, maria];
|
||||
const issues = findInconsistencies(chapters, entities);
|
||||
expect(issues.some((i) => i.kind === "orphan-mention")).toBe(true);
|
||||
expect(issues.some((i) => i.kind === "relationship-asymmetry")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Consistency checks across entities and chapters. These catch the kinds of
|
||||
* contradictions that creep in as a manuscript grows: a character mentioned in
|
||||
* prose whose entity was deleted, or a relationship that only one side records.
|
||||
* See CHUNK #4 step 2.4.
|
||||
*/
|
||||
import type { Character, Entity, ProseMirrorDoc } from "./types.js";
|
||||
|
||||
export type RelationshipSymmetry = "symmetric" | "directed";
|
||||
|
||||
/**
|
||||
* Relationship kinds that should hold in both directions. If Ada lists "Bram:
|
||||
* sibling", then Bram should list "Ada: sibling" too.
|
||||
*/
|
||||
export const SYMMETRIC_RELATIONSHIPS = new Set(["sibling", "friend", "spouse", "partner", "family", "rival"]);
|
||||
|
||||
export interface MentionIssue {
|
||||
kind: "orphan-mention";
|
||||
chapterId: string;
|
||||
chapterTitle: string;
|
||||
entityId: string;
|
||||
/** The mention text, if any. */
|
||||
name?: string | undefined;
|
||||
}
|
||||
|
||||
export interface RelationshipIssue {
|
||||
kind: "relationship-asymmetry";
|
||||
fromEntity: string;
|
||||
fromName: string;
|
||||
toEntity: string;
|
||||
toName: string;
|
||||
relationship: string;
|
||||
}
|
||||
|
||||
export type ConsistencyIssue = MentionIssue | RelationshipIssue;
|
||||
|
||||
/**
|
||||
* Find mention nodes that reference an entity id that no longer exists (e.g.
|
||||
* the entity was deleted). Returns one issue per offending mention.
|
||||
*/
|
||||
export function findOrphanedMentions(
|
||||
chapters: Array<{ id: string; title: string; doc: ProseMirrorDoc }>,
|
||||
entityIds: Set<string>,
|
||||
): MentionIssue[] {
|
||||
const issues: MentionIssue[] = [];
|
||||
for (const ch of chapters) {
|
||||
const visit = (content: unknown[], _path: number[]): void => {
|
||||
for (const node of content as Record<string, unknown>[]) {
|
||||
if (!node || typeof node !== "object") continue;
|
||||
if (node.type === "CharacterMention") {
|
||||
const attrs = node.attrs as { entityId?: string; name?: string } | undefined;
|
||||
if (attrs?.entityId && !entityIds.has(attrs.entityId)) {
|
||||
issues.push({
|
||||
kind: "orphan-mention",
|
||||
chapterId: ch.id,
|
||||
chapterTitle: ch.title,
|
||||
entityId: attrs.entityId,
|
||||
name: attrs.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (Array.isArray(node.content)) visit(node.content as unknown[], _path);
|
||||
}
|
||||
};
|
||||
visit(ch.doc.content ?? [], []);
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the `relationships` array of a character into
|
||||
* `{ [targetId]: { kind, note } }`.
|
||||
*/
|
||||
export function parseRelationships(character: Character): Record<string, { kind: string; note: string }> {
|
||||
const out: Record<string, { kind: string; note: string }> = {};
|
||||
for (const r of character.relationships ?? []) {
|
||||
out[r.targetId] = { kind: r.kind, note: r.note };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find relationships recorded by one character but not reciprocated by the
|
||||
* other, for symmetric relationship kinds. Returns one issue per asymmetric
|
||||
* pair (reported once, from the recording side).
|
||||
*/
|
||||
export function findRelationshipAsymmetry(entities: Entity[]): RelationshipIssue[] {
|
||||
const characters = entities.filter((e): e is Character => !("fields" in e) && !("body" in e));
|
||||
// index by id -> character (targetId is an entity id)
|
||||
const byId = new Map<string, Character>();
|
||||
for (const c of characters) byId.set(c.id, c);
|
||||
|
||||
const issues: RelationshipIssue[] = [];
|
||||
for (const c of characters) {
|
||||
const rels = parseRelationships(c);
|
||||
for (const [targetId, rel] of Object.entries(rels)) {
|
||||
const sym = rel.kind.toLowerCase();
|
||||
if (!SYMMETRIC_RELATIONSHIPS.has(sym)) continue;
|
||||
const other = byId.get(targetId);
|
||||
if (!other || other.id === c.id) continue;
|
||||
// Does the other side record this character back with a symmetric kind?
|
||||
const otherRel = parseRelationships(other);
|
||||
const back = otherRel[c.id];
|
||||
if (!back || !SYMMETRIC_RELATIONSHIPS.has(back.kind.toLowerCase())) {
|
||||
issues.push({
|
||||
kind: "relationship-asymmetry",
|
||||
fromEntity: c.id,
|
||||
fromName: c.name,
|
||||
toEntity: other.id,
|
||||
toName: other.name,
|
||||
relationship: rel.kind,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
/** Run every consistency check and return the combined issue list. */
|
||||
export function findInconsistencies(
|
||||
chapters: Array<{ id: string; title: string; doc: ProseMirrorDoc }>,
|
||||
entities: Entity[],
|
||||
): ConsistencyIssue[] {
|
||||
const entityIds = new Set(entities.map((e) => e.id));
|
||||
const issues: ConsistencyIssue[] = [
|
||||
...findOrphanedMentions(chapters, entityIds),
|
||||
...findRelationshipAsymmetry(entities),
|
||||
];
|
||||
return issues;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { fuzzyScore, fuzzyMatch, fuzzySort } from "./fuzzy.js";
|
||||
|
||||
describe("fuzzyScore", () => {
|
||||
it("matches an empty query as no-match (score 0)", () => {
|
||||
expect(fuzzyScore("", "mara").score).toBe(0);
|
||||
});
|
||||
|
||||
it("matches a whole-string query at score 1", () => {
|
||||
const r = fuzzyScore("mara", "Mara");
|
||||
expect(r.score).toBe(1);
|
||||
expect(r.indices).toEqual([0, 1, 2, 3]);
|
||||
});
|
||||
|
||||
it("matches a subsequence in order", () => {
|
||||
const r = fuzzyScore("mr", "Mara");
|
||||
expect(r.score).toBeGreaterThan(0);
|
||||
expect(r.indices).toEqual([0, 2]);
|
||||
});
|
||||
|
||||
it("does not match a non-subsequence", () => {
|
||||
expect(fuzzyScore("z", "Mara").score).toBe(0);
|
||||
expect(fuzzyScore("marz", "Mara").score).toBe(0);
|
||||
});
|
||||
|
||||
it("scores consecutive matches higher than scattered ones", () => {
|
||||
const consecutive = fuzzyScore("mar", "maria").score;
|
||||
const scattered = fuzzyScore("mr", "maria").score;
|
||||
expect(consecutive).toBeGreaterThan(scattered);
|
||||
});
|
||||
|
||||
it("scores word-start matches higher", () => {
|
||||
const wordStart = fuzzyScore("h", "harbor").score;
|
||||
const mid = fuzzyScore("r", "harbor").score;
|
||||
expect(wordStart).toBeGreaterThan(mid);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fuzzyMatch", () => {
|
||||
it("is true for a subsequence and false otherwise", () => {
|
||||
expect(fuzzyMatch("mr", "Mara")).toBe(true);
|
||||
expect(fuzzyMatch("z", "Mara")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fuzzySort", () => {
|
||||
const items = [
|
||||
{ id: "ada", name: "Ada Lovelace" },
|
||||
{ id: "maria", name: "Maria Harbor" },
|
||||
{ id: "mark", name: "Mark Twain" },
|
||||
];
|
||||
|
||||
it("returns all items for an empty query, preserving order", () => {
|
||||
expect(fuzzySort(items, "", (i) => i.name).map((i) => i.id)).toEqual(["ada", "maria", "mark"]);
|
||||
});
|
||||
|
||||
it("drops non-matches and orders by descending score", () => {
|
||||
const sorted = fuzzySort(items, "mar", (i) => i.name);
|
||||
expect(sorted.map((i) => i.id)).toContain("maria");
|
||||
expect(sorted.map((i) => i.id)).not.toContain("ada");
|
||||
// "Maria Harbor" (word start) should beat "Mark Twain" (word start) only on tie-break; both match.
|
||||
expect(sorted.length).toBe(2);
|
||||
});
|
||||
|
||||
it("is stable for equal scores", () => {
|
||||
// "ada" and "amy" both score 2 for query "a" (word-start match); input
|
||||
// order must be preserved.
|
||||
const stable = [
|
||||
{ id: "a", name: "ada" },
|
||||
{ id: "b", name: "amy" },
|
||||
];
|
||||
const sorted = fuzzySort(stable, "a", (i) => i.name);
|
||||
expect(sorted.map((i) => i.id)).toEqual(["a", "b"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Dependency-light fuzzy scorer used for entity search and mention
|
||||
* autocomplete.
|
||||
*
|
||||
* This is a subsequence scorer in the spirit of "sublime" / Fuse.js: a query is
|
||||
* a sequence of characters that must appear (in order) within a target string.
|
||||
* It scores matches so that:
|
||||
*
|
||||
* - consecutive matches score higher than scattered ones,
|
||||
* - matches at the start of a word (after a separator) score higher,
|
||||
* - earlier matches in the string score higher.
|
||||
*
|
||||
* The result is a number in `[0, 1]` where `1` is a perfect (whole-string,
|
||||
* case-insensitive, consecutive) match. It is intentionally allocation-light
|
||||
* and fast enough to run on every keystroke against a few hundred entities
|
||||
* well under the 50 ms budget the plan calls for.
|
||||
*/
|
||||
|
||||
const WORD_SEP = /[^a-zA-Z0-9]+/;
|
||||
|
||||
export interface FuzzyResult {
|
||||
/** Match score in [0, 1]; 0 means no match. */
|
||||
score: number;
|
||||
/** Indices in `target` that were matched by the query, in order. */
|
||||
indices: number[];
|
||||
}
|
||||
|
||||
/** Score a single matched cell from its predecessor score. */
|
||||
function scoreCell(prev: number, consecutive: number, wordStart: number): number {
|
||||
let s = prev + 1; // base reward for a match
|
||||
if (consecutive) s *= 1.4; // multiplicative streak bonus
|
||||
if (wordStart) s += 0.8; // word-start bonus
|
||||
return s;
|
||||
}
|
||||
|
||||
/** Normalize a raw score into [0, 1]. */
|
||||
function normalize(raw: number): number {
|
||||
if (!isFinite(raw)) return 0;
|
||||
return Math.max(0, Math.min(1, raw / 3));
|
||||
}
|
||||
|
||||
/**
|
||||
* Score `query` against `target`. Returns `{ score: 0 }` when the query is not a
|
||||
* subsequence of the target (i.e. no match).
|
||||
*/
|
||||
export function fuzzyScore(query: string, target: string): FuzzyResult {
|
||||
const q = query;
|
||||
const t = target;
|
||||
if (q.length === 0) return { score: 0, indices: [] };
|
||||
if (q.length > t.length) return { score: 0, indices: [] };
|
||||
|
||||
const ql = q.length;
|
||||
const tl = t.length;
|
||||
const tq = q.toLowerCase();
|
||||
const tt = t.toLowerCase();
|
||||
|
||||
// best[i] = best score having matched the first i query chars.
|
||||
// where[i] = target index at which the i-th match landed (for recovery).
|
||||
const best = new Float64Array(ql + 1).fill(-Infinity);
|
||||
const where = new Int32Array(ql + 1);
|
||||
const consec = new Uint8Array(ql + 1);
|
||||
best[0] = 0;
|
||||
|
||||
for (let ti = 0; ti < tl; ti++) {
|
||||
const tc = tt[ti]!;
|
||||
// Walk from high to low so each cell reads the previous row only.
|
||||
for (let i = ql; i >= 1; i--) {
|
||||
const qc = tq[i - 1]!;
|
||||
if (tc !== qc) continue;
|
||||
const fromPrev = best[i - 1]!;
|
||||
if (fromPrev <= -Infinity) continue;
|
||||
const wordStart = ti === 0 || WORD_SEP.test(t[ti - 1]!) ? 1 : 0;
|
||||
const c = consec[i - 1] ? 1 : 0;
|
||||
const s = scoreCell(fromPrev, c, wordStart);
|
||||
if (s > best[i]!) {
|
||||
best[i] = s;
|
||||
where[i] = ti;
|
||||
consec[i] = c;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (best[ql]! <= -Infinity) return { score: 0, indices: [] };
|
||||
|
||||
// Recover matched indices by walking `where` backwards.
|
||||
const indices: number[] = [];
|
||||
for (let i = ql; i >= 1; i--) {
|
||||
indices.push(where[i]!);
|
||||
}
|
||||
indices.reverse();
|
||||
|
||||
return { score: normalize(best[ql]!), indices };
|
||||
}
|
||||
|
||||
/** Convenience: does `query` match `target` as a subsequence? */
|
||||
export function fuzzyMatch(query: string, target: string): boolean {
|
||||
return fuzzyScore(query, target).score > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort `items` by descending fuzzy score against `query`, dropping non-matches.
|
||||
* Stable: equal scores keep input order.
|
||||
*/
|
||||
export function fuzzySort<T>(items: T[], query: string, pick: (item: T) => string): T[] {
|
||||
if (!query) return items.slice();
|
||||
const scored = items
|
||||
.map((item) => ({ item, res: fuzzyScore(query, pick(item)) }))
|
||||
.filter((r) => r.res.score > 0);
|
||||
scored.sort((a, b) => b.res.score - a.res.score);
|
||||
return scored.map((r) => r.item);
|
||||
}
|
||||
@@ -12,4 +12,6 @@ export * from "./schema.js";
|
||||
export * from "./schemas.js";
|
||||
export * from "./autosave.js";
|
||||
export * from "./backlinks.js";
|
||||
export * from "./fuzzy.js";
|
||||
export * from "./consistency.js";
|
||||
export * from "./storage-server.js";
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
import type { StorageAdapter } from "./storage.js";
|
||||
import { createUlid } from "./ids.js";
|
||||
import { nowIso } from "./time.js";
|
||||
import { fuzzySort } from "./fuzzy.js";
|
||||
|
||||
interface EntityRec {
|
||||
key: string;
|
||||
@@ -49,6 +50,35 @@ const entityKey = (projectId: string, kind: string, id: string): string =>
|
||||
const kindOf = (e: Entity): string =>
|
||||
"fields" in e ? "world" : "body" in e ? "note" : "character";
|
||||
|
||||
/**
|
||||
* Concatenate every human-readable field of an entity into a single searchable
|
||||
* string (name + field values). Used by the fuzzy search index.
|
||||
*/
|
||||
function entitySearchText(e: Entity): string {
|
||||
if ("fields" in e) {
|
||||
// WorldItem
|
||||
const parts = [e.name, e.kind];
|
||||
for (const v of Object.values(e.fields)) if (typeof v === "string") parts.push(v);
|
||||
return parts.join(" ");
|
||||
}
|
||||
if ("body" in e) {
|
||||
// Note
|
||||
return [e.body, ...e.tags].join(" ");
|
||||
}
|
||||
// Character
|
||||
return [
|
||||
e.name,
|
||||
e.role,
|
||||
e.bio,
|
||||
e.appearance,
|
||||
e.voice,
|
||||
e.relationships,
|
||||
e.arc,
|
||||
e.status,
|
||||
...e.tags,
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
/**
|
||||
* The local (offline-first) `StorageAdapter`, backed by IndexedDB via Dexie.
|
||||
* Implements the exact `StorageAdapter` contract so the app can run fully
|
||||
@@ -136,6 +166,30 @@ export const storageLocal: StorageAdapter = {
|
||||
await db.entities.put({ key: entityKey(e.projectId, kind, e.id), projectId: e.projectId, kind, data: e });
|
||||
},
|
||||
|
||||
async listEntities(projectId: string, kind?: string): Promise<Entity[]> {
|
||||
const rows = await db.entities
|
||||
.where("projectId")
|
||||
.equals(projectId)
|
||||
.toArray();
|
||||
const filtered = kind ? rows.filter((r) => r.kind === kind) : rows;
|
||||
return filtered.map((r) => r.data);
|
||||
},
|
||||
|
||||
async getEntity(projectId: string, kind: string, id: string): Promise<Entity | null> {
|
||||
const row = await db.entities.get(entityKey(projectId, kind, id));
|
||||
return row ? row.data : null;
|
||||
},
|
||||
|
||||
async deleteEntity(projectId: string, kind: string, id: string): Promise<void> {
|
||||
await db.entities.delete(entityKey(projectId, kind, id));
|
||||
},
|
||||
|
||||
async searchEntities(projectId: string, q: string): Promise<Entity[]> {
|
||||
const all = await this.listEntities(projectId);
|
||||
if (!q) return all;
|
||||
return fuzzySort(all, q, entitySearchText);
|
||||
},
|
||||
|
||||
async saveBoard(b: Board): Promise<void> {
|
||||
await db.boards.put(b);
|
||||
},
|
||||
|
||||
@@ -118,6 +118,41 @@ export function createServerClient(opts: ServerClientOptions): StorageAdapter {
|
||||
}
|
||||
},
|
||||
|
||||
async listEntities(projectId: string, kind?: string): Promise<Entity[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (kind) params.set("kind", kind);
|
||||
const qs = params.toString();
|
||||
const res = await req<{ entities: Entity[] }>(
|
||||
"GET",
|
||||
`/api/projects/${projectId}/entities${qs ? `?${qs}` : ""}`,
|
||||
);
|
||||
return res.entities;
|
||||
},
|
||||
|
||||
async getEntity(projectId: string, kind: string, id: string): Promise<Entity | null> {
|
||||
try {
|
||||
const res = await req<{ entity: Entity }>(
|
||||
"GET",
|
||||
`/api/projects/${projectId}/entities/${kind}/${id}`,
|
||||
);
|
||||
return res.entity;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
async deleteEntity(projectId: string, kind: string, id: string): Promise<void> {
|
||||
await req("DELETE", `/api/projects/${projectId}/entities/${kind}/${id}`);
|
||||
},
|
||||
|
||||
async searchEntities(projectId: string, q: string): Promise<Entity[]> {
|
||||
const res = await req<{ entities: Entity[] }>(
|
||||
"GET",
|
||||
`/api/projects/${projectId}/entities?q=${encodeURIComponent(q)}`,
|
||||
);
|
||||
return res.entities;
|
||||
},
|
||||
|
||||
async saveBoard(b: Board): Promise<void> {
|
||||
await req("POST", `/api/projects/${b.projectId}/boards`, {
|
||||
id: b.id,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import "fake-indexeddb/auto";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createServerClient, type ProseMirrorDoc, type StorageAdapter } from "@pubscript/core";
|
||||
import { createServerClient, type ProseMirrorDoc, type Character, type StorageAdapter, type WorldItem } from "@pubscript/core";
|
||||
import { storageLocal } from "@pubscript/core/local";
|
||||
|
||||
const doc = (text: string): ProseMirrorDoc => ({ type: "doc", content: [{ type: "text", text }] });
|
||||
@@ -63,6 +63,31 @@ function mockServerFetch(): typeof fetch {
|
||||
entities.set(`${m[1]!}|${body.kind}|${e.id}`, e);
|
||||
return json({ entity: e });
|
||||
}
|
||||
if (method === "GET" && m) {
|
||||
const projectId = m[1]!;
|
||||
const kind = u.searchParams.get("kind");
|
||||
const q = u.searchParams.get("q") ?? "";
|
||||
const all = [...entities.values()].filter((e) => e.projectId === projectId && (!kind || e.kind === kind));
|
||||
const filtered = q
|
||||
? all.filter((e) => JSON.stringify(e).toLowerCase().includes(q.toLowerCase()))
|
||||
: all;
|
||||
return json({ entities: filtered });
|
||||
}
|
||||
m = u.pathname.match(/^\/api\/projects\/([^/]+)\/entities\/([^/]+)\/([^/]+)$/);
|
||||
if (method === "GET" && m) {
|
||||
const projectId = m[1]!;
|
||||
const kind = m[2]!;
|
||||
const id = m[3]!;
|
||||
const e = entities.get(`${projectId}|${kind}|${id}`);
|
||||
return e ? json({ entity: e }) : json({ error: "not_found" }, 404);
|
||||
}
|
||||
if (method === "DELETE" && m) {
|
||||
const projectId = m[1]!;
|
||||
const kind = m[2]!;
|
||||
const id = m[3]!;
|
||||
entities.delete(`${projectId}|${kind}|${id}`);
|
||||
return json({ ok: true });
|
||||
}
|
||||
m = u.pathname.match(/^\/api\/projects\/([^/]+)\/boards$/);
|
||||
if (method === "POST" && m) {
|
||||
const b = { id: body.id ?? id(), projectId: m[1]!, nodes: body.nodes ?? [], edges: body.edges ?? [] };
|
||||
@@ -132,6 +157,85 @@ const EXPECTED = {
|
||||
chapterRev: 1,
|
||||
};
|
||||
|
||||
/** Entity CRUD + search scenario both adapters must pass identically. */
|
||||
async function entityScenario(adapter: StorageAdapter) {
|
||||
const projectId = "proj-ent";
|
||||
await adapter.createProject({ title: "Entities" });
|
||||
|
||||
const ada: Character = {
|
||||
id: "ada",
|
||||
projectId,
|
||||
name: "Ada Lovelace",
|
||||
role: "protagonist",
|
||||
bio: "A mathematician and first computer programmer.",
|
||||
appearance: "sharp eyes, Victorian dress",
|
||||
voice: "precise, confident",
|
||||
relationships: [{ targetId: "babbage", kind: "ally", note: "worked together" }],
|
||||
arc: "From curiosity to genius.",
|
||||
status: "active",
|
||||
tags: ["math", "victorian"],
|
||||
};
|
||||
const babbage: Character = {
|
||||
id: "babbage",
|
||||
projectId,
|
||||
name: "Charles Babbage",
|
||||
role: "mentor",
|
||||
bio: "An obsessed inventor of computing machines.",
|
||||
appearance: "bearded, bespectacled",
|
||||
voice: "grumbly",
|
||||
relationships: [],
|
||||
arc: "",
|
||||
status: "active",
|
||||
tags: ["inventor"],
|
||||
};
|
||||
const clocktower: WorldItem = {
|
||||
id: "clocktower",
|
||||
projectId,
|
||||
kind: "location",
|
||||
name: "The Clocktower",
|
||||
fields: { era: "1840s", significance: "meeting place" },
|
||||
};
|
||||
|
||||
await adapter.saveEntity(ada);
|
||||
await adapter.saveEntity(babbage);
|
||||
await adapter.saveEntity(clocktower);
|
||||
|
||||
// list all
|
||||
const all = await adapter.listEntities(projectId);
|
||||
expect(all.map((e) => e.id).sort()).toEqual(["ada", "babbage", "clocktower"]);
|
||||
|
||||
// list by kind
|
||||
const chars = await adapter.listEntities(projectId, "character");
|
||||
expect(chars.map((e) => e.id).sort()).toEqual(["ada", "babbage"]);
|
||||
|
||||
// getEntity: found
|
||||
expect(((await adapter.getEntity(projectId, "character", "ada")) as Character | null)?.name).toBe("Ada Lovelace");
|
||||
|
||||
// getEntity: not found
|
||||
expect(await adapter.getEntity(projectId, "character", "nope")).toBeNull();
|
||||
|
||||
// search: fuzzy match, best-first
|
||||
const babbageRes = await adapter.searchEntities(projectId, "babbage");
|
||||
expect(babbageRes.map((e) => e.id)).toContain("babbage");
|
||||
const adaRes = await adapter.searchEntities(projectId, "ada");
|
||||
expect(adaRes[0]?.id).toBe("ada");
|
||||
|
||||
// search: empty returns all
|
||||
expect((await adapter.searchEntities(projectId, "")).length).toBe(3);
|
||||
|
||||
// delete
|
||||
await adapter.deleteEntity(projectId, "character", "ada");
|
||||
expect(await adapter.getEntity(projectId, "character", "ada")).toBeNull();
|
||||
expect((await adapter.listEntities(projectId)).map((e) => e.id)).toEqual(["babbage", "clocktower"]);
|
||||
}
|
||||
|
||||
describe.each([
|
||||
{ name: "local (IndexedDB)", adapter: storageLocal },
|
||||
{ name: "server (REST)", adapter: createServerClient({ baseUrl: "http://test", fetch: mockServerFetch() }) },
|
||||
])("$name adapter honors the entity contract", async ({ adapter }) => {
|
||||
it("lists, fetches, searches, and deletes entities", () => entityScenario(adapter));
|
||||
});
|
||||
|
||||
describe("StorageAdapter contract (web/local vs server)", () => {
|
||||
it("local (IndexedDB) adapter honors the contract", async () => {
|
||||
await expect(runScenario(storageLocal)).resolves.toMatchObject(EXPECTED);
|
||||
|
||||
@@ -40,6 +40,18 @@ export interface StorageAdapter {
|
||||
saveDocument(doc: DocumentRef): Promise<void>;
|
||||
getChapter(id: string): Promise<Chapter | null>;
|
||||
saveEntity(e: Entity): Promise<void>;
|
||||
/**
|
||||
* List entities for a project, optionally filtered by kind ("character" |
|
||||
* "world" | "note"). Returns every entity when `kind` is omitted.
|
||||
*/
|
||||
listEntities(projectId: string, kind?: string): Promise<Entity[]>;
|
||||
getEntity(projectId: string, kind: string, id: string): Promise<Entity | null>;
|
||||
deleteEntity(projectId: string, kind: string, id: string): Promise<void>;
|
||||
/**
|
||||
* Fuzzy search across an entity's name and field values. Returns matches
|
||||
* ordered best-first; empty `q` returns all entities for the project.
|
||||
*/
|
||||
searchEntities(projectId: string, q: string): Promise<Entity[]>;
|
||||
saveBoard(b: Board): Promise<void>;
|
||||
saveAISession(s: AISession): Promise<void>;
|
||||
|
||||
|
||||
@@ -130,6 +130,10 @@ export type RelationshipKind =
|
||||
| "ally"
|
||||
| "rival"
|
||||
| "family"
|
||||
| "sibling"
|
||||
| "friend"
|
||||
| "spouse"
|
||||
| "partner"
|
||||
| "mentor"
|
||||
| "romantic"
|
||||
| "custom";
|
||||
@@ -141,7 +145,7 @@ export interface WorldItem {
|
||||
projectId: string;
|
||||
kind: WorldItemKind;
|
||||
name: string;
|
||||
fields: Record<string, string>;
|
||||
fields: Record<string, string | string[]>;
|
||||
}
|
||||
|
||||
export type BeatTemplate =
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -126,6 +126,115 @@ body {
|
||||
line-height: var(--ps-font-leading, 1.75);
|
||||
}
|
||||
|
||||
/* ---- Writing surface: toolbar, focus/typewriter modes ---- */
|
||||
.ps-writing {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
.ps-writing-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 8px 16px;
|
||||
border-bottom: 1px solid var(--ps-border);
|
||||
background: var(--ps-surface);
|
||||
flex: none;
|
||||
}
|
||||
.ps-chapter-select {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.ps-chapter-select-label {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--ps-text-muted);
|
||||
}
|
||||
.ps-chapter-select select {
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--ps-border);
|
||||
border-radius: 8px;
|
||||
background: var(--ps-canvas);
|
||||
color: var(--ps-text);
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.ps-toolbar-group {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
.ps-tool {
|
||||
padding: 5px 12px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--ps-text-muted);
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ps-tool:hover {
|
||||
color: var(--ps-text);
|
||||
background: var(--ps-canvas);
|
||||
}
|
||||
.ps-tool.is-active {
|
||||
color: var(--ps-text);
|
||||
border-color: var(--ps-accent);
|
||||
background: color-mix(in srgb, var(--ps-accent) 12%, transparent);
|
||||
}
|
||||
.ps-autosave-indicator {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
font-size: 12px;
|
||||
color: var(--ps-text-muted);
|
||||
}
|
||||
.ps-autosave-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--ps-text-muted);
|
||||
}
|
||||
.ps-autosave-indicator[data-status="saving"] .ps-autosave-dot {
|
||||
background: #d9a441;
|
||||
}
|
||||
.ps-autosave-indicator[data-status="dirty"] .ps-autosave-dot {
|
||||
background: #d96c4b;
|
||||
}
|
||||
.ps-autosave-indicator[data-status="saved"] .ps-autosave-dot {
|
||||
background: #4f9d69;
|
||||
}
|
||||
|
||||
/* Focus mode: dim the whole document, then restore the block under the cursor. */
|
||||
.ps-editor-focus .ps-editor-content {
|
||||
opacity: 0.22;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
.ps-editor-focus .ps-editor-content .ps-focus-active,
|
||||
.ps-editor-focus .ps-editor-content .ps-focus-active * {
|
||||
opacity: 1;
|
||||
transition: opacity 0.05s ease;
|
||||
}
|
||||
|
||||
.ps-writing-status {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
padding: 6px 16px;
|
||||
border-top: 1px solid var(--ps-border);
|
||||
background: var(--ps-surface);
|
||||
color: var(--ps-text-muted);
|
||||
font-size: 12px;
|
||||
flex: none;
|
||||
}
|
||||
.ps-writing-status-note {
|
||||
margin-left: auto;
|
||||
color: var(--ps-text);
|
||||
}
|
||||
|
||||
/* ---- Sidebar nav ---- */
|
||||
.ps-logo {
|
||||
font-weight: 700;
|
||||
|
||||
+14
-4
@@ -1,3 +1,4 @@
|
||||
import { fileURLToPath } from "node:url";
|
||||
import Fastify from "fastify";
|
||||
import cors from "@fastify/cors";
|
||||
import cookie from "@fastify/cookie";
|
||||
@@ -74,7 +75,16 @@ export async function start() {
|
||||
return app;
|
||||
}
|
||||
|
||||
start().catch((err) => {
|
||||
console.error("Failed to start server:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
// Auto-boot only when this file is the CLI entry point (pnpm dev / start),
|
||||
// not when imported by tests (vitest) — otherwise start() would try to bind
|
||||
// the port and tear the test process down.
|
||||
const invokedDirectly =
|
||||
process.argv[1] !== undefined &&
|
||||
fileURLToPath(import.meta.url) === process.argv[1];
|
||||
|
||||
if (invokedDirectly) {
|
||||
start().catch((err) => {
|
||||
console.error("Failed to start server:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
+16
-1
@@ -183,7 +183,7 @@ export function toEntity(r: EntityRow): Entity {
|
||||
bio: f.bio ?? "",
|
||||
appearance: f.appearance ?? "",
|
||||
voice: f.voice ?? "",
|
||||
relationships: f.relationships ?? "",
|
||||
relationships: typeof f.relationships === "string" ? JSON.parse(f.relationships) : (f.relationships ?? []),
|
||||
arc: f.arc ?? "",
|
||||
status: f.status ?? "active",
|
||||
tags: f.tags ?? [],
|
||||
@@ -251,6 +251,21 @@ export function entityToRow(e: Entity): Omit<EntityRow, "created_at" | "updated_
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
/** Concatenate every human-readable field of an entity into one searchable string. */
|
||||
export function entitySearchText(e: Entity): string {
|
||||
if ("fields" in e) {
|
||||
const parts = [e.name, e.kind];
|
||||
for (const v of Object.values(e.fields)) if (typeof v === "string") parts.push(v);
|
||||
return parts.join(" ");
|
||||
}
|
||||
if ("body" in e) {
|
||||
return [e.body, ...e.tags].join(" ");
|
||||
}
|
||||
const c = e as Character;
|
||||
const relText = (c.relationships ?? []).map((r) => `${r.targetId} ${r.kind} ${r.note}`).join(" ");
|
||||
return [c.name, c.role, c.bio, c.appearance, c.voice, relText, c.arc, c.status, ...c.tags].join(" ");
|
||||
}
|
||||
|
||||
/** Count words in a ProseMirror doc (text nodes only). */
|
||||
export function wordCount(doc: unknown): number {
|
||||
let count = 0;
|
||||
|
||||
+11
-5
@@ -15,6 +15,7 @@ import {
|
||||
toAISession,
|
||||
toEntity,
|
||||
entityToRow,
|
||||
entitySearchText,
|
||||
wordCount,
|
||||
type ProjectRow,
|
||||
type ShareRow,
|
||||
@@ -58,7 +59,7 @@ const entityCreate = z.object({
|
||||
bio: z.string().optional(),
|
||||
appearance: z.string().optional(),
|
||||
voice: z.string().optional(),
|
||||
relationships: z.string().optional(),
|
||||
relationships: z.array(z.any()).optional(),
|
||||
arc: z.string().optional(),
|
||||
status: z.enum(["active", "archived"]).optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
@@ -315,7 +316,7 @@ export function registerRoutes(app: FastifyInstance): void {
|
||||
// ---------- Entities (characters / world / notes) ----------
|
||||
app.get("/api/projects/:id/entities", async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const { kind } = req.query as { kind?: string };
|
||||
const { kind, q } = req.query as { kind?: string; q?: string };
|
||||
requireProjectAccess(app, req, id);
|
||||
let rows: EntityRow[];
|
||||
if (kind) {
|
||||
@@ -323,7 +324,12 @@ export function registerRoutes(app: FastifyInstance): void {
|
||||
} else {
|
||||
rows = db.prepare("SELECT * FROM entities WHERE project_id = ?").all(id) as EntityRow[];
|
||||
}
|
||||
return { entities: rows.map(toEntity) };
|
||||
let entities = rows.map(toEntity);
|
||||
if (q) {
|
||||
const needle = q.toLowerCase();
|
||||
entities = entities.filter((e) => entitySearchText(e).toLowerCase().includes(needle));
|
||||
}
|
||||
return { entities };
|
||||
});
|
||||
|
||||
app.post("/api/projects/:id/entities", async (req) => {
|
||||
@@ -337,7 +343,7 @@ export function registerRoutes(app: FastifyInstance): void {
|
||||
if (body.kind === "character") {
|
||||
entity = {
|
||||
id: eid, projectId: id, name: body.name ?? "", role: body.role ?? "", bio: body.bio ?? "",
|
||||
appearance: body.appearance ?? "", voice: body.voice ?? "", relationships: body.relationships ?? "",
|
||||
appearance: body.appearance ?? "", voice: body.voice ?? "", relationships: body.relationships ?? [],
|
||||
arc: body.arc ?? "", status: body.status ?? "active", tags: body.tags ?? [],
|
||||
};
|
||||
} else if (body.kind === "world") {
|
||||
@@ -374,7 +380,7 @@ export function registerRoutes(app: FastifyInstance): void {
|
||||
if (kind === "character") {
|
||||
entity = {
|
||||
id, projectId, name: body.name ?? existing.name, role: body.role ?? "", bio: body.bio ?? "",
|
||||
appearance: body.appearance ?? "", voice: body.voice ?? "", relationships: body.relationships ?? "",
|
||||
appearance: body.appearance ?? "", voice: body.voice ?? "", relationships: body.relationships ?? [],
|
||||
arc: body.arc ?? "", status: body.status ?? "active", tags: body.tags ?? [],
|
||||
};
|
||||
} else if (kind === "world") {
|
||||
|
||||
Reference in New Issue
Block a user