diff --git a/src/common/components/ContentEditable.tsx b/src/common/components/ContentEditable.tsx index b8e8894..9ae5834 100644 --- a/src/common/components/ContentEditable.tsx +++ b/src/common/components/ContentEditable.tsx @@ -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, 'value' | 'onInput'> & { value: string; placeholder?: string; autoLines?: boolean; + highlight?: boolean; onInput?: JSX.EventHandler>; }; +const parseLines = (html: string): string => ( + html + .replace(/\n?
<\/div>/g, '\n\n') + .replace(/\n?]*>/, '\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,7 @@ function setCaretOffset(el: HTMLElement, offset: number) { function traverse(node: Node): boolean { if (node.nodeType === Node.TEXT_NODE) { - const text = node.textContent || '' + const text = node.textContent || ''; const len = text.length; if (remaining <= len) { range.setStart(node, remaining); @@ -57,53 +71,35 @@ function resizeToContent(el: HTMLElement) { el.style.height = el.scrollHeight + 'px'; } -export const ContentEditable = ({ value, placeholder, autoLines, onInput, onKeyDown, class: externalClass, ...props }: Props) => { +export const ContentEditable = ({ + value, + placeholder, + autoLines, + highlight: enableHighlight, + onInput, + class: externalClass, + ...props +}: Props) => { const ref = useRef(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 = (e) => { - if (e.key !== 'Enter' || !ref.current) { - onKeyDown?.(e); - return; - }; - e.preventDefault(); - - const sel = window.getSelection(); - if (!sel || sel.rangeCount === 0) return; - - const range = sel.getRangeAt(0); - range.deleteContents(); - - const endsWithNewline = ref.current.innerText?.endsWith('\n'); - const caretAtEnd = getCaretOffset(ref.current) === (ref.current.innerText?.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.innerText; - ref.current?.dispatchEvent(new InputEvent('input', { bubbles: true })); - onKeyDown?.(e); - }; + }, [value, enableHighlight]); const handleInput: JSX.EventHandler> = (e) => { - if (autoLines && ref.current) resizeToContent(ref.current); - if (ref.current) { - (ref.current as any).value = ref.current.innerText; - } + 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); }; @@ -114,7 +110,6 @@ export const ContentEditable = ({ value, placeholder, autoLines, onInput, onKeyD contentEditable data-placeholder={value.replaceAll('\n', '').length ? undefined : placeholder} class={clsx(styles.root, autoLines && styles.autoLines, externalClass)} - onKeyDown={handleKeyDown} onInput={handleInput} /> ); diff --git a/src/games/storywriter/components/chat-sidebar.tsx b/src/games/storywriter/components/chat-sidebar.tsx index 4e77243..084e7d1 100644 --- a/src/games/storywriter/components/chat-sidebar.tsx +++ b/src/games/storywriter/components/chat-sidebar.tsx @@ -515,8 +515,9 @@ export const ChatPanel = ({ visible }: { visible: boolean }) => {
{ if (e.key === 'Enter' && e.ctrlKey) { @@ -570,8 +571,9 @@ export const ChatPanel = ({ visible }: { visible: boolean }) => {
{
{body}
dispatch({ type: 'STORE_CHAPTER_SUMMARY', diff --git a/src/games/storywriter/components/editors/scratchpad.tsx b/src/games/storywriter/components/editors/scratchpad.tsx index 9e55db2..b09acd6 100644 --- a/src/games/storywriter/components/editors/scratchpad.tsx +++ b/src/games/storywriter/components/editors/scratchpad.tsx @@ -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 ( diff --git a/src/games/storywriter/components/editors/story.tsx b/src/games/storywriter/components/editors/story.tsx index fa28947..b9ed4a5 100644 --- a/src/games/storywriter/components/editors/story.tsx +++ b/src/games/storywriter/components/editors/story.tsx @@ -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) + '' + lastEditedText + '' + 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 ( { }); }, [currentWorld?.id]); - const value = useMemo(() => highlight(currentWorld?.systemInstructionOverride || ''), [currentWorld?.systemInstructionOverride]); - if (!currentWorld || !visible) { return null; } return ( diff --git a/src/games/storywriter/components/settings/chat-system-instruction.tsx b/src/games/storywriter/components/settings/chat-system-instruction.tsx index df3a2a7..f37412a 100644 --- a/src/games/storywriter/components/settings/chat-system-instruction.tsx +++ b/src/games/storywriter/components/settings/chat-system-instruction.tsx @@ -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 = () => {
{
{
{