Stories editor
This commit is contained in:
parent
e3321eac89
commit
4c57bef21e
|
|
@ -6,13 +6,57 @@ type Props = Omit<JSX.HTMLAttributes<HTMLDivElement>, 'value' | 'onInput'> & {
|
|||
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) => {
|
||||
const ref = useRef<HTMLDivElement>(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 (
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -17,6 +17,10 @@
|
|||
|
||||
--radius: 4px;
|
||||
--transition: 0.15s ease;
|
||||
|
||||
--textColor: #DCDCD2;
|
||||
--italicColor: #AFAFAF;
|
||||
--quoteColor: #D4E5FF;
|
||||
}
|
||||
|
||||
* {
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div class={styles.root}>
|
||||
<Sidebar side="left" />
|
||||
<MenuSidebar />
|
||||
<Editor />
|
||||
<Sidebar side="right" />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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 <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 (
|
||||
<div class={styles.editor}>
|
||||
<ContentEditable
|
||||
class={styles.editable}
|
||||
value={content}
|
||||
value={value}
|
||||
onInput={handleInput}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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) => {
|
|||
</button>
|
||||
{open.value && (
|
||||
<div class={styles.content}>
|
||||
{/* TODO */}
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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<IStateContext>(() => ({ state, dispatch }), [state]);
|
||||
const value = useMemo<IStateContext>(() => ({
|
||||
stories: state.stories,
|
||||
currentStory: state.stories.find(s => s.id === state.currentStoryId) ?? null,
|
||||
dispatch,
|
||||
}), [state]);
|
||||
|
||||
return (
|
||||
<StateContext.Provider value={value}>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
Loading…
Reference in New Issue