1
0
Fork 0

Compare commits

..

No commits in common. "7f18a66a8b7f525d354efbeda8a2d71afafb8926" and "2a711a6deadcb5fa8d037866eeb93f8485bbaa4f" have entirely different histories.

10 changed files with 65 additions and 64 deletions

View File

@ -1,36 +1,22 @@
import { useEffect, useRef, useState } from "preact/hooks"; import { useEffect, useRef } from "preact/hooks";
import type { JSX } from "preact"; import type { JSX } from "preact";
import clsx from "clsx"; import clsx from "clsx";
import styles from "../assets/content-editable.module.css"; import styles from "../assets/content-editable.module.css";
import { highlight } from "../highlight";
type Props = Omit<JSX.HTMLAttributes<HTMLDivElement>, 'value' | 'onInput'> & { type Props = Omit<JSX.HTMLAttributes<HTMLDivElement>, 'value' | 'onInput'> & {
value: string; value: string;
placeholder?: string; placeholder?: string;
autoLines?: boolean; autoLines?: boolean;
highlight?: boolean;
onInput?: JSX.EventHandler<JSX.TargetedInputEvent<HTMLDivElement>>; onInput?: JSX.EventHandler<JSX.TargetedInputEvent<HTMLDivElement>>;
}; };
const parseLines = (html: string): string => (
html
.replace(/\n?<div><br\/?><\/div>/g, '\n\n')
.replace(/\n?<div[^>]*>/, '\n')
.replace(/<[^>]+>/g, '')
);
function getCaretOffset(el: HTMLElement): number { function getCaretOffset(el: HTMLElement): number {
const sel = window.getSelection(); const sel = window.getSelection();
if (!sel || sel.rangeCount === 0) return 0; if (!sel || sel.rangeCount === 0) return 0;
const range = sel.getRangeAt(0).cloneRange(); const range = sel.getRangeAt(0).cloneRange();
range.selectNodeContents(el); range.selectNodeContents(el);
range.setEnd(sel.getRangeAt(0).endContainer, sel.getRangeAt(0).endOffset); range.setEnd(sel.getRangeAt(0).endContainer, sel.getRangeAt(0).endOffset);
const contents = range.cloneContents(); return range.toString().length;
const div = document.createElement('div');
div.appendChild(contents);
const text = parseLines(div.innerHTML);
return text.length;
} }
function setCaretOffset(el: HTMLElement, offset: number) { function setCaretOffset(el: HTMLElement, offset: number) {
@ -41,8 +27,7 @@ function setCaretOffset(el: HTMLElement, offset: number) {
function traverse(node: Node): boolean { function traverse(node: Node): boolean {
if (node.nodeType === Node.TEXT_NODE) { if (node.nodeType === Node.TEXT_NODE) {
const text = node.textContent || ''; const len = node.textContent?.length ?? 0;
const len = text.length;
if (remaining <= len) { if (remaining <= len) {
range.setStart(node, remaining); range.setStart(node, remaining);
range.collapse(true); range.collapse(true);
@ -71,46 +56,61 @@ function resizeToContent(el: HTMLElement) {
el.style.height = el.scrollHeight + 'px'; el.style.height = el.scrollHeight + 'px';
} }
export const ContentEditable = ({ export const ContentEditable = ({ value, placeholder, autoLines, onInput, class: externalClass, ...props }: Props) => {
value,
placeholder,
autoLines,
highlight: enableHighlight,
onInput,
class: externalClass,
...props
}: Props) => {
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
const el = ref.current; const el = ref.current;
if (!el) return; if (!el || el.innerHTML === value) return;
const offset = document.activeElement === el ? getCaretOffset(el) : null; const offset = document.activeElement === el ? getCaretOffset(el) : null;
const newValue = enableHighlight ? highlight(value) : value; el.innerHTML = value;
el.innerHTML = newValue;
(el as any).value = value;
if (offset !== null) setCaretOffset(el, offset); if (offset !== null) setCaretOffset(el, offset);
if (autoLines) resizeToContent(el); if (autoLines) resizeToContent(el);
}, [value, enableHighlight]); }, [value]);
const handleKeyDown: JSX.KeyboardEventHandler<HTMLDivElement> = (e) => {
if (e.key !== 'Enter' || !ref.current) return;
e.preventDefault();
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0) return;
const range = sel.getRangeAt(0);
range.deleteContents();
const endsWithNewline = ref.current.textContent?.endsWith('\n');
const caretAtEnd = getCaretOffset(ref.current) === (ref.current.textContent?.length ?? 0);
const newline = document.createTextNode('\n'.repeat((endsWithNewline || !caretAtEnd) ? 1 : 2));
range.insertNode(newline);
range.setStartAfter(newline);
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
(ref.current as any).value = ref.current.textContent;
ref.current?.dispatchEvent(new InputEvent('input', { bubbles: true }));
};
const handleInput: JSX.EventHandler<JSX.TargetedInputEvent<HTMLDivElement>> = (e) => { const handleInput: JSX.EventHandler<JSX.TargetedInputEvent<HTMLDivElement>> = (e) => {
if (!ref.current) return; if (autoLines && ref.current) resizeToContent(ref.current);
if (autoLines) resizeToContent(ref.current); if (ref.current) {
const text = parseLines(ref.current.innerHTML); (ref.current as any).value = ref.current.textContent;
// add value prop to work with useInputState/useInputCallback }
(ref.current as any).value = text;
onInput?.(e); onInput?.(e);
}; };
return ( return (
<div <div
ref={ref} ref={ref}
{...props}
contentEditable contentEditable
onKeyDown={handleKeyDown}
onInput={handleInput}
data-placeholder={value.replaceAll('\n', '').length ? undefined : placeholder} data-placeholder={value.replaceAll('\n', '').length ? undefined : placeholder}
class={clsx(styles.root, autoLines && styles.autoLines, externalClass)} class={clsx(styles.root, autoLines && styles.autoLines, externalClass)}
onInput={handleInput} {...props}
/> />
); );
}; };

View File

@ -515,9 +515,8 @@ export const ChatPanel = ({ visible }: { visible: boolean }) => {
<div class={styles.editContainer}> <div class={styles.editContainer}>
<ContentEditable <ContentEditable
autoLines autoLines
highlight
class={styles.editTextarea} class={styles.editTextarea}
value={editingContent} value={highlight(editingContent)}
onInput={setEditingContent} onInput={setEditingContent}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' && e.ctrlKey) { if (e.key === 'Enter' && e.ctrlKey) {
@ -571,9 +570,8 @@ export const ChatPanel = ({ visible }: { visible: boolean }) => {
<div class={styles.inputWrapper}> <div class={styles.inputWrapper}>
<ContentEditable <ContentEditable
autoLines autoLines
highlight
class={styles.input} class={styles.input}
value={input} value={highlight(input)}
onInput={setInput} onInput={setInput}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder={ placeholder={

View File

@ -1,4 +1,5 @@
import { ContentEditable } from "@common/components/ContentEditable"; import { ContentEditable } from "@common/components/ContentEditable";
import { highlight } from "@common/highlight";
import { useMemo } from "preact/hooks"; import { useMemo } from "preact/hooks";
import styles from "../../assets/chapters-editor.module.css"; import styles from "../../assets/chapters-editor.module.css";
import { useAppState } from "../../contexts/state"; import { useAppState } from "../../contexts/state";
@ -46,9 +47,8 @@ export const ChaptersEditor = ({ visible }: { visible: boolean }) => {
<div class={styles.chunkPreview}>{body}</div> <div class={styles.chunkPreview}>{body}</div>
<ContentEditable <ContentEditable
autoLines autoLines
highlight
class={styles.summaryEditable} class={styles.summaryEditable}
value={summary ?? ''} value={highlight(summary ?? '')}
placeholder="Not summarized yet..." placeholder="Not summarized yet..."
onInput={(e) => dispatch({ onInput={(e) => dispatch({
type: 'STORE_CHAPTER_SUMMARY', type: 'STORE_CHAPTER_SUMMARY',

View File

@ -1,5 +1,7 @@
import { ContentEditable } from "@common/components/ContentEditable"; import { ContentEditable } from "@common/components/ContentEditable";
import { highlight } from "@common/highlight";
import { useInputCallback } from "@common/hooks/useInputCallback"; import { useInputCallback } from "@common/hooks/useInputCallback";
import { useMemo } from "preact/hooks";
import styles from "../../assets/editor.module.css"; import styles from "../../assets/editor.module.css";
import { useAppState } from "../../contexts/state"; import { useAppState } from "../../contexts/state";
@ -16,15 +18,16 @@ export const ScratchpadEditor = ({ visible }: { visible: boolean }) => {
}); });
}, [currentStory?.id, currentWorld?.id]); }, [currentStory?.id, currentWorld?.id]);
const value = useMemo(() => highlight(currentStory?.scratchpad || ''), [currentStory?.scratchpad]);
if (!currentWorld || !currentStory || !visible) { if (!currentWorld || !currentStory || !visible) {
return null; return null;
} }
return ( return (
<ContentEditable <ContentEditable
highlight
class={styles.editable} class={styles.editable}
value={currentStory?.scratchpad ?? ''} value={value}
onInput={handleInput} onInput={handleInput}
placeholder="Notes, ideas, outlines — anything you don't want in the story..." placeholder="Notes, ideas, outlines — anything you don't want in the story..."
/> />

View File

@ -1,4 +1,5 @@
import { ContentEditable } from "@common/components/ContentEditable"; import { ContentEditable } from "@common/components/ContentEditable";
import { highlight } from "@common/highlight";
import { useInputCallback } from "@common/hooks/useInputCallback"; import { useInputCallback } from "@common/hooks/useInputCallback";
import { useMemo } from "preact/hooks"; import { useMemo } from "preact/hooks";
import styles from "../../assets/editor.module.css"; import styles from "../../assets/editor.module.css";
@ -19,15 +20,12 @@ export const StoryEditor = ({ visible }: { visible: boolean }) => {
const value = useMemo(() => { const value = useMemo(() => {
if (!currentStory) return ''; if (!currentStory) return '';
const { text, lastEditedText } = currentStory; const { text, lastEditedText } = currentStory;
if (!lastEditedText) return text; if (!lastEditedText) return highlight(text);
const idx = text.lastIndexOf(lastEditedText); const idx = text.lastIndexOf(lastEditedText);
if (idx === -1) return text; if (idx === -1) return highlight(text);
const marked = text.slice(0, idx) + '<mark>' + lastEditedText + '</mark>' + text.slice(idx + lastEditedText.length); const marked = text.slice(0, idx) + '<mark>' + lastEditedText + '</mark>' + text.slice(idx + lastEditedText.length);
return marked; return highlight(marked);
}, [currentStory?.text, currentStory?.lastEditedText]); }, [currentStory?.text, currentStory?.lastEditedText]);
@ -37,7 +35,6 @@ export const StoryEditor = ({ visible }: { visible: boolean }) => {
return ( return (
<ContentEditable <ContentEditable
highlight
class={styles.editable} class={styles.editable}
value={value} value={value}
onInput={handleInput} onInput={handleInput}

View File

@ -1,5 +1,7 @@
import { ContentEditable } from "@common/components/ContentEditable"; import { ContentEditable } from "@common/components/ContentEditable";
import { highlight } from "@common/highlight";
import { useInputCallback } from "@common/hooks/useInputCallback"; import { useInputCallback } from "@common/hooks/useInputCallback";
import { useMemo } from "preact/hooks";
import styles from "../../assets/editor.module.css"; import styles from "../../assets/editor.module.css";
import { useAppState } from "../../contexts/state"; import { useAppState } from "../../contexts/state";
@ -15,15 +17,16 @@ export const SystemEditor = ({ visible }: { visible: boolean }) => {
}); });
}, [currentWorld?.id]); }, [currentWorld?.id]);
const value = useMemo(() => highlight(currentWorld?.systemInstructionOverride || ''), [currentWorld?.systemInstructionOverride]);
if (!currentWorld || !visible) { if (!currentWorld || !visible) {
return null; return null;
} }
return ( return (
<ContentEditable <ContentEditable
highlight
class={styles.editable} class={styles.editable}
value={currentWorld?.systemInstructionOverride || ''} value={value}
onInput={handleInput} onInput={handleInput}
placeholder="Override the global system instruction for this world. Leave empty to use the global setting." placeholder="Override the global system instruction for this world. Leave empty to use the global setting."
/> />

View File

@ -1,4 +1,5 @@
import { ContentEditable } from "@common/components/ContentEditable"; import { ContentEditable } from "@common/components/ContentEditable";
import { highlight } from "@common/highlight";
import { useInputCallback } from "@common/hooks/useInputCallback"; import { useInputCallback } from "@common/hooks/useInputCallback";
import clsx from "clsx"; import clsx from "clsx";
import styles from "../../assets/settings-modal.module.css"; import styles from "../../assets/settings-modal.module.css";
@ -16,8 +17,7 @@ export const ChatSystemInstructionSettings = () => {
<div class={clsx(styles.formGroup, styles.formGroupFill)}> <div class={clsx(styles.formGroup, styles.formGroupFill)}>
<label class={styles.label}>Chat System Instruction</label> <label class={styles.label}>Chat System Instruction</label>
<ContentEditable <ContentEditable
highlight value={highlight(chatSystemInstruction)}
value={chatSystemInstruction}
onInput={setInstructionValue} onInput={setInstructionValue}
placeholder="Enter default system instruction for chat/roleplay worlds ({{char}}, {{user}} supported)..." placeholder="Enter default system instruction for chat/roleplay worlds ({{char}}, {{user}} supported)..."
class={clsx(styles.input, styles.textarea)} class={clsx(styles.input, styles.textarea)}

View File

@ -1,4 +1,5 @@
import { ContentEditable } from "@common/components/ContentEditable"; import { ContentEditable } from "@common/components/ContentEditable";
import { highlight } from "@common/highlight";
import { useInputCallback } from "@common/hooks/useInputCallback"; import { useInputCallback } from "@common/hooks/useInputCallback";
import clsx from "clsx"; import clsx from "clsx";
import styles from "../../assets/settings-modal.module.css"; import styles from "../../assets/settings-modal.module.css";
@ -16,8 +17,7 @@ export const ContinuePromptSettings = () => {
<div class={clsx(styles.formGroup, styles.formGroupFill)}> <div class={clsx(styles.formGroup, styles.formGroupFill)}>
<label class={styles.label}>Continue Prompt</label> <label class={styles.label}>Continue Prompt</label>
<ContentEditable <ContentEditable
highlight value={highlight(continuePrompt)}
value={continuePrompt}
onInput={setPromptValue} onInput={setPromptValue}
placeholder="Enter the prompt prepended when continuing the story..." placeholder="Enter the prompt prepended when continuing the story..."
class={clsx(styles.input, styles.textarea)} class={clsx(styles.input, styles.textarea)}

View File

@ -1,4 +1,5 @@
import { ContentEditable } from "@common/components/ContentEditable"; import { ContentEditable } from "@common/components/ContentEditable";
import { highlight } from "@common/highlight";
import { useInputCallback } from "@common/hooks/useInputCallback"; import { useInputCallback } from "@common/hooks/useInputCallback";
import clsx from "clsx"; import clsx from "clsx";
import styles from "../../assets/settings-modal.module.css"; import styles from "../../assets/settings-modal.module.css";
@ -16,8 +17,7 @@ export const SystemInstructionSettings = () => {
<div class={clsx(styles.formGroup, styles.formGroupFill)}> <div class={clsx(styles.formGroup, styles.formGroupFill)}>
<label class={styles.label}>System Instruction</label> <label class={styles.label}>System Instruction</label>
<ContentEditable <ContentEditable
highlight value={highlight(systemInstruction)}
value={systemInstruction}
onInput={setInstructionValue} onInput={setInstructionValue}
placeholder="Enter system instruction for the AI assistant..." placeholder="Enter system instruction for the AI assistant..."
class={clsx(styles.input, styles.textarea)} class={clsx(styles.input, styles.textarea)}

View File

@ -1,4 +1,5 @@
import { ContentEditable } from "@common/components/ContentEditable"; import { ContentEditable } from "@common/components/ContentEditable";
import { highlight } from "@common/highlight";
import { useInputCallback } from "@common/hooks/useInputCallback"; import { useInputCallback } from "@common/hooks/useInputCallback";
import { useInputState } from "@common/hooks/useInputState"; import { useInputState } from "@common/hooks/useInputState";
import clsx from "clsx"; import clsx from "clsx";
@ -37,8 +38,7 @@ export const UserSettings = () => {
<div class={clsx(styles.formGroup, styles.formGroupFill)}> <div class={clsx(styles.formGroup, styles.formGroupFill)}>
<label class={styles.label}>Your Description</label> <label class={styles.label}>Your Description</label>
<ContentEditable <ContentEditable
highlight value={highlight(userDescription)}
value={userDescription}
onInput={setDescription} onInput={setDescription}
placeholder="Describe yourself for the AI..." placeholder="Describe yourself for the AI..."
class={clsx(styles.input, styles.textarea)} class={clsx(styles.input, styles.textarea)}