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

131 lines
4.8 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 { useInputCallback } from "@common/hooks/useInputCallback";
import Prompt from "../utils/prompt";
const TABS: { id: Tab; label: string; right?: boolean }[] = [
{ id: "story", label: "Story" },
{ id: "chapters", label: "Chapters" },
{ id: "lore", label: "Lore" },
{ id: "characters", label: "Characters" },
{ id: "locations", label: "Locations" },
{ id: "scratchpad", label: "Scratchpad", right: true },
{ id: "prompt", label: "Prompt" },
];
export const Editor = () => {
const appState = useAppState();
const { currentStory, dispatch } = appState;
const handleInput = useInputCallback((text: string) => {
if (!currentStory) return;
dispatch({ type: 'EDIT_STORY', id: currentStory.id, text });
}, [currentStory?.id]);
const handleScratchpadInput = useInputCallback((text: string) => {
if (!currentStory) return;
dispatch({ type: 'EDIT_SCRATCHPAD', id: currentStory.id, text });
}, [currentStory?.id]);
const handleTabChange = (tab: Tab) => {
if (!currentStory) return;
dispatch({
type: 'SET_CURRENT_TAB',
id: currentStory.id,
tab,
});
};
const contentRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (contentRef.current) {
contentRef.current.scrollTop = contentRef.current.scrollHeight;
}
}, [currentStory?.id, currentStory?.currentTab]);
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 promptPreview = useMemo(() => {
if (currentStory?.currentTab !== 'prompt') return '';
const text = Prompt.formatSystemPrompt(appState);
return highlight(text, false);
}, [currentStory?.currentTab, appState]);
if (!currentStory) {
return <div class={styles.editor} />;
}
return (
<div class={styles.editor}>
<div class={styles.title}>
{currentStory.title}
</div>
<div class={styles.content} ref={contentRef}>
{currentStory.currentTab === "story" && (
<ContentEditable
class={styles.editable}
value={storyValue}
onInput={handleInput}
placeholder="Start writing your story..."
/>
)}
{currentStory.currentTab === "lore" && (
<LoreEditor />
)}
{currentStory.currentTab === "characters" && (
<CharacterEditor />
)}
{currentStory.currentTab === "locations" && (
<LocationEditor />
)}
{currentStory.currentTab === "chapters" && (
<ChaptersEditor />
)}
{currentStory.currentTab === "scratchpad" && (
<ContentEditable
class={styles.editable}
value={currentStory.scratchpad ?? ''}
onInput={handleScratchpadInput}
placeholder="Notes, ideas, outlines — anything you don't want in the story..."
/>
)}
{currentStory.currentTab === "prompt" && (
<div class={styles.promptPreview} dangerouslySetInnerHTML={{ __html: promptPreview }} />
)}
</div>
<div class={styles.tabs}>
{TABS.map((tab) => (
<button
key={tab.id}
class={clsx(styles.tab, currentStory.currentTab === tab.id && styles.active, tab.right && styles.tabRight)}
onClick={() => handleTabChange(tab.id)}
>
{tab.label}
</button>
))}
</div>
</div>
);
};