Compare commits
2 Commits
2a711a6dea
...
7f18a66a8b
| Author | SHA1 | Date |
|---|---|---|
|
|
7f18a66a8b | |
|
|
20485ba551 |
|
|
@ -1,22 +1,36 @@
|
||||||
import { useEffect, useRef } from "preact/hooks";
|
import { useEffect, useRef, useState } 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);
|
||||||
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) {
|
function setCaretOffset(el: HTMLElement, offset: number) {
|
||||||
|
|
@ -27,7 +41,8 @@ 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 len = node.textContent?.length ?? 0;
|
const text = node.textContent || '';
|
||||||
|
const len = text.length;
|
||||||
if (remaining <= len) {
|
if (remaining <= len) {
|
||||||
range.setStart(node, remaining);
|
range.setStart(node, remaining);
|
||||||
range.collapse(true);
|
range.collapse(true);
|
||||||
|
|
@ -56,61 +71,46 @@ function resizeToContent(el: HTMLElement) {
|
||||||
el.style.height = el.scrollHeight + 'px';
|
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);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = ref.current;
|
const el = ref.current;
|
||||||
if (!el || el.innerHTML === value) return;
|
if (!el) return;
|
||||||
|
|
||||||
const offset = document.activeElement === el ? getCaretOffset(el) : null;
|
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 (offset !== null) setCaretOffset(el, offset);
|
||||||
if (autoLines) resizeToContent(el);
|
if (autoLines) resizeToContent(el);
|
||||||
}, [value]);
|
}, [value, enableHighlight]);
|
||||||
|
|
||||||
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 (autoLines && ref.current) resizeToContent(ref.current);
|
if (!ref.current) return;
|
||||||
if (ref.current) {
|
if (autoLines) resizeToContent(ref.current);
|
||||||
(ref.current as any).value = ref.current.textContent;
|
const text = parseLines(ref.current.innerHTML);
|
||||||
}
|
// 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)}
|
||||||
{...props}
|
onInput={handleInput}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -515,8 +515,9 @@ 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={highlight(editingContent)}
|
value={editingContent}
|
||||||
onInput={setEditingContent}
|
onInput={setEditingContent}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter' && e.ctrlKey) {
|
if (e.key === 'Enter' && e.ctrlKey) {
|
||||||
|
|
@ -570,8 +571,9 @@ 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={highlight(input)}
|
value={input}
|
||||||
onInput={setInput}
|
onInput={setInput}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder={
|
placeholder={
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
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";
|
||||||
|
|
@ -47,8 +46,9 @@ 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={highlight(summary ?? '')}
|
value={summary ?? ''}
|
||||||
placeholder="Not summarized yet..."
|
placeholder="Not summarized yet..."
|
||||||
onInput={(e) => dispatch({
|
onInput={(e) => dispatch({
|
||||||
type: 'STORE_CHAPTER_SUMMARY',
|
type: 'STORE_CHAPTER_SUMMARY',
|
||||||
|
|
|
||||||
|
|
@ -1,7 +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 styles from "../../assets/editor.module.css";
|
import styles from "../../assets/editor.module.css";
|
||||||
import { useAppState } from "../../contexts/state";
|
import { useAppState } from "../../contexts/state";
|
||||||
|
|
||||||
|
|
@ -18,16 +16,15 @@ 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={value}
|
value={currentStory?.scratchpad ?? ''}
|
||||||
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..."
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
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";
|
||||||
|
|
@ -20,12 +19,15 @@ 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 highlight(text);
|
if (!lastEditedText) return text;
|
||||||
|
|
||||||
const idx = text.lastIndexOf(lastEditedText);
|
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);
|
const marked = text.slice(0, idx) + '<mark>' + lastEditedText + '</mark>' + text.slice(idx + lastEditedText.length);
|
||||||
return highlight(marked);
|
return marked;
|
||||||
}, [currentStory?.text, currentStory?.lastEditedText]);
|
}, [currentStory?.text, currentStory?.lastEditedText]);
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -35,6 +37,7 @@ 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}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +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 styles from "../../assets/editor.module.css";
|
import styles from "../../assets/editor.module.css";
|
||||||
import { useAppState } from "../../contexts/state";
|
import { useAppState } from "../../contexts/state";
|
||||||
|
|
||||||
|
|
@ -17,16 +15,15 @@ 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={value}
|
value={currentWorld?.systemInstructionOverride || ''}
|
||||||
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."
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
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";
|
||||||
|
|
@ -17,7 +16,8 @@ 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
|
||||||
value={highlight(chatSystemInstruction)}
|
highlight
|
||||||
|
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)}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
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";
|
||||||
|
|
@ -17,7 +16,8 @@ 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
|
||||||
value={highlight(continuePrompt)}
|
highlight
|
||||||
|
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)}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
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";
|
||||||
|
|
@ -17,7 +16,8 @@ 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
|
||||||
value={highlight(systemInstruction)}
|
highlight
|
||||||
|
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)}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
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";
|
||||||
|
|
@ -38,7 +37,8 @@ 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
|
||||||
value={highlight(userDescription)}
|
highlight
|
||||||
|
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)}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue