From 4c57bef21ecba2ef2acac50869fab4dd46068b37 Mon Sep 17 00:00:00 2001 From: Pabloader Date: Thu, 19 Mar 2026 15:17:41 +0000 Subject: [PATCH] Stories editor --- src/common/components/ContentEditable.tsx | 50 +++++++++++- .../ai-story/tools => common}/storage.ts | 0 src/games/ai-story/contexts/state.tsx | 2 +- src/games/ai-story/tools/huggingface.ts | 2 +- .../storywriter/assets/editor.module.css | 2 +- .../assets/menu-sidebar.module.css | 44 ++++++++++ src/games/storywriter/assets/style.css | 4 + src/games/storywriter/components/app.tsx | 3 +- src/games/storywriter/components/editor.tsx | 23 +++++- .../storywriter/components/menu-sidebar.tsx | 56 +++++++++++++ src/games/storywriter/components/sidebar.tsx | 6 +- src/games/storywriter/contexts/state.tsx | 80 +++++++++++++++++-- src/games/storywriter/utils/highlight.ts | 42 ++++++++++ 13 files changed, 294 insertions(+), 20 deletions(-) rename src/{games/ai-story/tools => common}/storage.ts (100%) create mode 100644 src/games/storywriter/assets/menu-sidebar.module.css create mode 100644 src/games/storywriter/components/menu-sidebar.tsx create mode 100644 src/games/storywriter/utils/highlight.ts diff --git a/src/common/components/ContentEditable.tsx b/src/common/components/ContentEditable.tsx index 5af070e..3151a3c 100644 --- a/src/common/components/ContentEditable.tsx +++ b/src/common/components/ContentEditable.tsx @@ -6,13 +6,57 @@ type Props = Omit, 'value' | 'onInput'> & { onInput?: JSX.EventHandler>; }; +function getCaretOffset(el: HTMLElement): number { + const sel = window.getSelection(); + if (!sel || sel.rangeCount === 0) return 0; + const range = sel.getRangeAt(0).cloneRange(); + range.selectNodeContents(el); + range.setEnd(sel.getRangeAt(0).endContainer, sel.getRangeAt(0).endOffset); + return range.toString().length; +} + +function setCaretOffset(el: HTMLElement, offset: number) { + const sel = window.getSelection(); + if (!sel) return; + const range = document.createRange(); + let remaining = offset; + + function traverse(node: Node): boolean { + if (node.nodeType === Node.TEXT_NODE) { + const len = node.textContent?.length ?? 0; + if (remaining <= len) { + range.setStart(node, remaining); + range.collapse(true); + return true; + } + remaining -= len; + } else { + for (const child of Array.from(node.childNodes)) { + if (traverse(child)) return true; + } + } + return false; + } + + if (!traverse(el)) { + range.selectNodeContents(el); + range.collapse(false); + } + + sel.removeAllRanges(); + sel.addRange(range); +} + export const ContentEditable = ({ value, onInput, ...props }: Props) => { const ref = useRef(null); useEffect(() => { - if (ref.current && ref.current.innerHTML !== value) { - ref.current.innerHTML = value; - } + const el = ref.current; + if (!el || el.innerHTML === value) return; + + const offset = document.activeElement === el ? getCaretOffset(el) : null; + el.innerHTML = value; + if (offset !== null) setCaretOffset(el, offset); }, [value]); return ( diff --git a/src/games/ai-story/tools/storage.ts b/src/common/storage.ts similarity index 100% rename from src/games/ai-story/tools/storage.ts rename to src/common/storage.ts diff --git a/src/games/ai-story/contexts/state.tsx b/src/games/ai-story/contexts/state.tsx index 2004282..eba53f5 100644 --- a/src/games/ai-story/contexts/state.tsx +++ b/src/games/ai-story/contexts/state.tsx @@ -3,7 +3,7 @@ import { useCallback, useEffect, useMemo, useReducer, useState, type Dispatch, t import { MessageTools, type IMessage } from "../tools/messages"; import { useInputState } from "@common/hooks/useInputState"; import { type IConnection } from "../tools/connection"; -import { loadObject, saveObject } from "../tools/storage"; +import { loadObject, saveObject } from "../../../common/storage"; import { useInputCallback } from "@common/hooks/useInputCallback"; import { callUpdater, throttle } from "@common/utils"; import { Huggingface } from "../tools/huggingface"; diff --git a/src/games/ai-story/tools/huggingface.ts b/src/games/ai-story/tools/huggingface.ts index f8010ea..07c7e91 100644 --- a/src/games/ai-story/tools/huggingface.ts +++ b/src/games/ai-story/tools/huggingface.ts @@ -2,8 +2,8 @@ import { gguf } from '@huggingface/gguf'; import * as hub from '@huggingface/hub'; import { Template } from '@huggingface/jinja'; import { Tokenizer } from '@huggingface/tokenizers'; +import { loadObject, saveObject } from '@common/storage'; import { normalizeModel } from './model'; -import { loadObject, saveObject } from './storage'; export namespace Huggingface { export interface ITemplateMessage { diff --git a/src/games/storywriter/assets/editor.module.css b/src/games/storywriter/assets/editor.module.css index 7aa02f1..c2987c9 100644 --- a/src/games/storywriter/assets/editor.module.css +++ b/src/games/storywriter/assets/editor.module.css @@ -16,7 +16,7 @@ font-family: 'Georgia', serif; font-size: 17px; line-height: 1.9; - color: var(--text-dim); + color: var(--textColor); background: transparent; border: none; outline: none; diff --git a/src/games/storywriter/assets/menu-sidebar.module.css b/src/games/storywriter/assets/menu-sidebar.module.css new file mode 100644 index 0000000..caee5ce --- /dev/null +++ b/src/games/storywriter/assets/menu-sidebar.module.css @@ -0,0 +1,44 @@ +.menu { + display: flex; + flex-direction: column; + gap: 4px; + height: 100%; +} + +.newButton { + width: 100%; + padding: 6px 8px; + text-align: left; + font-size: 13px; + color: var(--accent-alt); + + &:hover { + color: var(--accent-alt); + background: var(--bg-hover); + } +} + +.list { + display: flex; + flex-direction: column; + gap: 2px; +} + +.item { + width: 100%; + padding: 6px 8px; + text-align: left; + font-size: 13px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.active { + color: var(--text); + background: var(--bg-active); + + &:hover { + background: var(--bg-active); + } +} diff --git a/src/games/storywriter/assets/style.css b/src/games/storywriter/assets/style.css index b9aeec8..2e641b4 100644 --- a/src/games/storywriter/assets/style.css +++ b/src/games/storywriter/assets/style.css @@ -17,6 +17,10 @@ --radius: 4px; --transition: 0.15s ease; + + --textColor: #DCDCD2; + --italicColor: #AFAFAF; + --quoteColor: #D4E5FF; } * { diff --git a/src/games/storywriter/components/app.tsx b/src/games/storywriter/components/app.tsx index 35d705e..9b9fc31 100644 --- a/src/games/storywriter/components/app.tsx +++ b/src/games/storywriter/components/app.tsx @@ -1,3 +1,4 @@ +import { MenuSidebar } from "./menu-sidebar"; import { Sidebar } from "./sidebar"; import { Editor } from "./editor"; import styles from '../assets/app.module.css'; @@ -5,7 +6,7 @@ import styles from '../assets/app.module.css'; export const App = () => { return (
- +
diff --git a/src/games/storywriter/components/editor.tsx b/src/games/storywriter/components/editor.tsx index 1d1b3aa..101344c 100644 --- a/src/games/storywriter/components/editor.tsx +++ b/src/games/storywriter/components/editor.tsx @@ -1,15 +1,32 @@ import { ContentEditable } from "@common/components/ContentEditable"; -import { useInputState } from "@common/hooks/useInputState"; +import { useAppState } from "../contexts/state"; import styles from '../assets/editor.module.css'; +import { highlight } from "../utils/highlight"; +import { useMemo } from "preact/hooks"; export const Editor = () => { - const [content, handleInput] = useInputState(''); + const { currentStory, dispatch } = useAppState(); + + if (!currentStory) { + return
; + } + + const handleInput = (e: Event) => { + const text = (e.target as HTMLElement).textContent; + dispatch({ + type: 'EDIT_STORY', + id: currentStory.id, + text, + }); + }; + + const value = useMemo(() => highlight(currentStory.text), [currentStory.text]); return (
diff --git a/src/games/storywriter/components/menu-sidebar.tsx b/src/games/storywriter/components/menu-sidebar.tsx new file mode 100644 index 0000000..699a2dd --- /dev/null +++ b/src/games/storywriter/components/menu-sidebar.tsx @@ -0,0 +1,56 @@ +import clsx from "clsx"; +import { Sidebar } from "./sidebar"; +import { useAppState } from "../contexts/state"; +import type { Story } from "../contexts/state"; +import styles from '../assets/menu-sidebar.module.css'; + +// ─── Story Item ─────────────────────────────────────────────────────────────── + +interface StoryItemProps { + story: Story; + active: boolean; + onSelect: () => void; +} + +const StoryItem = ({ story, active, onSelect }: StoryItemProps) => ( + +); + +// ─── Menu Sidebar ───────────────────────────────────────────────────────────── + +export const MenuSidebar = () => { + const { stories, currentStory, dispatch } = useAppState(); + + const handleCreate = () => { + dispatch({ type: 'CREATE_STORY', title: 'New Story' }); + }; + + const handleSelect = (id: string) => { + dispatch({ type: 'SELECT_STORY', id }); + }; + + return ( + +
+ +
+ {stories.map(story => ( + handleSelect(story.id)} + /> + ))} +
+
+
+ ); +}; diff --git a/src/games/storywriter/components/sidebar.tsx b/src/games/storywriter/components/sidebar.tsx index aa770ad..1b65c42 100644 --- a/src/games/storywriter/components/sidebar.tsx +++ b/src/games/storywriter/components/sidebar.tsx @@ -1,12 +1,14 @@ import clsx from "clsx"; +import type { JSX } from "preact"; import { useBool } from "@common/hooks/useBool"; import styles from '../assets/sidebar.module.css'; interface Props { side: 'left' | 'right'; + children?: JSX.Element | JSX.Element[]; } -export const Sidebar = ({ side }: Props) => { +export const Sidebar = ({ side, children }: Props) => { const open = useBool(true); return ( @@ -16,7 +18,7 @@ export const Sidebar = ({ side }: Props) => { {open.value && (
- {/* TODO */} + {children}
)}
diff --git a/src/games/storywriter/contexts/state.tsx b/src/games/storywriter/contexts/state.tsx index b17c29b..342c53a 100644 --- a/src/games/storywriter/contexts/state.tsx +++ b/src/games/storywriter/contexts/state.tsx @@ -1,34 +1,94 @@ import { createContext } from "preact"; import { useContext, useMemo, useReducer } from "preact/hooks"; +// ─── Types ──────────────────────────────────────────────────────────────────── + +export interface Story { + id: string; + title: string; + text: string; +} + // ─── State ─────────────────────────────────────────────────────────────────── interface IState { - // TODO: define state shape + stories: Story[]; + currentStoryId: string | null; } // ─── Actions ───────────────────────────────────────────────────────────────── type Action = - | { type: 'TODO' }; + | { type: 'CREATE_STORY'; title: string } + | { type: 'RENAME_STORY'; id: string; title: string } + | { type: 'EDIT_STORY'; id: string; text: string } + | { type: 'DELETE_STORY'; id: string } + | { type: 'SELECT_STORY'; id: string }; // ─── Initial State ─────────────────────────────────────────────────────────── const DEFAULT_STATE: IState = { - // TODO + stories: [], + currentStoryId: null, }; // ─── Reducer ───────────────────────────────────────────────────────────────── -function reducer(state: IState, _action: Action): IState { - // TODO: implement action handlers - return state; +function reducer(state: IState, action: Action): IState { + switch (action.type) { + case 'CREATE_STORY': { + const story: Story = { + id: crypto.randomUUID(), + title: action.title, + text: '', + }; + return { + ...state, + stories: [...state.stories, story], + currentStoryId: story.id, + }; + } + case 'RENAME_STORY': { + return { + ...state, + stories: state.stories.map(s => + s.id === action.id ? { ...s, title: action.title } : s + ), + }; + } + case 'EDIT_STORY': { + return { + ...state, + stories: state.stories.map(s => + s.id === action.id ? { ...s, text: action.text } : s + ), + }; + } + case 'DELETE_STORY': { + const remaining = state.stories.filter(s => s.id !== action.id); + const deletingCurrent = state.currentStoryId === action.id; + return { + ...state, + stories: remaining, + currentStoryId: deletingCurrent ? null : state.currentStoryId, + }; + } + case 'SELECT_STORY': { + return { + ...state, + currentStoryId: action.id, + }; + } + default: + return state; + } } // ─── Context ───────────────────────────────────────────────────────────────── interface IStateContext { - state: IState; + stories: Story[]; + currentStory: Story | null; dispatch: (action: Action) => void; } @@ -41,7 +101,11 @@ export const useAppState = () => useContext(StateContext); export const StateContextProvider = ({ children }: { children?: any }) => { const [state, dispatch] = useReducer(reducer, DEFAULT_STATE); - const value = useMemo(() => ({ state, dispatch }), [state]); + const value = useMemo(() => ({ + stories: state.stories, + currentStory: state.stories.find(s => s.id === state.currentStoryId) ?? null, + dispatch, + }), [state]); return ( diff --git a/src/games/storywriter/utils/highlight.ts b/src/games/storywriter/utils/highlight.ts new file mode 100644 index 0000000..99214ac --- /dev/null +++ b/src/games/storywriter/utils/highlight.ts @@ -0,0 +1,42 @@ +export const highlight = (message: string): string => { + const replaceRegex = /(\*\*?|")/ig; + const splitToken = '___SPLIT_AWOORWA___'; + + const preparedMessage = message.replace(replaceRegex, `${splitToken}$1${splitToken}`); + const parts = preparedMessage.split(splitToken); + + const stack: string[] = []; + + let resultHTML = ''; + + for (const part of parts) { + const isClose = stack.at(-1) === part; + if (isClose) { + stack.pop(); + if (part === '*' || part === '**' || part === '"') { + resultHTML += `${part}`; + } + } else { + if (part === '*') { + stack.push(part); + resultHTML += ``; + } else if (part === '**') { + stack.push(part); + resultHTML += ``; + } else if (part === '"') { + stack.push(part); + resultHTML += ``; + } + resultHTML += part; + } + } + + while (stack.length) { + const part = stack.pop(); + if (part === '*' || part === '**' || part === '"') { + resultHTML += ``; + } + } + + return resultHTML; +} \ No newline at end of file