1
0
Fork 0
tsgames/src/games/storywriter/components/editor.tsx

225 lines
9.6 KiB
TypeScript

import { ContentEditable } from "@common/components/ContentEditable";
import { highlight } from "@common/highlight";
import { useAppState, type Tab } from "../contexts/state";
import styles from '../assets/editor.module.css';
import { useMemo, useRef, useEffect } from "preact/hooks";
import clsx from "clsx";
import { CharacterEditor } from "./character-editor";
import { LocationEditor } from "./location-editor";
import { ChaptersEditor } from "./chapters-editor";
import { LoreEditor } from "./lore-editor";
import { Menu } from "./menu";
import { ChatPanel } from "./chat-sidebar";
import { useInputCallback } from "@common/hooks/useInputCallback";
import Prompt from "../utils/prompt";
import { BookOpen, List, Users, MapPin, BookMarked, FileText, Code, Layers, MessageSquare, Globe, BrainCircuit, MessagesSquare, type LucideIcon } from "lucide-preact";
// Tabs available when a story is selected (regular world)
const STORY_TABS: { id: Tab; label: string; icon: LucideIcon; right?: boolean }[] = [
{ id: "menu", label: "Menu", icon: List },
{ id: "story", label: "Story", icon: BookOpen },
{ id: "chapters", label: "Chapters", icon: Layers },
{ id: "lore", label: "Lore", icon: BookMarked },
{ id: "characters", label: "Characters", icon: Users },
{ id: "locations", label: "Locations", icon: MapPin },
{ id: "scratchpad", label: "Scratchpad", icon: FileText, right: true },
{ id: "prompt", label: "Prompt", icon: Code },
];
// Tabs available when only a world is selected (no story, regular world)
const WORLD_TABS: { id: Tab; label: string; icon: LucideIcon; right?: boolean }[] = [
{ id: "menu", label: "Menu", icon: List },
{ id: "lore", label: "Lore", icon: BookMarked },
{ id: "characters", label: "Characters", icon: Users },
{ id: "locations", label: "Locations", icon: MapPin },
{ id: "system", label: "System", icon: BrainCircuit },
];
// Tabs for a chat session within a chat-only world
const CHAT_STORY_TABS: { id: Tab; label: string; icon: LucideIcon; right?: boolean }[] = [
{ id: "menu", label: "Menu", icon: List },
{ id: "chat", label: "Chat", icon: MessageSquare },
{ id: "scratchpad", label: "Scratchpad", icon: FileText, right: true },
{ id: "prompt", label: "Prompt", icon: Code },
];
// Tabs for a chat-only world with no session selected
const CHAT_WORLD_TABS: { id: Tab; label: string; icon: LucideIcon; right?: boolean }[] = [
{ id: "menu", label: "Menu", icon: List },
{ id: "system", label: "System", icon: BrainCircuit },
];
export const Editor = () => {
const appState = useAppState();
const { currentWorld, currentStory, currentTab, chatOpen, dispatch } = appState;
const handleInput = useInputCallback((text: string) => {
if (!currentStory || !currentWorld) return;
dispatch({ type: 'EDIT_STORY', worldId: currentWorld.id, id: currentStory.id, text });
}, [currentStory?.id, currentWorld?.id]);
const handleScratchpadInput = useInputCallback((text: string) => {
if (!currentStory || !currentWorld) return;
dispatch({ type: 'EDIT_SCRATCHPAD', worldId: currentWorld.id, id: currentStory.id, text });
}, [currentStory?.id, currentWorld?.id]);
const handleSystemOverrideInput = useInputCallback((text: string) => {
if (!currentWorld) return;
dispatch({ type: 'SET_WORLD_SYSTEM_INSTRUCTION_OVERRIDE', worldId: currentWorld.id, systemInstructionOverride: text || undefined });
}, [currentWorld?.id]);
const handleTabChange = (tab: Tab) => {
dispatch({ type: 'SET_CURRENT_TAB', tab });
};
const contentRef = useRef<HTMLDivElement>(null);
const storyValue = useMemo(() => {
if (!currentStory) return '';
const { text, lastEditedText } = currentStory;
if (!lastEditedText) return highlight(text);
const idx = text.lastIndexOf(lastEditedText);
if (idx === -1) return highlight(text);
const marked = text.slice(0, idx) + '<mark>' + lastEditedText + '</mark>' + text.slice(idx + lastEditedText.length);
return highlight(marked);
}, [currentStory?.text, currentStory?.lastEditedText]);
const scratchpadValue = useMemo(() => {
return highlight(currentStory?.scratchpad || '');
}, [currentStory?.scratchpad]);
const promptPreview = useMemo(() => {
if (currentTab !== 'prompt') return '';
const text = Prompt.formatSystemPrompt(appState);
return highlight(text, false);
}, [currentTab, appState]);
const overrideValue = useMemo(() => {
return highlight(currentWorld?.systemInstructionOverride || '');
}, [currentWorld?.systemInstructionOverride]);
useEffect(() => {
if (currentStory?.lastEditedText) {
const raf = requestAnimationFrame(() => {
if (contentRef.current) {
contentRef.current.scrollTo({
top: contentRef.current.scrollHeight,
behavior: 'smooth',
});
}
});
return () => cancelAnimationFrame(raf);
}
}, [currentStory?.lastEditedText]);
useEffect(() => {
const raf = requestAnimationFrame(() => {
if (contentRef.current) {
contentRef.current.scrollTop = contentRef.current.scrollHeight;
}
});
return () => cancelAnimationFrame(raf);
}, [currentStory?.id, currentWorld?.id, currentTab]);
const hasSelection = currentWorld !== null;
const isChatOnly = currentWorld?.chatOnly ?? false;
const tabs = currentStory
? (isChatOnly ? CHAT_STORY_TABS : STORY_TABS)
: currentWorld
? (isChatOnly ? CHAT_WORLD_TABS : WORLD_TABS)
: [{ id: "menu" as Tab, label: "Menu", icon: List }];
// Title bar: use MessagesSquare icon for chat-only worlds
const WorldIcon = isChatOnly ? MessagesSquare : Globe;
const titleBar = currentStory
? <div class={styles.title}>
<span class={styles.titleWorld}>{currentWorld?.title}</span>
<span class={styles.titleSep}>/</span>{currentStory.title}</div>
: currentWorld
? <div class={styles.title}><WorldIcon size={24} />{currentWorld.title}</div>
: null;
const isChatTab = currentTab === 'chat';
return (
<div class={styles.editor}>
{titleBar}
<div class={clsx(styles.content, currentTab === 'menu' && styles.menuContent, isChatTab && styles.chatContent)} ref={contentRef}>
{currentTab === "menu" && (
<Menu />
)}
{currentTab === "story" && currentStory && (
<ContentEditable
class={styles.editable}
value={storyValue}
onInput={handleInput}
placeholder="Start writing your story..."
/>
)}
{currentTab === "lore" && (currentStory || currentWorld) && (
<LoreEditor />
)}
{currentTab === "characters" && (currentStory || currentWorld) && (
<CharacterEditor />
)}
{currentTab === "locations" && (currentStory || currentWorld) && (
<LocationEditor />
)}
{currentTab === "chapters" && currentStory && (
<ChaptersEditor />
)}
{currentTab === "scratchpad" && currentStory && (
<ContentEditable
class={styles.editable}
value={scratchpadValue}
onInput={handleScratchpadInput}
placeholder="Notes, ideas, outlines — anything you don't want in the story..."
/>
)}
{currentTab === "prompt" && currentStory && (
<div class={styles.promptPreview} dangerouslySetInnerHTML={{ __html: promptPreview }} />
)}
{currentTab === "system" && currentWorld && (
<ContentEditable
class={styles.editable}
value={overrideValue}
onInput={handleSystemOverrideInput}
placeholder="Override the global system instruction for this world. Leave empty to use the global setting."
/>
)}
{currentTab === "chat" && currentStory && isChatOnly && (
<ChatPanel />
)}
</div>
<div class={styles.tabs}>
{tabs.filter(tab => hasSelection || tab.id === 'menu').map((tab) => (
<button
key={tab.id}
class={clsx(styles.tab, currentTab === tab.id && styles.active, tab.right && styles.tabRight)}
onClick={() => handleTabChange(tab.id)}
title={tab.label}
>
<tab.icon size={15} />
<span class={styles.tabLabel}>{tab.label}</span>
</button>
))}
{currentStory && !isChatOnly && (
<button
class={clsx(styles.tab, styles.tabRight, chatOpen && styles.active)}
onClick={() => dispatch({ type: 'SET_CHAT_OPEN', open: !chatOpen })}
title="Chat"
>
<MessageSquare size={15} />
<span class={styles.tabLabel}>Chat</span>
</button>
)}
</div>
</div>
);
};