225 lines
9.6 KiB
TypeScript
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>
|
|
);
|
|
};
|