feat(entity): Chunk #4 remaining items — consistency UI, context rail, palette search, expanded seed & DATA_MODEL
- Consistency tab in EntitiesView surfacing findInconsistencies() results - ContextRail + MentionPopover wired into WritingView for entity context/hover - CommandPalette indexes entities and chapters for cross-entity search - link field type for entity references in character fields - findDeadCharacterMentions consistency check + tests - Expand seed with a location, note, and a natural Elias Vell mention so the dead-character-after-death case is actually exercised - Author docs/DATA_MODEL.md documenting core schemas, types, storage contract - Rename CharacterMention id attr -> entityId to match core backlinks/consistency
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useProjectStore } from "../store/project-store.js";
|
||||
import type { NavItem } from "./Sidebar.js";
|
||||
import type { Entity } from "@pubscript/core";
|
||||
|
||||
export interface CommandPaletteProps {
|
||||
open: boolean;
|
||||
@@ -8,11 +10,35 @@ export interface CommandPaletteProps {
|
||||
nav: NavItem[];
|
||||
}
|
||||
|
||||
/** A short label for an entity in the palette. */
|
||||
function entityLabel(e: Entity): string {
|
||||
if ("fields" in e) return e.name;
|
||||
if ("body" in e) return e.body.trim().slice(0, 40) || "(untitled note)";
|
||||
return e.name;
|
||||
}
|
||||
|
||||
/** A short snippet for an entity in the palette. */
|
||||
function entitySnippet(e: Entity): string {
|
||||
if ("fields" in e) {
|
||||
const kind = e.kind;
|
||||
const region = e.fields.region;
|
||||
return kind === "location" ? `Location · ${Array.isArray(region) ? region[0] ?? "" : region ?? ""}` : kind;
|
||||
}
|
||||
if ("body" in e) return e.body.trim().slice(0, 60);
|
||||
return e.role || e.status || "character";
|
||||
}
|
||||
|
||||
type PaletteEntry =
|
||||
| { kind: "command"; item: { id: string; label: string; icon: string; path: string; kbd: string } }
|
||||
| { kind: "chapter"; item: { id: string; title: string } }
|
||||
| { kind: "entity"; item: Entity };
|
||||
|
||||
export function CommandPalette({ open, onClose, nav }: CommandPaletteProps) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [active, setActive] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const navigate = useNavigate();
|
||||
const { entities, chapters } = useProjectStore();
|
||||
|
||||
const commands = useMemo(
|
||||
() =>
|
||||
@@ -26,14 +52,47 @@ export function CommandPalette({ open, onClose, nav }: CommandPaletteProps) {
|
||||
[nav],
|
||||
);
|
||||
|
||||
const filtered = useMemo(
|
||||
() =>
|
||||
query
|
||||
? commands.filter((c) => c.label.toLowerCase().includes(query.toLowerCase()))
|
||||
: commands,
|
||||
[commands, query],
|
||||
const entityResults = useMemo(() => {
|
||||
if (!query.trim()) return [];
|
||||
const q = query.toLowerCase();
|
||||
return entities.filter((e) => entityLabel(e).toLowerCase().includes(q));
|
||||
}, [query, entities]);
|
||||
|
||||
const chapterResults = useMemo(() => {
|
||||
if (!query.trim()) return [];
|
||||
const q = query.toLowerCase();
|
||||
return chapters.filter((c) => c.title.toLowerCase().includes(q));
|
||||
}, [query, chapters]);
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const sections: { title?: string; items: PaletteEntry[] }[] = [];
|
||||
if (query.trim()) {
|
||||
if (chapterResults.length) {
|
||||
sections.push({
|
||||
title: "Chapters",
|
||||
items: chapterResults.map((c) => ({ kind: "chapter", item: { id: c.id, title: c.title } })),
|
||||
});
|
||||
}
|
||||
if (entityResults.length) {
|
||||
sections.push({ title: "Entities", items: entityResults.map((e) => ({ kind: "entity", item: e })) });
|
||||
}
|
||||
}
|
||||
sections.push({
|
||||
items: commands.map((c) => ({ kind: "command" as const, item: c })),
|
||||
});
|
||||
return sections;
|
||||
}, [query, chapterResults, entityResults, commands]);
|
||||
|
||||
// Flatten for arrow-key navigation.
|
||||
const flat = useMemo(
|
||||
() => groups.flatMap((g) => g.items),
|
||||
[groups],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setActive(0);
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setQuery("");
|
||||
@@ -50,20 +109,23 @@ export function CommandPalette({ open, onClose, nav }: CommandPaletteProps) {
|
||||
onClose();
|
||||
} else if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setActive((a) => Math.min(a + 1, filtered.length - 1));
|
||||
setActive((a) => Math.min(a + 1, flat.length - 1));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setActive((a) => Math.max(a - 1, 0));
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (flat[active]) run((flat[active] as { kind: "command"; item: { id: string } }).item);
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [open, filtered, onClose]);
|
||||
}, [open, flat, active, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const run = (id: string) => {
|
||||
navigate(id);
|
||||
const run = (item: { id: string }) => {
|
||||
navigate(item.id);
|
||||
onClose();
|
||||
};
|
||||
|
||||
@@ -78,7 +140,7 @@ export function CommandPalette({ open, onClose, nav }: CommandPaletteProps) {
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="ps-command-input"
|
||||
placeholder="Type a command or search…"
|
||||
placeholder="Search chapters, characters, notes…"
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
@@ -86,20 +148,64 @@ export function CommandPalette({ open, onClose, nav }: CommandPaletteProps) {
|
||||
}}
|
||||
/>
|
||||
<div className="ps-command-list">
|
||||
{filtered.length === 0 ? (
|
||||
{flat.length === 0 ? (
|
||||
<div className="ps-command-empty">No matches</div>
|
||||
) : (
|
||||
filtered.map((c, i) => (
|
||||
<button
|
||||
key={c.id}
|
||||
className={`ps-command-item${i === active ? " is-active" : ""}`}
|
||||
onClick={() => run(c.id)}
|
||||
onMouseEnter={() => setActive(i)}
|
||||
>
|
||||
<span className="ps-command-icon">{c.icon}</span>
|
||||
<span style={{ flex: 1, textAlign: "left" }}>{c.label}</span>
|
||||
<span className="ps-command-kbd">{c.path}</span>
|
||||
</button>
|
||||
groups.map((g, gi) => (
|
||||
<div key={gi}>
|
||||
{g.title ? (
|
||||
<div className="ps-command-group-title">{g.title}</div>
|
||||
) : null}
|
||||
{g.items.map((entry) => {
|
||||
const idx = flat.indexOf(entry);
|
||||
if (entry.kind === "command") {
|
||||
const c = entry.item;
|
||||
return (
|
||||
<button
|
||||
key={c.id}
|
||||
className={`ps-command-item${idx === active ? " is-active" : ""}`}
|
||||
onClick={() => run(c)}
|
||||
onMouseEnter={() => setActive(idx)}
|
||||
>
|
||||
<span className="ps-command-icon">{c.icon}</span>
|
||||
<span style={{ flex: 1, textAlign: "left" }}>{c.label}</span>
|
||||
<span className="ps-command-kbd">{c.path}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
if (entry.kind === "chapter") {
|
||||
const ch = entry.item;
|
||||
return (
|
||||
<button
|
||||
key={ch.id}
|
||||
className={`ps-command-item${idx === active ? " is-active" : ""}`}
|
||||
onClick={() => run({ id: `/` })}
|
||||
onMouseEnter={() => setActive(idx)}
|
||||
>
|
||||
<span className="ps-command-icon">📖</span>
|
||||
<span style={{ flex: 1, textAlign: "left" }}>{ch.title}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
const e = entry.item;
|
||||
return (
|
||||
<button
|
||||
key={e.id}
|
||||
className={`ps-command-item${idx === active ? " is-active" : ""}`}
|
||||
onClick={() => run({ id: `/characters?entity=${encodeURIComponent(e.id)}` })}
|
||||
onMouseEnter={() => setActive(idx)}
|
||||
>
|
||||
<span className="ps-command-icon">
|
||||
{"fields" in e ? "🌍" : "body" in e ? "📝" : "👤"}
|
||||
</span>
|
||||
<span style={{ flex: 1, textAlign: "left" }}>
|
||||
<div>{entityLabel(e)}</div>
|
||||
<div className="ps-command-subtitle">{entitySnippet(e)}</div>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -8,10 +8,13 @@
|
||||
* - shows word count, scene count, and an autosave indicator.
|
||||
*/
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
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";
|
||||
import { ContextRail } from "../features/entities/ContextRail.js";
|
||||
import { MentionPopover } from "../features/entities/MentionPopover.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"]);
|
||||
@@ -49,6 +52,7 @@ interface WritingViewProps {
|
||||
}
|
||||
|
||||
export function WritingView({ onChapterChange }: WritingViewProps) {
|
||||
const navigate = useNavigate();
|
||||
const project = useProjectStore((s) => s.project);
|
||||
const chapters = useProjectStore((s) => s.chapters);
|
||||
const activeChapterId = useProjectStore((s) => s.activeChapterId);
|
||||
@@ -56,6 +60,7 @@ export function WritingView({ onChapterChange }: WritingViewProps) {
|
||||
const scheduleSave = useProjectStore((s) => s.scheduleSave);
|
||||
const flushAutosave = useProjectStore((s) => s.flushAutosave);
|
||||
const autosave = useProjectStore((s) => s.autosave);
|
||||
const entities = useProjectStore((s) => s.entities);
|
||||
|
||||
const focusMode = useSettings((st) => st.focusMode);
|
||||
const typewriterMode = useSettings((st) => st.typewriterMode);
|
||||
@@ -230,7 +235,17 @@ export function WritingView({ onChapterChange }: WritingViewProps) {
|
||||
<div className={`ps-content ps-editor-writing${focusMode ? " ps-editor-focus" : ""}`}>
|
||||
<div className="ps-editor" ref={editorDomRef}>
|
||||
<EditorContent editor={editor} />
|
||||
<MentionPopover
|
||||
entities={entities}
|
||||
containerRef={editorDomRef}
|
||||
onOpen={(id) => navigate(`/characters?entity=${encodeURIComponent(id)}`)}
|
||||
/>
|
||||
</div>
|
||||
<ContextRail
|
||||
chapter={chapter}
|
||||
entities={entities}
|
||||
onOpenEntity={(id) => navigate(`/characters?entity=${encodeURIComponent(id)}`)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="ps-writing-status">
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* ContextRail — the right-hand rail on the writing surface.
|
||||
*
|
||||
* Shows three things about the current chapter:
|
||||
* - characters mentioned in this chapter (auto-derived from `@` mentions),
|
||||
* - pinned notes (the author's quick-reference stack),
|
||||
* - notes linked to this chapter (`linkedTo`).
|
||||
*
|
||||
* Each item opens its entity sheet when clicked.
|
||||
*/
|
||||
import React from "react";
|
||||
import type { Chapter, Entity } from "@pubscript/core";
|
||||
import { collectMentionsInDoc } from "@pubscript/core";
|
||||
|
||||
interface ContextRailProps {
|
||||
chapter: Chapter | null;
|
||||
entities: Entity[];
|
||||
onOpenEntity: (id: string) => void;
|
||||
}
|
||||
|
||||
/** True for a character entity (has `relationships`, no `fields`/`body`). */
|
||||
function isCharacter(e: Entity): e is Extract<Entity, { relationships: unknown }> {
|
||||
return !("fields" in e) && !("body" in e);
|
||||
}
|
||||
|
||||
/** A short label for an entity in the rail, regardless of kind. */
|
||||
function label(e: Entity): string {
|
||||
if ("fields" in e) return e.name;
|
||||
if ("body" in e) return e.body.trim().slice(0, 40) || "(untitled note)";
|
||||
return e.name;
|
||||
}
|
||||
|
||||
export function ContextRail({ chapter, entities, onOpenEntity }: ContextRailProps) {
|
||||
const mentionedIds = chapter ? collectMentionsInDoc(chapter.doc) : new Set<string>();
|
||||
|
||||
// Characters mentioned in this chapter, in mention order, deduped.
|
||||
const mentionedChars = React.useMemo(() => {
|
||||
const seen = new Set<string>();
|
||||
const out: Entity[] = [];
|
||||
for (const id of mentionedIds) {
|
||||
if (seen.has(id)) continue;
|
||||
const e = entities.find((x) => x.id === id);
|
||||
if (e && isCharacter(e) && !seen.has(id)) {
|
||||
seen.add(id);
|
||||
out.push(e);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}, [mentionedIds, entities]);
|
||||
|
||||
const pinnedNotes = React.useMemo(
|
||||
() => entities.filter((e) => "body" in e && e.pinned),
|
||||
[entities],
|
||||
);
|
||||
|
||||
const relatedNotes = React.useMemo(
|
||||
() => entities.filter((e) => "body" in e && e.linkedTo === chapter?.id),
|
||||
[entities, chapter?.id],
|
||||
);
|
||||
|
||||
const Section = ({
|
||||
title,
|
||||
items,
|
||||
}: {
|
||||
title: string;
|
||||
items: Entity[];
|
||||
}) => (
|
||||
<section className="ps-context-section">
|
||||
<h3 className="ps-context-section-title">
|
||||
{title}
|
||||
<span className="ps-context-count">{items.length}</span>
|
||||
</h3>
|
||||
{items.length === 0 ? (
|
||||
<p className="ps-context-empty">—</p>
|
||||
) : (
|
||||
<ul className="ps-context-list">
|
||||
{items.map((e) => (
|
||||
<li key={e.id}>
|
||||
<button
|
||||
type="button"
|
||||
className="ps-context-item"
|
||||
onClick={() => onOpenEntity(e.id)}
|
||||
>
|
||||
<span className="ps-context-item-name">{label(e)}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
|
||||
return (
|
||||
<aside className="ps-context-rail">
|
||||
<Section title="In this chapter" items={mentionedChars} />
|
||||
<Section title="Pinned" items={pinnedNotes} />
|
||||
<Section title="Linked notes" items={relatedNotes} />
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
export default ContextRail;
|
||||
@@ -6,20 +6,23 @@
|
||||
* 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 { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import type { ConsistencyIssue, Entity } from "@pubscript/core";
|
||||
import { fuzzySort, findInconsistencies } 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";
|
||||
type TabId = "all" | "people" | "places" | "notes" | "consistency";
|
||||
|
||||
interface Tab {
|
||||
id: TabId;
|
||||
label: string;
|
||||
/** Filter predicate over entities, or null for "all". */
|
||||
match: (e: Entity) => boolean;
|
||||
/** When true, the tab shows a special view instead of entity cards. */
|
||||
special?: boolean;
|
||||
}
|
||||
|
||||
const TABS: Tab[] = [
|
||||
@@ -27,6 +30,7 @@ const TABS: Tab[] = [
|
||||
{ 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 },
|
||||
{ id: "consistency", label: "Consistency", match: () => true, special: true },
|
||||
];
|
||||
|
||||
const KIND_OPTIONS = [
|
||||
@@ -49,6 +53,8 @@ function searchText(e: Entity): string {
|
||||
}
|
||||
|
||||
export function EntitiesView() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const entities = useProjectStore((s) => s.entities);
|
||||
const createEntity = useProjectStore((s) => s.createEntity);
|
||||
const updateEntity = useProjectStore((s) => s.updateEntity);
|
||||
@@ -60,6 +66,24 @@ export function EntitiesView() {
|
||||
const [draft, setDraft] = React.useState<Entity | null>(null);
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
// Run every consistency check over the current entities and chapters.
|
||||
const chapters = useProjectStore((s) => s.chapters);
|
||||
const issues = React.useMemo(() => findInconsistencies(chapters, entities), [chapters, entities]);
|
||||
|
||||
// Open a specific entity when linked from elsewhere (e.g. the writing-view
|
||||
// context rail: `/characters?entity=<id>`).
|
||||
React.useEffect(() => {
|
||||
const id = searchParams.get("entity");
|
||||
if (!id) return;
|
||||
const e = entities.find((x) => x.id === id);
|
||||
if (e) {
|
||||
setDraft(e);
|
||||
setOpen(true);
|
||||
}
|
||||
// Clear the param so a reload doesn't re-open the drawer.
|
||||
navigate("/characters", { replace: true });
|
||||
}, [searchParams, entities, navigate]);
|
||||
|
||||
const visible = React.useMemo(() => {
|
||||
const filtered = entities.filter((e) => TABS.find((t) => t.id === tab)!.match(e));
|
||||
return query ? fuzzySort(filtered, query, searchText) : filtered;
|
||||
@@ -121,7 +145,12 @@ export function EntitiesView() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{visible.length === 0 ? (
|
||||
{tab === "consistency" ? (
|
||||
<ConsistencyPanel issues={issues} onOpenEntity={(id) => {
|
||||
const e = entities.find((x) => x.id === id);
|
||||
if (e) { setDraft(e); setOpen(true); }
|
||||
}} />
|
||||
) : visible.length === 0 ? (
|
||||
<p className="ps-muted">No entities match.</p>
|
||||
) : (
|
||||
<div className="ps-entity-grid">
|
||||
@@ -184,3 +213,94 @@ function tagsFor(e: Entity): string[] {
|
||||
if ("body" in e) return e.tags;
|
||||
return e.tags;
|
||||
}
|
||||
|
||||
interface ConsistencyPanelProps {
|
||||
issues: ConsistencyIssue[];
|
||||
onOpenEntity: (id: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Consistency tab — shows the combined output of `findInconsistencies()` as a
|
||||
* list of warnings. Each issue names the affected entity, which can be opened
|
||||
* in the editor drawer.
|
||||
*/
|
||||
function ConsistencyPanel({ issues, onOpenEntity }: ConsistencyPanelProps) {
|
||||
if (issues.length === 0) {
|
||||
return (
|
||||
<div className="ps-consistency-empty">
|
||||
<div className="ps-consistency-empty-icon">✓</div>
|
||||
<p className="ps-muted">No inconsistencies found. Your entity registry and chapters are consistent.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ps-consistency-panel">
|
||||
<div className="ps-consistency-header">
|
||||
<span className="ps-consistency-count">{issues.length} issue{issues.length === 1 ? "" : "s"}</span>
|
||||
</div>
|
||||
<ul className="ps-consistency-list">
|
||||
{issues.map((issue, i) => (
|
||||
<li key={`${issue.kind}-${i}`} className="ps-consistency-item">
|
||||
<span className="ps-consistency-icon">{iconFor(issue)}</span>
|
||||
<div className="ps-consistency-body">
|
||||
<div className="ps-consistency-title">{titleFor(issue)}</div>
|
||||
<div className="ps-consistency-detail">{detailFor(issue)}</div>
|
||||
</div>
|
||||
{(issue.kind === "orphan-mention" || issue.kind === "dead-character-mentioned") && (
|
||||
<button
|
||||
type="button"
|
||||
className="ps-consistency-open"
|
||||
onClick={() => onOpenEntity(issue.entityId)}
|
||||
>
|
||||
Open
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Severity icon for an issue. */
|
||||
function iconFor(issue: ConsistencyIssue): string {
|
||||
switch (issue.kind) {
|
||||
case "orphan-mention":
|
||||
return "⚠";
|
||||
case "dead-character-mentioned":
|
||||
return "💀";
|
||||
case "relationship-asymmetry":
|
||||
return "↔";
|
||||
default:
|
||||
return "•";
|
||||
}
|
||||
}
|
||||
|
||||
/** Primary label for an issue. */
|
||||
function titleFor(issue: ConsistencyIssue): string {
|
||||
switch (issue.kind) {
|
||||
case "orphan-mention":
|
||||
return `Orphaned mention in “${issue.chapterTitle}”`;
|
||||
case "dead-character-mentioned":
|
||||
return `Dead character mentioned in “${issue.chapterTitle}”`;
|
||||
case "relationship-asymmetry":
|
||||
return `Unreciprocated ${issue.relationship} relationship`;
|
||||
default:
|
||||
return "Consistency issue";
|
||||
}
|
||||
}
|
||||
|
||||
/** Secondary explanation for an issue. */
|
||||
function detailFor(issue: ConsistencyIssue): string {
|
||||
switch (issue.kind) {
|
||||
case "orphan-mention":
|
||||
return `A mention references an entity that no longer exists.`;
|
||||
case "dead-character-mentioned":
|
||||
return `${issue.name ?? "A character"} is marked dead but still appears in the chapter.`;
|
||||
case "relationship-asymmetry":
|
||||
return `${issue.fromName} lists ${issue.toName} as “${issue.relationship}”, but the feeling isn't recorded on the other side.`;
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ export function EntityEditor({
|
||||
<div className="ps-entity-editor-body">
|
||||
{isCharacterDraft ? (
|
||||
<>
|
||||
<EntityFields entity={draft} onChange={patch} />
|
||||
<EntityFields entity={draft} onChange={patch} entities={entities} />
|
||||
<div className="ps-relationship-section">
|
||||
<h3>Relationships</h3>
|
||||
<RelationshipEditor
|
||||
@@ -85,7 +85,7 @@ export function EntityEditor({
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<EntityFields entity={draft} onChange={patch} />
|
||||
<EntityFields entity={draft} onChange={patch} entities={entities} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -12,6 +12,14 @@ import { ENTITY_FIELDS } from "@pubscript/core";
|
||||
import { Input } from "@pubscript/ui";
|
||||
import { Select } from "@pubscript/ui";
|
||||
|
||||
/** A short display label for an entity, regardless of kind. */
|
||||
export function entityLabel(e: Entity): string {
|
||||
if ("body" in e) {
|
||||
return e.body.trim().slice(0, 40) || "(untitled note)";
|
||||
}
|
||||
return e.name;
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
@@ -40,12 +48,15 @@ export function fieldsFor(e: Entity): FieldDef[] {
|
||||
interface EntityFieldsProps {
|
||||
entity: Entity;
|
||||
onChange: (patch: Partial<Entity>) => void;
|
||||
/** All entities in the book, used to resolve `link` field references. */
|
||||
entities?: Entity[];
|
||||
}
|
||||
|
||||
function renderField(
|
||||
field: FieldDef,
|
||||
value: string | string[],
|
||||
onChange: (v: string | string[]) => void,
|
||||
entities?: Entity[],
|
||||
) {
|
||||
const label = (
|
||||
<label className="ps-field-label">
|
||||
@@ -79,9 +90,22 @@ function renderField(
|
||||
);
|
||||
case "list":
|
||||
return <TagField value={Array.isArray(value) ? value : []} onChange={onChange} />;
|
||||
case "link":
|
||||
case "link": {
|
||||
const opts = (entities ?? [])
|
||||
.filter((e) => e.id !== value)
|
||||
.map((e) => ({ value: e.id, label: entityLabel(e) }));
|
||||
return (
|
||||
<div>
|
||||
{label}
|
||||
<Select
|
||||
value={typeof value === "string" ? value : ""}
|
||||
options={[{ value: "", label: "— none —" }, ...opts]}
|
||||
onChange={(ev) => onChange(ev.target.value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "text":
|
||||
default:
|
||||
return (
|
||||
<div className="ps-field-span-2">
|
||||
{label}
|
||||
@@ -146,7 +170,7 @@ function TagField({
|
||||
);
|
||||
}
|
||||
|
||||
export function EntityFields({ entity, onChange }: EntityFieldsProps) {
|
||||
export function EntityFields({ entity, onChange, entities }: EntityFieldsProps) {
|
||||
const fields = fieldsFor(entity);
|
||||
|
||||
return (
|
||||
@@ -157,6 +181,7 @@ export function EntityFields({ entity, onChange }: EntityFieldsProps) {
|
||||
field,
|
||||
getFieldValue(entity, field.key),
|
||||
(v) => onChange({ [field.key]: v } as Partial<Entity>),
|
||||
entities,
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* MentionPopover — a hover popover for `@` mention chips inside the editor.
|
||||
*
|
||||
* The editor renders each mention as `<span class="mention mention-character"
|
||||
* data-id="...">`. This component delegates mouse events on the editor content
|
||||
* container: hovering a chip looks up the entity and shows a small popover with
|
||||
* a one-line preview and an "Open sheet" action. Clicking the chip itself is
|
||||
* left to the editor (atoms are inert), so the only click target is the button.
|
||||
*/
|
||||
import React from "react";
|
||||
import type { Entity } from "@pubscript/core";
|
||||
|
||||
interface MentionPopoverProps {
|
||||
/** All entities, used to resolve a hovered chip's `data-id`. */
|
||||
entities: Entity[];
|
||||
/** Called when the user opens the sheet for a mention. */
|
||||
onOpen: (id: string) => void;
|
||||
/** Ref to the editor content container that hosts the mention chips. */
|
||||
containerRef: React.RefObject<HTMLElement | null>;
|
||||
}
|
||||
|
||||
/** A one-line preview for an entity, regardless of kind. */
|
||||
function preview(e: Entity): string {
|
||||
if ("fields" in e) {
|
||||
const d = (e.fields.description ?? e.fields.bio ?? "") as string;
|
||||
return d.replace(/\s+/g, " ").trim().slice(0, 120);
|
||||
}
|
||||
if ("body" in e) {
|
||||
return e.body.replace(/\s+/g, " ").trim().slice(0, 120);
|
||||
}
|
||||
// character
|
||||
return (e as { bio?: string }).bio?.replace(/\s+/g, " ").trim().slice(0, 120) ?? "";
|
||||
}
|
||||
|
||||
/** A short display label for an entity, regardless of kind. */
|
||||
function label(e: Entity): string {
|
||||
if ("body" in e) {
|
||||
return e.body.trim().slice(0, 40) || "(untitled note)";
|
||||
}
|
||||
return e.name;
|
||||
}
|
||||
|
||||
export function MentionPopover({ entities, onOpen, containerRef }: MentionPopoverProps) {
|
||||
const [state, setState] = React.useState<{
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
} | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const onOver = (ev: MouseEvent) => {
|
||||
const target = ev.target as HTMLElement | null;
|
||||
const chip = target?.closest?.("span.mention-character") as HTMLElement | null;
|
||||
if (!chip) return;
|
||||
const id = chip.getAttribute("data-id");
|
||||
if (!id) return;
|
||||
const rect = chip.getBoundingClientRect();
|
||||
setState({ id, x: rect.left, y: rect.bottom + 6 });
|
||||
};
|
||||
|
||||
const onOut = (ev: MouseEvent) => {
|
||||
const related = ev.relatedTarget as HTMLElement | null;
|
||||
if (related?.closest?.("span.mention-character")) return;
|
||||
setState(null);
|
||||
};
|
||||
|
||||
el.addEventListener("mouseover", onOver);
|
||||
el.addEventListener("mouseout", onOut);
|
||||
return () => {
|
||||
el.removeEventListener("mouseover", onOver);
|
||||
el.removeEventListener("mouseout", onOut);
|
||||
};
|
||||
}, [containerRef]);
|
||||
|
||||
if (!state) return null;
|
||||
|
||||
const entity = entities.find((e) => e.id === state.id) ?? null;
|
||||
// Guard against hovering a chip whose entity no longer exists.
|
||||
if (!entity) return null;
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
position: "fixed",
|
||||
left: `${Math.min(state.x, window.innerWidth - 240)}px`,
|
||||
top: `${Math.min(state.y, window.innerHeight - 120)}px`,
|
||||
zIndex: 1000,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ps-mention-popover" style={style}>
|
||||
<div className="ps-mention-popover-name">{label(entity)}</div>
|
||||
<div className="ps-mention-popover-preview">{preview(entity)}</div>
|
||||
<button
|
||||
type="button"
|
||||
className="ps-mention-popover-open"
|
||||
onClick={() => onOpen(entity.id)}
|
||||
>
|
||||
Open sheet
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MentionPopover;
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
createUlid,
|
||||
type Board,
|
||||
type Character,
|
||||
type Note,
|
||||
type ProseMirrorDoc,
|
||||
type WorldItem,
|
||||
type StorageAdapter,
|
||||
@@ -20,12 +21,29 @@ function para(text: string): ProseMirrorDoc["content"][0] {
|
||||
return { type: "paragraph", content: [{ type: "text", text }] };
|
||||
}
|
||||
|
||||
/** Build a ProseMirror doc from an array of paragraph texts. */
|
||||
function doc(paras: string[]): ProseMirrorDoc {
|
||||
return { type: "doc", content: paras.map(para) };
|
||||
/** A paragraph containing a character mention atom (used to exercise backlinks & consistency). */
|
||||
function paraMention(
|
||||
before: string,
|
||||
entityId: string,
|
||||
label: string,
|
||||
after: string,
|
||||
): ProseMirrorDoc["content"][0] {
|
||||
return {
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{ type: "text", text: before },
|
||||
{ type: "CharacterMention", attrs: { entityId, label } },
|
||||
{ type: "text", text: after },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const CHAPTERS: { title: string; paras: string[] }[] = [
|
||||
/** Build a ProseMirror doc from an array of paragraph texts or nodes. */
|
||||
function doc(paras: Array<string | ProseMirrorDoc["content"][0]>): ProseMirrorDoc {
|
||||
return { type: "doc", content: paras.map((p) => (typeof p === "string" ? para(p) : p)) };
|
||||
}
|
||||
|
||||
const CHAPTERS: { title: string; paras: Array<string | ProseMirrorDoc["content"][0]> }[] = [
|
||||
{
|
||||
title: "Chapter One — The Arrival",
|
||||
paras: [
|
||||
@@ -55,6 +73,12 @@ const CHAPTERS: { title: string; paras: string[] }[] = [
|
||||
"Mara walked the shoreline and found, half-buried in the wrack, a shard of glass the size of her palm — but the glass was warm, and it held inside it a light that did not belong to the rising sun.",
|
||||
"This was the secret the Glass Harbor had kept for a hundred years: the light of the lighthouse was not made, it was mined — drawn up from the deep in shards of living glass that only a keeper knew how to kindle.",
|
||||
"She understood then why the old keeper had never waved, and why he had never left. Some duties are not chosen. They are inherited, like the tide.",
|
||||
paraMention(
|
||||
"Her father, ",
|
||||
"char-elias-vell",
|
||||
"Elias Vell",
|
||||
" had drowned keeping the light when she was fifteen — and now the duty he left behind felt heavier than the whole sea.",
|
||||
),
|
||||
"When Tomas Reed returned from his rounds that evening, he found Mara at the lamp, her hands on the warm glass, and he stopped in the doorway with the old grief on his face.",
|
||||
'"I can teach you," he said. "If you want it. No one has ever wanted it before."',
|
||||
"Mara looked out at the water, at the fragile line of the quay, at the little lights of the town that had welcomed her without asking why she had come.",
|
||||
@@ -128,6 +152,21 @@ const CHARACTERS: Character[] = [
|
||||
status: "active",
|
||||
tags: ["mysterious", "hermit"],
|
||||
},
|
||||
{
|
||||
id: "char-elias-vell",
|
||||
projectId: "",
|
||||
name: "Elias Vell",
|
||||
role: "The Keeper Who Died",
|
||||
bio: "Mara's father and the lighthouse keeper before Tomas. Drowned keeping the light through the Great Storm of Mara's fifteenth year. He is gone, but his absence shapes everyone who loved him — especially the daughter who left and came back.",
|
||||
appearance: "Salt of Mara's own hair, a keeper's sun-leathered face, hands gone blue from the deep glass.",
|
||||
voice: "Never heard. Remembered for the songs he hummed while turning the lamp.",
|
||||
relationships: [
|
||||
{ targetId: "char-mara-vell", kind: "family", note: "Daughter; the one he could not save from the harbor, or from himself." },
|
||||
],
|
||||
arc: "A grief inherited, not chosen — the weight Mara carries and must finally set down.",
|
||||
status: "dead",
|
||||
tags: ["ghost", "father", "keeper"],
|
||||
},
|
||||
];
|
||||
|
||||
const WORLD: WorldItem = {
|
||||
@@ -146,6 +185,30 @@ const WORLD: WorldItem = {
|
||||
},
|
||||
};
|
||||
|
||||
const LIGHTHOUSE: WorldItem = {
|
||||
id: "world-lighthouse",
|
||||
projectId: "",
|
||||
kind: "location",
|
||||
name: "The Lighthouse",
|
||||
fields: {
|
||||
kind: "location",
|
||||
description:
|
||||
"A tall white tower on the northern point of the rock, reached by one hundred and fourteen slick steps. Its lamp is fed by shards of living glass, and the mechanism that turns the beam has seized more than once.",
|
||||
population: "—",
|
||||
economy: "—",
|
||||
notable: "The Lamp (where the beam is kindled) · The Gears (that seize with salt and rust) · The Stairs (one hundred and fourteen, and counting).",
|
||||
},
|
||||
};
|
||||
|
||||
const ARRIVAL_NOTE: Note = {
|
||||
id: "note-arrival",
|
||||
projectId: "",
|
||||
body: "Mara Vell returns to the Glass Harbor three years after fleeing it. She came for a reason she has never named — and the harbor, its failing light, and the people who keep it are not the same as the memory she carried away. What is the grief she runs from, and why does the glass answer when she says she wants the light?",
|
||||
pinned: true,
|
||||
tags: ["plot", "act-one"],
|
||||
linkedTo: "char-mara-vell",
|
||||
};
|
||||
|
||||
const BOARD: Board = {
|
||||
id: "board-story",
|
||||
projectId: "",
|
||||
@@ -193,6 +256,8 @@ export async function seedDemoProject(adapter: StorageAdapter): Promise<string>
|
||||
await adapter.saveEntity({ ...character, projectId: project.id });
|
||||
}
|
||||
await adapter.saveEntity({ ...WORLD, projectId: project.id });
|
||||
await adapter.saveEntity({ ...LIGHTHOUSE, projectId: project.id });
|
||||
await adapter.saveEntity({ ...ARRIVAL_NOTE, projectId: project.id });
|
||||
await adapter.saveBoard({ ...BOARD, projectId: project.id });
|
||||
|
||||
return project.id;
|
||||
|
||||
@@ -464,3 +464,242 @@
|
||||
fill: var(--ps-text-muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ---- Context rail (writing surface) ---- */
|
||||
.ps-context-rail {
|
||||
flex: 0 0 260px;
|
||||
overflow-y: auto;
|
||||
border-left: 1px solid var(--ps-border);
|
||||
padding: 16px 14px;
|
||||
background: var(--ps-surface);
|
||||
font-family: var(--ps-font-ui);
|
||||
}
|
||||
|
||||
.ps-context-section {
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.ps-context-section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--ps-text-muted);
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.ps-context-count {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--ps-text-muted);
|
||||
background: var(--ps-surface-2);
|
||||
border: 1px solid var(--ps-border);
|
||||
border-radius: 999px;
|
||||
padding: 0 7px;
|
||||
min-width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ps-context-empty {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--ps-text-muted);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.ps-context-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.ps-context-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
padding: 7px 10px;
|
||||
background: var(--ps-surface-2);
|
||||
font-family: var(--ps-font-ui);
|
||||
font-size: 13px;
|
||||
color: var(--ps-text);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.ps-context-item:hover {
|
||||
border-color: var(--ps-accent);
|
||||
background: color-mix(in srgb, var(--ps-accent) 12%, transparent);
|
||||
}
|
||||
|
||||
.ps-context-item-name {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ---- Mention hover popover (writing surface) ---- */
|
||||
.ps-mention-popover {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
width: 240px;
|
||||
background: var(--ps-surface-2);
|
||||
border: 1px solid var(--ps-border);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.16);
|
||||
padding: 12px 14px;
|
||||
font-family: var(--ps-font-ui);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.ps-mention-popover-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--ps-text);
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.ps-mention-popover-preview {
|
||||
font-size: 13px;
|
||||
color: var(--ps-text-muted);
|
||||
margin: 0 0 10px;
|
||||
line-height: 1.4;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ps-mention-popover-open {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
border: none;
|
||||
border-radius: 7px;
|
||||
padding: 6px 10px;
|
||||
background: var(--ps-accent);
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
font-family: var(--ps-font-ui);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ps-mention-popover-open:hover {
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
|
||||
/* ---- Consistency panel ---- */
|
||||
|
||||
.ps-consistency-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 64px 24px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.ps-consistency-empty-icon {
|
||||
font-size: 40px;
|
||||
line-height: 1;
|
||||
color: var(--ps-success);
|
||||
}
|
||||
|
||||
.ps-consistency-panel {
|
||||
border: 1px solid var(--ps-border);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
background: var(--ps-surface);
|
||||
}
|
||||
|
||||
.ps-consistency-header {
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--ps-border);
|
||||
background: var(--ps-surface-alt);
|
||||
}
|
||||
|
||||
.ps-consistency-count {
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
color: var(--ps-text);
|
||||
}
|
||||
|
||||
.ps-consistency-count:empty {
|
||||
color: var(--ps-success);
|
||||
}
|
||||
|
||||
.ps-consistency-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.ps-consistency-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--ps-border);
|
||||
}
|
||||
|
||||
.ps-consistency-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.ps-consistency-icon {
|
||||
font-size: 18px;
|
||||
line-height: 1.3;
|
||||
width: 24px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ps-consistency-item:not(:last-child) .ps-consistency-icon {
|
||||
color: var(--ps-warning);
|
||||
}
|
||||
|
||||
.ps-consistency-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ps-consistency-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--ps-text);
|
||||
}
|
||||
|
||||
.ps-consistency-detail {
|
||||
font-size: 13px;
|
||||
color: var(--ps-text-muted);
|
||||
margin-top: 3px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.ps-consistency-open {
|
||||
flex-shrink: 0;
|
||||
border: 1px solid var(--ps-border);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--ps-text);
|
||||
padding: 5px 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-family: var(--ps-font-ui);
|
||||
}
|
||||
|
||||
.ps-consistency-open:hover {
|
||||
border-color: var(--ps-accent);
|
||||
color: var(--ps-accent);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user