1
0
Fork 0

Compare commits

...

2 Commits

10 changed files with 64 additions and 65 deletions

View File

@ -1,22 +1,36 @@
import { useEffect, useRef } from "preact/hooks";
import { useEffect, useRef, useState } from "preact/hooks";
import type { JSX } from "preact";
import clsx from "clsx";
import styles from "../assets/content-editable.module.css";
import { highlight } from "../highlight";
type Props = Omit<JSX.HTMLAttributes<HTMLDivElement>, 'value' | 'onInput'> & {
value: string;
placeholder?: string;
autoLines?: boolean;
highlight?: boolean;
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 {
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;
const contents = range.cloneContents();
const div = document.createElement('div');
div.appendChild(contents);
const text = parseLines(div.innerHTML);
return text.length;
}
function setCaretOffset(el: HTMLElement, offset: number) {
@ -27,7 +41,8 @@ function setCaretOffset(el: HTMLElement, offset: number) {
function traverse(node: Node): boolean {
if (node.nodeType === Node.TEXT_NODE) {
const len = node.textContent?.length ?? 0;
const text = node.textContent || '';
const len = text.length;
if (remaining <= len) {
range.setStart(node, remaining);
range.collapse(true);
@ -56,61 +71,46 @@ function resizeToContent(el: HTMLElement) {
el.style.height = el.scrollHeight + 'px';
}
export const ContentEditable = ({ value, placeholder, autoLines, onInput, class: externalClass, ...props }: Props) => {
export const ContentEditable = ({
value,
placeholder,
autoLines,
highlight: enableHighlight,
onInput,
class: externalClass,
...props
}: Props) => {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = ref.current;
if (!el || el.innerHTML === value) return;
if (!el) return;
const offset = document.activeElement === el ? getCaretOffset(el) : null;
el.innerHTML = value;
const newValue = enableHighlight ? highlight(value) : value;
el.innerHTML = newValue;
(el as any).value = value;
if (offset !== null) setCaretOffset(el, offset);
if (autoLines) resizeToContent(el);
}, [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 }));
};
}, [value, enableHighlight]);
const handleInput: JSX.EventHandler<JSX.TargetedInputEvent<HTMLDivElement>> = (e) => {
if (autoLines && ref.current) resizeToContent(ref.current);
if (ref.current) {
(ref.current as any).value = ref.current.textContent;
}
if (!ref.current) return;
if (autoLines) resizeToContent(ref.current);
const text = parseLines(ref.current.innerHTML);
// add value prop to work with useInputState/useInputCallback
(ref.current as any).value = text;
onInput?.(e);
};
return (
<div
ref={ref}
{...props}
contentEditable
onKeyDown={handleKeyDown}
onInput={handleInput}
data-placeholder={value.replaceAll('\n', '').length ? undefined : placeholder}
class={clsx(styles.root, autoLines && styles.autoLines, externalClass)}
{...props}
onInput={handleInput}
/>
);
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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