1
0
Fork 0

Stories editor

This commit is contained in:
Pabloader 2026-03-19 15:17:41 +00:00
parent e3321eac89
commit 4c57bef21e
13 changed files with 294 additions and 20 deletions

View File

@ -6,13 +6,57 @@ type Props = Omit<JSX.HTMLAttributes<HTMLDivElement>, 'value' | 'onInput'> & {
onInput?: JSX.EventHandler<JSX.TargetedInputEvent<HTMLDivElement>>; onInput?: JSX.EventHandler<JSX.TargetedInputEvent<HTMLDivElement>>;
}; };
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) => { export const ContentEditable = ({ value, onInput, ...props }: Props) => {
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
if (ref.current && ref.current.innerHTML !== value) { const el = ref.current;
ref.current.innerHTML = value; if (!el || el.innerHTML === value) return;
}
const offset = document.activeElement === el ? getCaretOffset(el) : null;
el.innerHTML = value;
if (offset !== null) setCaretOffset(el, offset);
}, [value]); }, [value]);
return ( return (

View File

@ -3,7 +3,7 @@ import { useCallback, useEffect, useMemo, useReducer, useState, type Dispatch, t
import { MessageTools, type IMessage } from "../tools/messages"; import { MessageTools, type IMessage } from "../tools/messages";
import { useInputState } from "@common/hooks/useInputState"; import { useInputState } from "@common/hooks/useInputState";
import { type IConnection } from "../tools/connection"; 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 { useInputCallback } from "@common/hooks/useInputCallback";
import { callUpdater, throttle } from "@common/utils"; import { callUpdater, throttle } from "@common/utils";
import { Huggingface } from "../tools/huggingface"; import { Huggingface } from "../tools/huggingface";

View File

@ -2,8 +2,8 @@ import { gguf } from '@huggingface/gguf';
import * as hub from '@huggingface/hub'; import * as hub from '@huggingface/hub';
import { Template } from '@huggingface/jinja'; import { Template } from '@huggingface/jinja';
import { Tokenizer } from '@huggingface/tokenizers'; import { Tokenizer } from '@huggingface/tokenizers';
import { loadObject, saveObject } from '@common/storage';
import { normalizeModel } from './model'; import { normalizeModel } from './model';
import { loadObject, saveObject } from './storage';
export namespace Huggingface { export namespace Huggingface {
export interface ITemplateMessage { export interface ITemplateMessage {

View File

@ -16,7 +16,7 @@
font-family: 'Georgia', serif; font-family: 'Georgia', serif;
font-size: 17px; font-size: 17px;
line-height: 1.9; line-height: 1.9;
color: var(--text-dim); color: var(--textColor);
background: transparent; background: transparent;
border: none; border: none;
outline: none; outline: none;

View File

@ -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);
}
}

View File

@ -17,6 +17,10 @@
--radius: 4px; --radius: 4px;
--transition: 0.15s ease; --transition: 0.15s ease;
--textColor: #DCDCD2;
--italicColor: #AFAFAF;
--quoteColor: #D4E5FF;
} }
* { * {

View File

@ -1,3 +1,4 @@
import { MenuSidebar } from "./menu-sidebar";
import { Sidebar } from "./sidebar"; import { Sidebar } from "./sidebar";
import { Editor } from "./editor"; import { Editor } from "./editor";
import styles from '../assets/app.module.css'; import styles from '../assets/app.module.css';
@ -5,7 +6,7 @@ import styles from '../assets/app.module.css';
export const App = () => { export const App = () => {
return ( return (
<div class={styles.root}> <div class={styles.root}>
<Sidebar side="left" /> <MenuSidebar />
<Editor /> <Editor />
<Sidebar side="right" /> <Sidebar side="right" />
</div> </div>

View File

@ -1,15 +1,32 @@
import { ContentEditable } from "@common/components/ContentEditable"; 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 styles from '../assets/editor.module.css';
import { highlight } from "../utils/highlight";
import { useMemo } from "preact/hooks";
export const Editor = () => { export const Editor = () => {
const [content, handleInput] = useInputState(''); const { currentStory, dispatch } = useAppState();
if (!currentStory) {
return <div class={styles.editor} />;
}
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 ( return (
<div class={styles.editor}> <div class={styles.editor}>
<ContentEditable <ContentEditable
class={styles.editable} class={styles.editable}
value={content} value={value}
onInput={handleInput} onInput={handleInput}
/> />
</div> </div>

View File

@ -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) => (
<button
class={clsx(styles.item, active && styles.active)}
onClick={onSelect}
>
{story.title}
</button>
);
// ─── 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 (
<Sidebar side="left">
<div class={styles.menu}>
<button class={styles.newButton} onClick={handleCreate}>
+ New Story
</button>
<div class={styles.list}>
{stories.map(story => (
<StoryItem
key={story.id}
story={story}
active={story.id === currentStory?.id}
onSelect={() => handleSelect(story.id)}
/>
))}
</div>
</div>
</Sidebar>
);
};

View File

@ -1,12 +1,14 @@
import clsx from "clsx"; import clsx from "clsx";
import type { JSX } from "preact";
import { useBool } from "@common/hooks/useBool"; import { useBool } from "@common/hooks/useBool";
import styles from '../assets/sidebar.module.css'; import styles from '../assets/sidebar.module.css';
interface Props { interface Props {
side: 'left' | 'right'; side: 'left' | 'right';
children?: JSX.Element | JSX.Element[];
} }
export const Sidebar = ({ side }: Props) => { export const Sidebar = ({ side, children }: Props) => {
const open = useBool(true); const open = useBool(true);
return ( return (
@ -16,7 +18,7 @@ export const Sidebar = ({ side }: Props) => {
</button> </button>
{open.value && ( {open.value && (
<div class={styles.content}> <div class={styles.content}>
{/* TODO */} {children}
</div> </div>
)} )}
</div> </div>

View File

@ -1,34 +1,94 @@
import { createContext } from "preact"; import { createContext } from "preact";
import { useContext, useMemo, useReducer } from "preact/hooks"; import { useContext, useMemo, useReducer } from "preact/hooks";
// ─── Types ────────────────────────────────────────────────────────────────────
export interface Story {
id: string;
title: string;
text: string;
}
// ─── State ─────────────────────────────────────────────────────────────────── // ─── State ───────────────────────────────────────────────────────────────────
interface IState { interface IState {
// TODO: define state shape stories: Story[];
currentStoryId: string | null;
} }
// ─── Actions ───────────────────────────────────────────────────────────────── // ─── Actions ─────────────────────────────────────────────────────────────────
type Action = 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 ─────────────────────────────────────────────────────────── // ─── Initial State ───────────────────────────────────────────────────────────
const DEFAULT_STATE: IState = { const DEFAULT_STATE: IState = {
// TODO stories: [],
currentStoryId: null,
}; };
// ─── Reducer ───────────────────────────────────────────────────────────────── // ─── Reducer ─────────────────────────────────────────────────────────────────
function reducer(state: IState, _action: Action): IState { function reducer(state: IState, action: Action): IState {
// TODO: implement action handlers switch (action.type) {
return state; 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 ───────────────────────────────────────────────────────────────── // ─── Context ─────────────────────────────────────────────────────────────────
interface IStateContext { interface IStateContext {
state: IState; stories: Story[];
currentStory: Story | null;
dispatch: (action: Action) => void; dispatch: (action: Action) => void;
} }
@ -41,7 +101,11 @@ export const useAppState = () => useContext(StateContext);
export const StateContextProvider = ({ children }: { children?: any }) => { export const StateContextProvider = ({ children }: { children?: any }) => {
const [state, dispatch] = useReducer(reducer, DEFAULT_STATE); const [state, dispatch] = useReducer(reducer, DEFAULT_STATE);
const value = useMemo<IStateContext>(() => ({ state, dispatch }), [state]); const value = useMemo<IStateContext>(() => ({
stories: state.stories,
currentStory: state.stories.find(s => s.id === state.currentStoryId) ?? null,
dispatch,
}), [state]);
return ( return (
<StateContext.Provider value={value}> <StateContext.Provider value={value}>

View File

@ -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}</span>`;
}
} else {
if (part === '*') {
stack.push(part);
resultHTML += `<span style="font-style:italic;color:var(--italicColor)">`;
} else if (part === '**') {
stack.push(part);
resultHTML += `<span style="font-weight:bold">`;
} else if (part === '"') {
stack.push(part);
resultHTML += `<span style="color:var(--quoteColor)">`;
}
resultHTML += part;
}
}
while (stack.length) {
const part = stack.pop();
if (part === '*' || part === '**' || part === '"') {
resultHTML += `</span>`;
}
}
return resultHTML;
}