131 lines
4.8 KiB
TypeScript
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>
|
|
);
|
|
};
|