Compare commits
3 Commits
97533daa91
...
76d4ef297e
| Author | SHA1 | Date |
|---|---|---|
|
|
76d4ef297e | |
|
|
05eb1a8fc4 | |
|
|
a6a189de1d |
|
|
@ -1,129 +1,534 @@
|
|||
import { useEffect, useRef } from "preact/hooks";
|
||||
import type { JSX } from "preact";
|
||||
import clsx from "clsx";
|
||||
import styles from "../assets/content-editable.module.css";
|
||||
import { highlight } from "../highlight";
|
||||
import { useLayoutEffect, useRef } 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'> & {
|
||||
export type ContentEditableElement = HTMLDivElement & { value: string };
|
||||
export type Props = Omit<JSX.HTMLAttributes<HTMLDivElement>, 'value' | 'onInput'> & {
|
||||
value: string;
|
||||
placeholder?: string;
|
||||
autoLines?: boolean;
|
||||
highlight?: boolean;
|
||||
/** Highlight every literal, case-sensitive match without changing value. */
|
||||
highlightText?: string | readonly string[];
|
||||
disabled?: boolean;
|
||||
onInput?: JSX.EventHandler<JSX.TargetedInputEvent<HTMLDivElement>>;
|
||||
};
|
||||
|
||||
const parseLines = (html: string, keepSpan: boolean = false): string => {
|
||||
html = html
|
||||
.replace(/\n?<div><br\/?><\/div>/g, '\n\n')
|
||||
.replace(/\n?<div[^>]*>/, '\n')
|
||||
.replace(/<\/?div[^>]*>/g, '')
|
||||
.replace(/<br[^>]*>/g, '');
|
||||
type Snapshot = { text: string; anchor: number; focus: number };
|
||||
const normalize = (text: string) => text.replace(/\r\n?/g, '\n');
|
||||
const nativeBlock = (node: Node) => node.nodeType === 1 && /^(DIV|P|LI|PRE|H[1-6])$/.test((node as Element).tagName);
|
||||
const graphemes = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
|
||||
type Point = [Node, number];
|
||||
type TextRun = { node: Node; start: number; end: number };
|
||||
let nextMatchId = 0;
|
||||
|
||||
if (!keepSpan) {
|
||||
html = html.replace(/<[^>]+>/g, '');
|
||||
function findTextMatches(root: HTMLElement, query: Props['highlightText']) {
|
||||
const terms = [...new Set((typeof query === 'string' ? [query] : query ?? []).map(normalize).filter(Boolean))];
|
||||
const nodes: { node: Text; from: number; to: number }[] = [];
|
||||
const matches: { from: number; to: number }[] = [];
|
||||
if (!terms.length) return { nodes, matches };
|
||||
let text = '';
|
||||
const walker = root.ownerDocument.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
||||
let node: Node | null;
|
||||
while ((node = walker.nextNode())) {
|
||||
const from = text.length;
|
||||
text += node.nodeValue;
|
||||
nodes.push({ node: node as Text, from, to: text.length });
|
||||
}
|
||||
|
||||
return html;
|
||||
};
|
||||
|
||||
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);
|
||||
const contents = range.cloneContents();
|
||||
const div = document.createElement('div');
|
||||
div.appendChild(contents);
|
||||
const text = parseLines(div.innerHTML);
|
||||
return text.length;
|
||||
for (const term of terms) {
|
||||
let from = text.indexOf(term);
|
||||
while (from !== -1) {
|
||||
matches.push({ from, to: from + term.length });
|
||||
from = text.indexOf(term, from + 1);
|
||||
}
|
||||
}
|
||||
matches.sort((a, b) => a.from - b.from || a.to - b.to);
|
||||
const merged: typeof matches = [];
|
||||
for (const match of matches) {
|
||||
const previous = merged.at(-1);
|
||||
if (previous && match.from < previous.to) previous.to = Math.max(previous.to, match.to);
|
||||
else merged.push({ ...match });
|
||||
}
|
||||
return { nodes, matches: merged };
|
||||
}
|
||||
|
||||
function setCaretOffset(el: HTMLElement, offset: number) {
|
||||
const sel = window.getSelection();
|
||||
if (!sel) return;
|
||||
const range = document.createRange();
|
||||
let remaining = offset;
|
||||
/** Paint-only ranges keep live text nodes and selection untouched. Older
|
||||
* browsers use internal spans, which never become part of the source value. */
|
||||
function createMatchPainter(root: HTMLElement) {
|
||||
const doc = root.ownerDocument;
|
||||
const win = doc.defaultView as (Window & typeof globalThis) | null;
|
||||
const registry = win?.CSS?.highlights;
|
||||
const HighlightClass = win?.Highlight;
|
||||
const native = !!registry && !!HighlightClass && !!win?.CSS.supports('selector(::highlight(ce-match))');
|
||||
const name = `content-editable-match-${++nextMatchId}`;
|
||||
const style = doc.createElement('style');
|
||||
const colors = 'background-color: var(--content-editable-highlight-background, #ffeb70); color: var(--content-editable-highlight-color, #1b241c);';
|
||||
style.textContent = `${native ? `::highlight(${name})` : `span[data-ce-match="${name}"]`} { ${colors} }`;
|
||||
const tree = root.getRootNode();
|
||||
(tree instanceof ShadowRoot ? tree : doc.head).append(style);
|
||||
return {
|
||||
native,
|
||||
paint(query: Props['highlightText']) {
|
||||
if (!native) return;
|
||||
const { nodes, matches } = findTextMatches(root, query);
|
||||
if (!matches.length) { registry!.delete(name); return; }
|
||||
const ranges = new HighlightClass!();
|
||||
let start = 0, end = 0;
|
||||
for (const match of matches) {
|
||||
while (nodes[start].to <= match.from) start++;
|
||||
end = Math.max(start, end);
|
||||
while (nodes[end].to < match.to) end++;
|
||||
const range = doc.createRange();
|
||||
range.setStart(nodes[start].node, match.from - nodes[start].from);
|
||||
range.setEnd(nodes[end].node, match.to - nodes[end].from);
|
||||
ranges.add(range);
|
||||
}
|
||||
registry!.set(name, ranges);
|
||||
},
|
||||
decorate(target: HTMLElement, query: Props['highlightText']) {
|
||||
if (native) return;
|
||||
const { nodes, matches } = findTextMatches(target, query);
|
||||
for (const run of nodes) {
|
||||
// Work backwards so earlier offsets still address the original
|
||||
// text node, even when several matches occur in the same node.
|
||||
for (let index = matches.length - 1; index >= 0; index--) {
|
||||
const from = Math.max(run.from, matches[index].from);
|
||||
const to = Math.min(run.to, matches[index].to);
|
||||
if (from >= to) continue;
|
||||
run.node.splitText(to - run.from);
|
||||
const text = run.node.splitText(from - run.from);
|
||||
const span = doc.createElement('span');
|
||||
span.setAttribute('data-ce-match', name);
|
||||
text.replaceWith(span);
|
||||
span.append(text);
|
||||
}
|
||||
}
|
||||
},
|
||||
destroy() {
|
||||
if (native) registry!.delete(name);
|
||||
style.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function traverse(node: Node): boolean {
|
||||
function deletionRange(root: HTMLElement, position: number, backward: boolean) {
|
||||
const { text } = indexDOM(root);
|
||||
const index = backward ? position - 1 : position;
|
||||
if (index < 0 || index >= text.length) return;
|
||||
const segment = graphemes.segment(text).containing(index)!;
|
||||
return { anchor: segment.index, focus: segment.index + segment.segment.length };
|
||||
}
|
||||
|
||||
/** Read the DOM, not its HTML serialization. The index also maps both ends of
|
||||
* a selection, including native line breaks. */
|
||||
function indexDOM(root: HTMLElement) {
|
||||
let text = '';
|
||||
const starts = new Map<Node, number>();
|
||||
const boundaries = new Map<Node, number[]>();
|
||||
const runs: TextRun[] = [];
|
||||
const visit = (node: Node) => {
|
||||
starts.set(node, text.length);
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
const text = node.textContent || '';
|
||||
const len = text.length;
|
||||
if (remaining <= len) {
|
||||
range.setStart(node, remaining);
|
||||
range.collapse(true);
|
||||
return true;
|
||||
const start = text.length;
|
||||
text += node.nodeValue ?? '';
|
||||
runs.push({ node, start, end: text.length });
|
||||
return;
|
||||
}
|
||||
remaining -= len;
|
||||
} else {
|
||||
for (const child of Array.from(node.childNodes)) {
|
||||
if (traverse(child)) return true;
|
||||
if (!(node instanceof Element)) return;
|
||||
if (node.tagName === 'BR') {
|
||||
if (!node.hasAttribute('data-ce-end') && !(nativeBlock(node.parentNode!) && node.parentNode!.childNodes.length === 1)) text += '\n';
|
||||
return;
|
||||
}
|
||||
const points: number[] = [];
|
||||
const children = Array.from(node.childNodes);
|
||||
children.forEach((child, index) => {
|
||||
if (nativeBlock(child) && index > 0 && (nativeBlock(children[index - 1]) || !text.endsWith('\n'))) text += '\n';
|
||||
points.push(text.length);
|
||||
visit(child);
|
||||
});
|
||||
points.push(text.length);
|
||||
boundaries.set(node, points);
|
||||
};
|
||||
visit(root);
|
||||
const offset = (node: Node | null, index: number) => {
|
||||
if (!node || !root.contains(node)) return text.length;
|
||||
if (node.nodeType === Node.TEXT_NODE) return (starts.get(node) ?? 0) + Math.min(index, node.nodeValue?.length ?? 0);
|
||||
const points = boundaries.get(node);
|
||||
return points?.[Math.min(index, points.length - 1)] ?? starts.get(node) ?? 0;
|
||||
};
|
||||
const point = (offset: number): Point => {
|
||||
offset = Math.max(0, Math.min(offset, text.length));
|
||||
// Prefer the next text node at shared boundaries, particularly after a
|
||||
// heading's trailing newline.
|
||||
for (const run of runs) {
|
||||
if (offset >= run.start && offset < run.end) return [run.node, offset - run.start];
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!traverse(el)) {
|
||||
range.selectNodeContents(el);
|
||||
range.collapse(false);
|
||||
}
|
||||
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
const following = runs.find(run => run.start >= offset);
|
||||
if (following) return [following.node, 0];
|
||||
const hasEnd = root.lastChild instanceof Element && root.lastChild.hasAttribute('data-ce-end');
|
||||
const last = runs.at(-1);
|
||||
if (last && !hasEnd) return [last.node, last.end - last.start];
|
||||
return [root, Math.max(0, root.childNodes.length - (hasEnd ? 1 : 0))];
|
||||
};
|
||||
return { text, offset, point };
|
||||
}
|
||||
|
||||
function resizeToContent(el: HTMLElement) {
|
||||
el.style.height = 'auto';
|
||||
el.style.height = el.scrollHeight + 'px';
|
||||
function readDOM(root: HTMLElement): Snapshot {
|
||||
const index = indexDOM(root);
|
||||
const selection = root.ownerDocument.getSelection();
|
||||
return {
|
||||
text: index.text,
|
||||
anchor: index.offset(selection?.anchorNode ?? null, selection?.anchorOffset ?? 0),
|
||||
focus: index.offset(selection?.focusNode ?? null, selection?.focusOffset ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
function restoreSelection(root: HTMLElement, snapshot: Snapshot) {
|
||||
const { point } = indexDOM(root);
|
||||
const [anchor, anchorOffset] = point(snapshot.anchor);
|
||||
const [focus, focusOffset] = point(snapshot.focus);
|
||||
root.ownerDocument.getSelection()?.setBaseAndExtent(anchor, anchorOffset, focus, focusOffset);
|
||||
}
|
||||
|
||||
function renderSource(root: HTMLElement, text: string, enabled: boolean) {
|
||||
if (enabled) root.innerHTML = highlight(text.replace(/&/g, '&').replace(/</g, '<'));
|
||||
else root.textContent = text;
|
||||
}
|
||||
|
||||
function selectedText(snapshot: Snapshot) {
|
||||
const from = Math.min(snapshot.anchor, snapshot.focus), to = Math.max(snapshot.anchor, snapshot.focus);
|
||||
return snapshot.text.slice(from, to);
|
||||
}
|
||||
|
||||
/** Reuse live nodes whenever their shape matches. Ordinary word edits leave
|
||||
* their original text nodes intact, including the browser's correction anchors. */
|
||||
function reconcile(parent: Node, desired: Node): boolean {
|
||||
let changed = false;
|
||||
let current = parent.firstChild;
|
||||
for (const next of Array.from(desired.childNodes)) {
|
||||
const same = current && current.nodeType === next.nodeType && (
|
||||
current.nodeType === Node.TEXT_NODE ||
|
||||
(current instanceof Element && next instanceof Element && current.tagName === next.tagName && current.getAttribute('class') === next.getAttribute('class'))
|
||||
);
|
||||
if (!same) {
|
||||
parent.insertBefore(next.cloneNode(true), current);
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
if (current!.nodeType === Node.TEXT_NODE) {
|
||||
if (current!.nodeValue !== next.nodeValue) { current!.nodeValue = next.nodeValue; changed = true; }
|
||||
} else {
|
||||
const live = current as Element;
|
||||
const wanted = next as Element;
|
||||
for (const attribute of Array.from(live.attributes)) {
|
||||
if (!wanted.hasAttribute(attribute.name)) { live.removeAttribute(attribute.name); changed = true; }
|
||||
}
|
||||
for (const attribute of Array.from(wanted.attributes)) {
|
||||
if (live.getAttribute(attribute.name) !== attribute.value) { live.setAttribute(attribute.name, attribute.value); changed = true; }
|
||||
}
|
||||
changed = reconcile(live, wanted) || changed;
|
||||
}
|
||||
current = current!.nextSibling;
|
||||
}
|
||||
while (current) {
|
||||
const next = current.nextSibling;
|
||||
parent.removeChild(current);
|
||||
changed = true;
|
||||
current = next;
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
export const ContentEditable = ({
|
||||
value,
|
||||
placeholder,
|
||||
autoLines,
|
||||
highlight: enableHighlight,
|
||||
disabled,
|
||||
onInput,
|
||||
class: externalClass,
|
||||
value, placeholder, autoLines = false, highlight: enableHighlight = false,
|
||||
highlightText,
|
||||
disabled = false, onInput, class: externalClass, className,
|
||||
onKeyDown, onBeforeInput, onPaste, onCopy, onCut,
|
||||
onCompositionStart, onCompositionEnd, onCompositionUpdate, onBlur,
|
||||
onCompositionStartCapture, onCompositionEndCapture, onCompositionUpdateCapture,
|
||||
children: _children, dangerouslySetInnerHTML: _html, contentEditable: _editable,
|
||||
...props
|
||||
}: Props) => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const root = useRef<ContentEditableElement>(null);
|
||||
const model = useRef<Snapshot>({ text: normalize(value), anchor: 0, focus: 0 });
|
||||
const before = useRef<Snapshot>();
|
||||
const past = useRef<Snapshot[]>([]);
|
||||
const future = useRef<Snapshot[]>([]);
|
||||
const grouping = useRef<{ kind: string; time: number; focus: number }>();
|
||||
const composing = useRef(false);
|
||||
const pending = useRef<string | null>(null);
|
||||
const lastProp = useRef(normalize(value));
|
||||
const timer = useRef<ReturnType<typeof setTimeout>>();
|
||||
const settleTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||
const notifying = useRef(false);
|
||||
const originalHeight = useRef<string | null>(null);
|
||||
const matchPainter = useRef<ReturnType<typeof createMatchPainter>>();
|
||||
const options = useRef({ autoLines, enableHighlight, highlightText, placeholder, disabled });
|
||||
options.current = { autoLines, enableHighlight, highlightText, placeholder, disabled };
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
const sync = () => {
|
||||
const el = root.current;
|
||||
if (!el) return;
|
||||
el.value = model.current.text;
|
||||
if (!el.value && options.current.placeholder) el.dataset.placeholder = options.current.placeholder;
|
||||
else delete el.dataset.placeholder;
|
||||
if (options.current.autoLines) {
|
||||
if (originalHeight.current === null) originalHeight.current = el.style.height;
|
||||
el.style.height = 'auto';
|
||||
const style = getComputedStyle(el);
|
||||
const adjustment = style.boxSizing === 'border-box'
|
||||
? parseFloat(style.borderTopWidth) + parseFloat(style.borderBottomWidth)
|
||||
: -parseFloat(style.paddingTop) - parseFloat(style.paddingBottom);
|
||||
el.style.height = `${el.scrollHeight + adjustment}px`;
|
||||
} else if (originalHeight.current !== null) {
|
||||
el.style.height = originalHeight.current;
|
||||
originalHeight.current = null;
|
||||
}
|
||||
matchPainter.current?.paint(options.current.highlightText);
|
||||
};
|
||||
|
||||
const newValue = enableHighlight ? highlight(value) : value;
|
||||
const oldValue = parseLines(el.innerHTML, true);
|
||||
const decorate = () => {
|
||||
clearTimeout(timer.current);
|
||||
const el = root.current;
|
||||
if (!el || composing.current) return;
|
||||
const focused = el.ownerDocument.activeElement === el;
|
||||
const selection = focused ? readDOM(el) : model.current;
|
||||
const { scrollTop, scrollLeft } = el;
|
||||
const desired = el.ownerDocument.createElement('div');
|
||||
renderSource(desired, model.current.text, options.current.enableHighlight);
|
||||
matchPainter.current?.decorate(desired, options.current.highlightText);
|
||||
if (!model.current.text || model.current.text.endsWith('\n')) {
|
||||
const end = el.ownerDocument.createElement('br');
|
||||
end.setAttribute('data-ce-end', '');
|
||||
desired.append(end);
|
||||
}
|
||||
const changed = reconcile(el, desired);
|
||||
if (focused && changed) restoreSelection(el, { ...selection, text: model.current.text });
|
||||
sync();
|
||||
el.scrollTop = scrollTop;
|
||||
el.scrollLeft = scrollLeft;
|
||||
};
|
||||
|
||||
if (oldValue === newValue) return;
|
||||
const schedule = () => {
|
||||
clearTimeout(timer.current);
|
||||
if (!composing.current) timer.current = setTimeout(decorate, 120);
|
||||
};
|
||||
|
||||
const offset = document.activeElement === el ? getCaretOffset(el) : null;
|
||||
el.innerHTML = newValue;
|
||||
(el as any).value = value;
|
||||
if (offset !== null) setCaretOffset(el, offset);
|
||||
if (autoLines) resizeToContent(el);
|
||||
}, [value, enableHighlight]);
|
||||
const remember = (previous: Snapshot, next: Snapshot, kind: string) => {
|
||||
if (previous.text === next.text) return;
|
||||
const now = Date.now();
|
||||
const group = grouping.current;
|
||||
const continuous = group && group.kind === kind && (kind === 'composition' || (now - group.time < 750 &&
|
||||
previous.anchor === previous.focus && previous.focus === group.focus &&
|
||||
(kind === 'insertText' || kind === 'deleteContentBackward' || kind === 'deleteContentForward')));
|
||||
if (!continuous) {
|
||||
past.current.push(previous);
|
||||
if (past.current.length > 200) past.current.shift();
|
||||
}
|
||||
future.current = [];
|
||||
grouping.current = { kind, time: now, focus: next.focus };
|
||||
};
|
||||
|
||||
const handleInput: JSX.EventHandler<JSX.TargetedInputEvent<HTMLDivElement>> = (e) => {
|
||||
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);
|
||||
const notify = (inputType: string, data: string | null = null) => {
|
||||
notifying.current = true;
|
||||
try {
|
||||
root.current!.dispatchEvent(new InputEvent('input', { bubbles: true, composed: true, inputType, data }));
|
||||
} finally { notifying.current = false; }
|
||||
};
|
||||
|
||||
const replaceSelection = (text: string, inputType: string, range?: Pick<Snapshot, 'anchor' | 'focus'>) => {
|
||||
const el = root.current!;
|
||||
const previous = readDOM(el);
|
||||
const selection = range ?? previous;
|
||||
const start = Math.min(selection.anchor, selection.focus);
|
||||
const end = Math.max(selection.anchor, selection.focus);
|
||||
const inserted = normalize(text);
|
||||
const position = start + inserted.length;
|
||||
const next = { text: previous.text.slice(0, start) + inserted + previous.text.slice(end), anchor: position, focus: position };
|
||||
remember(previous, next, inputType);
|
||||
model.current = next;
|
||||
decorate();
|
||||
restoreSelection(el, next);
|
||||
before.current = undefined;
|
||||
notify(inputType, inserted);
|
||||
};
|
||||
|
||||
const undo = (redo: boolean) => {
|
||||
const source = redo ? future.current : past.current;
|
||||
const destination = redo ? past.current : future.current;
|
||||
const snapshot = source.pop();
|
||||
if (!snapshot) return;
|
||||
destination.push(readDOM(root.current!));
|
||||
model.current = snapshot;
|
||||
grouping.current = undefined;
|
||||
decorate();
|
||||
restoreSelection(root.current!, snapshot);
|
||||
notify(redo ? 'historyRedo' : 'historyUndo');
|
||||
};
|
||||
|
||||
const applyExternal = (text: string) => {
|
||||
const selection = readDOM(root.current!);
|
||||
model.current = { text, anchor: Math.min(selection.anchor, text.length), focus: Math.min(selection.focus, text.length) };
|
||||
past.current = [];
|
||||
future.current = [];
|
||||
grouping.current = undefined;
|
||||
decorate();
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
matchPainter.current = createMatchPainter(root.current!);
|
||||
decorate();
|
||||
let width = root.current!.clientWidth;
|
||||
const observer = new ResizeObserver(() => {
|
||||
const next = root.current?.clientWidth;
|
||||
if (next !== width) { width = next ?? 0; sync(); }
|
||||
});
|
||||
observer.observe(root.current!);
|
||||
const fonts = root.current!.ownerDocument.fonts;
|
||||
fonts?.addEventListener('loadingdone', sync);
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
fonts?.removeEventListener('loadingdone', sync);
|
||||
clearTimeout(timer.current);
|
||||
clearTimeout(settleTimer.current);
|
||||
matchPainter.current?.destroy();
|
||||
matchPainter.current = undefined;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const next = normalize(value);
|
||||
if (lastProp.current === next) return;
|
||||
lastProp.current = next;
|
||||
if (next === model.current.text) { pending.current = null; return; }
|
||||
if (composing.current) pending.current = next;
|
||||
else applyExternal(next);
|
||||
}, [value]);
|
||||
|
||||
useLayoutEffect(() => { decorate(); sync(); }, [enableHighlight, autoLines, placeholder]);
|
||||
useLayoutEffect(() => {
|
||||
if (matchPainter.current?.native) matchPainter.current.paint(highlightText);
|
||||
else decorate();
|
||||
}, [highlightText]);
|
||||
|
||||
const handleInput: NonNullable<Props['onInput']> = event => {
|
||||
if (disabled) return;
|
||||
if (!notifying.current) {
|
||||
const next = readDOM(root.current!);
|
||||
const previous = before.current ?? model.current;
|
||||
remember(previous, next, composing.current || event.isComposing ? 'composition' : event.inputType || 'insertText');
|
||||
model.current = next;
|
||||
before.current = undefined;
|
||||
sync();
|
||||
schedule();
|
||||
}
|
||||
onInput?.(event);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
{...props}
|
||||
contentEditable={!disabled}
|
||||
data-placeholder={value?.replaceAll('\n', '').length ? undefined : placeholder}
|
||||
class={clsx(styles.root, autoLines && styles.autoLines, externalClass, disabled && styles.disabled)}
|
||||
onInput={disabled ? undefined : handleInput}
|
||||
ref={root}
|
||||
class={clsx(styles.root, autoLines && styles.autoLines, externalClass, className, disabled && styles.disabled)}
|
||||
contentEditable={disabled ? false : 'plaintext-only'}
|
||||
role={props.role ?? 'textbox'}
|
||||
aria-multiline="true"
|
||||
aria-disabled={disabled}
|
||||
tabIndex={disabled ? -1 : props.tabIndex ?? props.tabindex ?? 0}
|
||||
onInput={handleInput}
|
||||
onBeforeInput={event => {
|
||||
onBeforeInput?.(event);
|
||||
if (event.defaultPrevented || disabled || notifying.current) return;
|
||||
before.current = readDOM(root.current!);
|
||||
if (composing.current || event.isComposing || !event.cancelable) return;
|
||||
// Browsers merge styled block spans when replacing a selection,
|
||||
// sometimes removing a newline outside the selected text. Apply
|
||||
// that replacement to the source offsets instead of the layout.
|
||||
const hasSelection = before.current.anchor !== before.current.focus;
|
||||
if (hasSelection && event.inputType === 'insertText' && event.data !== null) {
|
||||
event.preventDefault();
|
||||
replaceSelection(event.data, event.inputType);
|
||||
} else if (event.inputType === 'deleteContentBackward' || event.inputType === 'deleteContentForward') {
|
||||
// Native block merging may remove an adjacent source newline
|
||||
// as well as the intended glyph. Delete one source grapheme
|
||||
// explicitly, keeping emoji and combining sequences whole.
|
||||
event.preventDefault();
|
||||
const range = hasSelection ? undefined : deletionRange(root.current!, before.current.focus, event.inputType === 'deleteContentBackward');
|
||||
if (hasSelection || range) replaceSelection('', event.inputType, range);
|
||||
} else if (event.inputType === 'insertParagraph' || event.inputType === 'insertLineBreak') {
|
||||
event.preventDefault();
|
||||
replaceSelection('\n', 'insertLineBreak');
|
||||
} else if (event.inputType === 'historyUndo' || event.inputType === 'historyRedo') {
|
||||
event.preventDefault();
|
||||
undo(event.inputType === 'historyRedo');
|
||||
}
|
||||
}}
|
||||
onKeyDown={event => {
|
||||
onKeyDown?.(event);
|
||||
if (event.defaultPrevented || disabled || composing.current || event.isComposing || event.keyCode === 229) return;
|
||||
if ((event.ctrlKey || event.metaKey) && !event.altKey && (event.key.toLowerCase() === 'z' || event.key.toLowerCase() === 'y')) {
|
||||
event.preventDefault();
|
||||
undo(event.key.toLowerCase() === 'y' || event.shiftKey);
|
||||
} else if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
replaceSelection('\n', 'insertLineBreak');
|
||||
}
|
||||
}}
|
||||
onPaste={event => {
|
||||
onPaste?.(event);
|
||||
if (event.defaultPrevented || disabled || composing.current || !event.clipboardData) return;
|
||||
event.preventDefault();
|
||||
replaceSelection(event.clipboardData.getData('text/plain'), 'insertFromPaste');
|
||||
}}
|
||||
onCopy={event => {
|
||||
onCopy?.(event);
|
||||
if (event.defaultPrevented || !event.clipboardData) return;
|
||||
const selection = readDOM(root.current!);
|
||||
event.clipboardData.setData('text/plain', selectedText(selection));
|
||||
event.preventDefault();
|
||||
}}
|
||||
onCut={event => {
|
||||
onCut?.(event);
|
||||
if (event.defaultPrevented || disabled || composing.current || !event.clipboardData) return;
|
||||
const selection = readDOM(root.current!);
|
||||
event.clipboardData.setData('text/plain', selectedText(selection));
|
||||
event.preventDefault();
|
||||
replaceSelection('', 'deleteByCut');
|
||||
}}
|
||||
{...{
|
||||
// Preact core does not lowercase composition event names on browsers
|
||||
// without an oncompositionstart property. Use their native names.
|
||||
oncompositionstartCapture: onCompositionStartCapture,
|
||||
oncompositionendCapture: onCompositionEndCapture,
|
||||
oncompositionupdateCapture: onCompositionUpdateCapture,
|
||||
oncompositionupdate: onCompositionUpdate,
|
||||
oncompositionstart: (event: JSX.TargetedCompositionEvent<HTMLDivElement>) => {
|
||||
composing.current = true;
|
||||
grouping.current = undefined;
|
||||
before.current = readDOM(root.current!);
|
||||
clearTimeout(timer.current);
|
||||
clearTimeout(settleTimer.current);
|
||||
onCompositionStart?.(event);
|
||||
},
|
||||
oncompositionend: (event: JSX.TargetedCompositionEvent<HTMLDivElement>) => {
|
||||
onCompositionEnd?.(event);
|
||||
clearTimeout(settleTimer.current);
|
||||
// Keep the DOM untouched through the final native input, which
|
||||
// may arrive either before or after compositionend.
|
||||
settleTimer.current = setTimeout(() => {
|
||||
composing.current = false;
|
||||
grouping.current = undefined;
|
||||
if (pending.current !== null) {
|
||||
const next = pending.current;
|
||||
pending.current = null;
|
||||
applyExternal(next);
|
||||
} else schedule();
|
||||
}, 30);
|
||||
},
|
||||
}}
|
||||
onBlur={event => { if (!composing.current) decorate(); onBlur?.(event); }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -52,8 +52,14 @@ export const importStoredReducer = <T>(key: string): boolean => {
|
|||
return true;
|
||||
};
|
||||
|
||||
export const useRemoteReducer = <T, A>(key: string, reducer: Reducer<T, A>, initialValue: T) => {
|
||||
export interface RemoteReducerOptions {
|
||||
/** Skip persisting changes while true; the latest state is saved when it turns false again. */
|
||||
paused?: boolean;
|
||||
}
|
||||
|
||||
export const useRemoteReducer = <T, A>(key: string, reducer: Reducer<T, A>, initialValue: T, options: RemoteReducerOptions = {}) => {
|
||||
const storedKey = remoteReducerKey(key);
|
||||
const paused = options.paused ?? false;
|
||||
|
||||
const getInitialValue = (): T => {
|
||||
try {
|
||||
|
|
@ -73,8 +79,9 @@ export const useRemoteReducer = <T, A>(key: string, reducer: Reducer<T, A>, init
|
|||
}, [storedKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (paused) return;
|
||||
saveObject(storedKey, state);
|
||||
}, [storedKey, state]);
|
||||
}, [storedKey, state, paused]);
|
||||
|
||||
return [state, dispatch] as [T, Dispatch<A>];
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import { useInputState } from "@common/hooks/useInputState";
|
|||
import { useMediaQuery } from "@common/hooks/useMediaQuery";
|
||||
import clsx from "clsx";
|
||||
import { Check, ChevronsRight, Edit2, GitFork, RefreshCw, SendHorizonal, Sparkles, Square, Trash2, X } from "lucide-preact";
|
||||
import { Component } from "preact";
|
||||
import type { ComponentType } from "preact";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
import { MOBILE_BREAKPOINT } from '../assets/breakpoints';
|
||||
import styles from '../assets/chat-sidebar.module.css';
|
||||
|
|
@ -15,55 +17,193 @@ import { Tools } from "../utils/tools";
|
|||
import { useChapterSummarization } from "../utils/useChapterSummarization";
|
||||
import { Sidebar } from "./sidebar";
|
||||
|
||||
/** Minimal `memo` for preact core (avoids pulling in the whole `preact/compat` layer). */
|
||||
function memo<P extends object>(Render: ComponentType<P>) {
|
||||
return class Memoized extends Component<P> {
|
||||
override shouldComponentUpdate(nextProps: P) {
|
||||
const prevProps = this.props as Record<string, unknown>;
|
||||
const next = nextProps as Record<string, unknown>;
|
||||
const prevKeys = Object.keys(prevProps);
|
||||
const nextKeys = Object.keys(next);
|
||||
if (prevKeys.length !== nextKeys.length) return true;
|
||||
return nextKeys.some(key => prevProps[key] !== next[key]);
|
||||
}
|
||||
|
||||
interface RoleHeaderProps {
|
||||
message: ChatMessage;
|
||||
chatMessages: ChatMessage[];
|
||||
override render() {
|
||||
return <Render {...this.props} />;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const RoleHeader = ({ message, chatMessages }: RoleHeaderProps) => {
|
||||
const { currentWorld, userName } = useAppState();
|
||||
|
||||
const toolName = useMemo(() => {
|
||||
if (message.role !== 'tool') return;
|
||||
for (const m of chatMessages.toReversed()) {
|
||||
if (m.role !== 'assistant') continue;
|
||||
const toolCall = m.tool_calls?.find(tc => tc.id === message.tool_call_id);
|
||||
if (toolCall) return toolCall.function.name;
|
||||
}
|
||||
}, [message, chatMessages]);
|
||||
|
||||
let displayName: string = message.role;
|
||||
let roleLabel = null;
|
||||
|
||||
if (message.role === 'tool' && toolName) {
|
||||
displayName = toolName;
|
||||
roleLabel = message.role;
|
||||
} else if (currentWorld?.chatOnly) {
|
||||
if (message.role === 'user' && userName) {
|
||||
displayName = userName;
|
||||
roleLabel = message.role;
|
||||
} else if (message.role === 'assistant' && currentWorld.title) {
|
||||
displayName = currentWorld.title;
|
||||
roleLabel = message.role;
|
||||
}
|
||||
}
|
||||
interface RoleHeaderProps {
|
||||
displayName: string;
|
||||
roleLabel: string | null;
|
||||
}
|
||||
|
||||
const RoleHeader = ({ displayName, roleLabel }: RoleHeaderProps) => {
|
||||
return (
|
||||
<div class={styles.role}>
|
||||
<span>{displayName}</span>
|
||||
{roleLabel && (
|
||||
<span class={styles.toolBadge}>
|
||||
{message.role}
|
||||
{roleLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface MessageRowProps {
|
||||
message: ChatMessage;
|
||||
displayName: string;
|
||||
roleLabel: string | null;
|
||||
isEditing: boolean;
|
||||
isLoading: boolean;
|
||||
chatOnly: boolean;
|
||||
substitution: Prompt.SubstitutionVars;
|
||||
editingContent: string;
|
||||
onEditInput: (e: Event | string) => void;
|
||||
onStartEdit: (message: ChatMessage) => void;
|
||||
onCancelEdit: () => void;
|
||||
onSaveEdit: (messageId: string, content: string) => void;
|
||||
onDelete: (messageId: string) => void;
|
||||
onFork: (messageId: string) => void;
|
||||
}
|
||||
|
||||
const MessageRow = memo(function MessageRow({
|
||||
message,
|
||||
displayName,
|
||||
roleLabel,
|
||||
isEditing,
|
||||
isLoading,
|
||||
chatOnly,
|
||||
substitution,
|
||||
editingContent,
|
||||
onEditInput,
|
||||
onStartEdit,
|
||||
onCancelEdit,
|
||||
onSaveEdit,
|
||||
onDelete,
|
||||
onFork,
|
||||
}: MessageRowProps) {
|
||||
const canEdit = message.role === 'user' || message.role === 'assistant';
|
||||
|
||||
// Markdown highlighting is expensive; only redo it when this message (or the substitution vars) change.
|
||||
const contentHtml = useMemo(
|
||||
() => highlight(Prompt.substitute(message.content, substitution), false).trim(),
|
||||
[message.content, substitution],
|
||||
);
|
||||
|
||||
return (
|
||||
<div class={styles.message} data-role={message.role}>
|
||||
<div class={styles.messageHeader}>
|
||||
<RoleHeader displayName={displayName} roleLabel={roleLabel} />
|
||||
|
||||
{!isLoading && canEdit && (
|
||||
<div class={styles.messageActions}>
|
||||
{isEditing ? (
|
||||
<>
|
||||
<button
|
||||
class={clsx(styles.iconButton, styles.saveButton)}
|
||||
onClick={() => onSaveEdit(message.id, editingContent)}
|
||||
disabled={!editingContent.trim()}
|
||||
title="Save (Ctrl+Enter)"
|
||||
>
|
||||
<Check size={12} />
|
||||
</button>
|
||||
<button
|
||||
class={clsx(styles.iconButton, styles.cancelButton)}
|
||||
onClick={onCancelEdit}
|
||||
title="Cancel (Esc)"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{chatOnly && (
|
||||
<button
|
||||
class={styles.iconButton}
|
||||
onClick={() => onFork(message.id)}
|
||||
title="Fork chat up to this message"
|
||||
>
|
||||
<GitFork size={12} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
class={styles.iconButton}
|
||||
onClick={() => onStartEdit(message)}
|
||||
title="Edit message"
|
||||
>
|
||||
<Edit2 size={12} />
|
||||
</button>
|
||||
<button
|
||||
class={styles.iconButton}
|
||||
onClick={() => onDelete(message.id)}
|
||||
title="Delete this and all following messages"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isEditing ? (
|
||||
<div class={styles.editContainer}>
|
||||
<ContentEditable
|
||||
autoLines
|
||||
highlight
|
||||
class={styles.editTextarea}
|
||||
value={editingContent}
|
||||
onInput={onEditInput}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
onSaveEdit(message.id, editingContent);
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onCancelEdit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{message.role === 'assistant' && message.reasoning_content?.trim() && (
|
||||
<div class={styles.reasoningContent}>
|
||||
{message.reasoning_content.trim()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
class={styles.content}
|
||||
dangerouslySetInnerHTML={{ __html: contentHtml }}
|
||||
/>
|
||||
|
||||
{message.role === 'assistant' && message.tool_calls && (
|
||||
<div class={styles.toolCalls}>
|
||||
{message.tool_calls.map((tool) => (
|
||||
<span key={tool.id} class={styles.toolBadge}>
|
||||
{tool.function.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export const ChatPanel = ({ visible }: { visible: boolean }) => {
|
||||
const appState = useAppState();
|
||||
const { currentWorld, currentStory, dispatch, connection, model, enableThinking, chatOpen, continuePrompt } = appState;
|
||||
const {
|
||||
currentWorld, currentStory, dispatch, connection, model, enableThinking,
|
||||
chatOpen, continuePrompt, userName, chatSystemInstruction, setSavingPaused,
|
||||
} = appState;
|
||||
const { summarizeAll, isSummarizing } = useChapterSummarization();
|
||||
const [input, setInput] = useInputState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
|
@ -82,6 +222,25 @@ export const ChatPanel = ({ visible }: { visible: boolean }) => {
|
|||
|
||||
const lastMessage = currentStory?.chatMessages.at(-1);
|
||||
|
||||
// Stable values for the memoized message rows: they must not change on every streamed chunk.
|
||||
const substitution = useMemo(
|
||||
() => Prompt.getSubstitutionVars(appState),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- exactly the fields read by getSubstitutionVars
|
||||
[currentWorld?.title, userName, chatSystemInstruction],
|
||||
);
|
||||
|
||||
// Resolve tool call ids once per message list change instead of scanning the whole history per message.
|
||||
const toolNames = useMemo(() => {
|
||||
const names = new Map<string, string>();
|
||||
for (const message of currentStory?.chatMessages ?? []) {
|
||||
if (message.role !== 'assistant') continue;
|
||||
for (const toolCall of message.tool_calls ?? []) {
|
||||
names.set(toolCall.id, toolCall.function.name);
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}, [currentStory?.chatMessages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (messagesRef.current) {
|
||||
messagesRef.current.scrollTo({
|
||||
|
|
@ -254,7 +413,6 @@ export const ChatPanel = ({ visible }: { visible: boolean }) => {
|
|||
});
|
||||
|
||||
if (tool_calls) {
|
||||
console.log(tool_calls);
|
||||
const toolMessages: ChatMessage[] = [];
|
||||
for (const tool of tool_calls) {
|
||||
if (abortControllerRef.current?.signal.aborted) {
|
||||
|
|
@ -305,6 +463,8 @@ export const ChatPanel = ({ visible }: { visible: boolean }) => {
|
|||
setIsLoading(true);
|
||||
setError(null);
|
||||
abortControllerRef.current = new AbortController();
|
||||
// Don't persist on every streamed chunk — the final state is saved when this finishes.
|
||||
setSavingPaused(true);
|
||||
|
||||
const excludedMessageIds = new Set<string>();
|
||||
try {
|
||||
|
|
@ -320,9 +480,10 @@ export const ChatPanel = ({ visible }: { visible: boolean }) => {
|
|||
}
|
||||
await sendMessage({ excludedMessageIds });
|
||||
} finally {
|
||||
setSavingPaused(false);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [currentStory, currentWorld, connection, model, isLoading, sendMessage]);
|
||||
}, [currentStory, currentWorld, connection, model, isLoading, sendMessage, setSavingPaused]);
|
||||
|
||||
const handleSendMessage = useCallback(async () => {
|
||||
if (!currentStory || !connection || !model || isLoading) return;
|
||||
|
|
@ -338,6 +499,7 @@ export const ChatPanel = ({ visible }: { visible: boolean }) => {
|
|||
setIsLoading(true);
|
||||
setError(null);
|
||||
abortControllerRef.current = new AbortController();
|
||||
setSavingPaused(true);
|
||||
|
||||
try {
|
||||
await sendMessage({
|
||||
|
|
@ -348,9 +510,10 @@ export const ChatPanel = ({ visible }: { visible: boolean }) => {
|
|||
}]
|
||||
});
|
||||
} finally {
|
||||
setSavingPaused(false);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [currentStory, input, connection, model, isLoading, sendMessage, handleRegenerate]);
|
||||
}, [currentStory, input, connection, model, isLoading, sendMessage, handleRegenerate, setSavingPaused]);
|
||||
|
||||
const handleContinue = useCallback(async () => {
|
||||
if (!currentStory || !connection || !model || isLoading) return;
|
||||
|
|
@ -359,6 +522,7 @@ export const ChatPanel = ({ visible }: { visible: boolean }) => {
|
|||
setIsLoading(true);
|
||||
setError(null);
|
||||
abortControllerRef.current = new AbortController();
|
||||
setSavingPaused(true);
|
||||
|
||||
try {
|
||||
if (currentWorld?.chatOnly) {
|
||||
|
|
@ -373,9 +537,10 @@ export const ChatPanel = ({ visible }: { visible: boolean }) => {
|
|||
});
|
||||
}
|
||||
} finally {
|
||||
setSavingPaused(false);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [currentStory, currentWorld, input, connection, model, isLoading, sendMessage]);
|
||||
}, [currentStory, currentWorld, input, connection, model, isLoading, sendMessage, setSavingPaused]);
|
||||
|
||||
const handleStopGeneration = useCallback(() => {
|
||||
abortControllerRef.current?.abort();
|
||||
|
|
@ -390,6 +555,7 @@ export const ChatPanel = ({ visible }: { visible: boolean }) => {
|
|||
};
|
||||
|
||||
const handleDeleteMessage = useCallback((messageId: string) => {
|
||||
const { currentWorld, currentStory } = appStateRef.current;
|
||||
if (!currentStory || !currentWorld) return;
|
||||
if (!confirm('Delete this and all following messages?')) return;
|
||||
dispatch({
|
||||
|
|
@ -398,9 +564,10 @@ export const ChatPanel = ({ visible }: { visible: boolean }) => {
|
|||
storyId: currentStory.id,
|
||||
messageId,
|
||||
});
|
||||
}, [currentStory, currentWorld, dispatch]);
|
||||
}, [dispatch]);
|
||||
|
||||
const handleForkChat = useCallback((messageId: string) => {
|
||||
const { currentWorld, currentStory } = appStateRef.current;
|
||||
if (!currentStory || !currentWorld) return;
|
||||
dispatch({
|
||||
type: 'DUPLICATE_STORY',
|
||||
|
|
@ -408,27 +575,28 @@ export const ChatPanel = ({ visible }: { visible: boolean }) => {
|
|||
id: currentStory.id,
|
||||
upToMessageId: messageId,
|
||||
});
|
||||
}, [currentStory, currentWorld, dispatch]);
|
||||
}, [dispatch]);
|
||||
|
||||
const handleStartEdit = useCallback((message: ChatMessage) => {
|
||||
setEditingMessageId(message.id);
|
||||
setEditingContent(message.content);
|
||||
}, [setEditingContent]);
|
||||
|
||||
const handleSaveEdit = useCallback(() => {
|
||||
if (!currentStory || !currentWorld || !editingMessageId) return;
|
||||
const handleSaveEdit = useCallback((messageId: string, content: string) => {
|
||||
const { currentWorld, currentStory } = appStateRef.current;
|
||||
if (!currentStory || !currentWorld || !content.trim()) return;
|
||||
|
||||
dispatch({
|
||||
type: 'EDIT_CHAT_MESSAGE',
|
||||
worldId: currentWorld.id,
|
||||
storyId: currentStory.id,
|
||||
messageId: editingMessageId,
|
||||
content: editingContent.trim(),
|
||||
messageId,
|
||||
content: content.trim(),
|
||||
});
|
||||
|
||||
setEditingMessageId(null);
|
||||
setEditingContent('');
|
||||
}, [currentStory, currentWorld, editingMessageId, editingContent, dispatch, setEditingContent]);
|
||||
}, [dispatch, setEditingContent]);
|
||||
|
||||
const handleCancelEdit = useCallback(() => {
|
||||
setEditingMessageId(null);
|
||||
|
|
@ -457,109 +625,44 @@ export const ChatPanel = ({ visible }: { visible: boolean }) => {
|
|||
<div class={styles.messages} ref={messagesRef}>
|
||||
{currentStory.chatMessages.map((message) => {
|
||||
const isEditing = editingMessageId === message.id;
|
||||
const canEdit = message.role === 'user' || message.role === 'assistant';
|
||||
const toolName = message.role === 'tool' && message.tool_call_id
|
||||
? toolNames.get(message.tool_call_id)
|
||||
: undefined;
|
||||
|
||||
let displayName: string = message.role;
|
||||
let roleLabel: string | null = null;
|
||||
|
||||
if (message.role === 'tool' && toolName) {
|
||||
displayName = toolName;
|
||||
roleLabel = message.role;
|
||||
} else if (currentWorld?.chatOnly) {
|
||||
if (message.role === 'user' && userName) {
|
||||
displayName = userName;
|
||||
roleLabel = message.role;
|
||||
} else if (message.role === 'assistant' && currentWorld.title) {
|
||||
displayName = currentWorld.title;
|
||||
roleLabel = message.role;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={message.id} class={styles.message} data-role={message.role}>
|
||||
<div class={styles.messageHeader}>
|
||||
<RoleHeader message={message} chatMessages={currentStory.chatMessages} />
|
||||
|
||||
{!isLoading && canEdit && (
|
||||
<div class={styles.messageActions}>
|
||||
{isEditing ? (
|
||||
<>
|
||||
<button
|
||||
class={clsx(styles.iconButton, styles.saveButton)}
|
||||
onClick={handleSaveEdit}
|
||||
disabled={!editingContent.trim()}
|
||||
title="Save (Ctrl+Enter)"
|
||||
>
|
||||
<Check size={12} />
|
||||
</button>
|
||||
<button
|
||||
class={clsx(styles.iconButton, styles.cancelButton)}
|
||||
onClick={handleCancelEdit}
|
||||
title="Cancel (Esc)"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{currentWorld?.chatOnly && (
|
||||
<button
|
||||
class={styles.iconButton}
|
||||
onClick={() => handleForkChat(message.id)}
|
||||
title="Fork chat up to this message"
|
||||
>
|
||||
<GitFork size={12} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
class={styles.iconButton}
|
||||
onClick={() => handleStartEdit(message)}
|
||||
title="Edit message"
|
||||
>
|
||||
<Edit2 size={12} />
|
||||
</button>
|
||||
<button
|
||||
class={styles.iconButton}
|
||||
onClick={() => handleDeleteMessage(message.id)}
|
||||
title="Delete this and all following messages"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isEditing ? (
|
||||
<div class={styles.editContainer}>
|
||||
<ContentEditable
|
||||
autoLines
|
||||
highlight
|
||||
class={styles.editTextarea}
|
||||
value={editingContent}
|
||||
onInput={setEditingContent}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
handleSaveEdit();
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
handleCancelEdit();
|
||||
}
|
||||
}}
|
||||
<MessageRow
|
||||
key={message.id}
|
||||
message={message}
|
||||
displayName={displayName}
|
||||
roleLabel={roleLabel}
|
||||
isEditing={isEditing}
|
||||
isLoading={isLoading}
|
||||
chatOnly={currentWorld?.chatOnly ?? false}
|
||||
substitution={substitution}
|
||||
editingContent={isEditing ? editingContent : ''}
|
||||
onEditInput={setEditingContent}
|
||||
onStartEdit={handleStartEdit}
|
||||
onCancelEdit={handleCancelEdit}
|
||||
onSaveEdit={handleSaveEdit}
|
||||
onDelete={handleDeleteMessage}
|
||||
onFork={handleForkChat}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{message.role === 'assistant' && message.reasoning_content?.trim() && (
|
||||
<div class={styles.reasoningContent}>
|
||||
{message.reasoning_content.trim()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
class={styles.content}
|
||||
dangerouslySetInnerHTML={{ __html: highlight(Prompt.substituteVars(appState, message.content), false).trim() }}
|
||||
/>
|
||||
|
||||
{message.role === 'assistant' && message.tool_calls && (
|
||||
<div class={styles.toolCalls}>
|
||||
{message.tool_calls.map((tool) => (
|
||||
<span key={tool.id} class={styles.toolBadge}>
|
||||
{tool.function.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{error && (
|
||||
|
|
|
|||
|
|
@ -67,10 +67,11 @@ export const Editor = () => {
|
|||
const editorRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const promptPreview = useMemo(() => {
|
||||
if (currentTab !== 'prompt') return '';
|
||||
let text = Prompt.formatSystemPrompt(appState);
|
||||
text = Prompt.substituteVars(appState, text);
|
||||
return highlight(text, false);
|
||||
}, [appState]);
|
||||
}, [appState, currentTab]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentStory?.lastEditedText) {
|
||||
|
|
|
|||
|
|
@ -17,20 +17,6 @@ export const StoryEditor = ({ visible }: { visible: boolean }) => {
|
|||
});
|
||||
}, [currentStory?.id, currentWorld?.id]);
|
||||
|
||||
const value = useMemo(() => {
|
||||
if (!currentStory) return '';
|
||||
|
||||
const { text, lastEditedText } = currentStory;
|
||||
if (!lastEditedText) return text;
|
||||
|
||||
const idx = text.lastIndexOf(lastEditedText);
|
||||
if (idx === -1) return text;
|
||||
|
||||
const marked = text.slice(0, idx) + '<mark>' + lastEditedText + '</mark>' + text.slice(idx + lastEditedText.length);
|
||||
return marked;
|
||||
}, [currentStory?.text, currentStory?.lastEditedText]);
|
||||
|
||||
|
||||
if (!currentWorld || !currentStory || !visible) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -39,7 +25,8 @@ export const StoryEditor = ({ visible }: { visible: boolean }) => {
|
|||
<ContentEditable
|
||||
highlight
|
||||
class={styles.editable}
|
||||
value={value}
|
||||
value={currentStory.text}
|
||||
highlightText={currentStory.lastEditedText}
|
||||
onInput={handleInput}
|
||||
placeholder="Start writing your story..."
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useRemoteReducer } from "@common/hooks/useRemote";
|
||||
import { createContext } from "preact";
|
||||
import { useContext, useMemo } from "preact/hooks";
|
||||
import { useContext, useMemo, useState } from "preact/hooks";
|
||||
import Chapters from "../utils/chapters";
|
||||
import LLM from "../utils/llm";
|
||||
|
||||
|
|
@ -686,6 +686,8 @@ export interface AppState {
|
|||
imageGenerationSettings: ImageGenerationSettings;
|
||||
/** Effective system instruction: world override if set, otherwise global */
|
||||
effectiveSystemInstruction: string;
|
||||
/** Pause persistence (used while streaming; the latest state is saved once streaming ends) */
|
||||
setSavingPaused: (paused: boolean) => void;
|
||||
dispatch: (action: Action) => void;
|
||||
}
|
||||
|
||||
|
|
@ -698,7 +700,8 @@ export const useAppState = () => useContext(StateContext);
|
|||
export const STATE_KEY = 'state';
|
||||
|
||||
export const StateContextProvider = ({ children }: { children?: any }) => {
|
||||
const [state, dispatch] = useRemoteReducer(STATE_KEY, reducer, DEFAULT_STATE);
|
||||
const [savingPaused, setSavingPaused] = useState(false);
|
||||
const [state, dispatch] = useRemoteReducer(STATE_KEY, reducer, DEFAULT_STATE, { paused: savingPaused });
|
||||
|
||||
const value = useMemo<AppState>(() => {
|
||||
const currentWorld = state.worlds.find(w => w.id === state.currentWorldId) ?? null;
|
||||
|
|
@ -748,6 +751,7 @@ export const StateContextProvider = ({ children }: { children?: any }) => {
|
|||
? chatSystemInstruction
|
||||
: systemInstruction)
|
||||
|| systemInstruction,
|
||||
setSavingPaused,
|
||||
dispatch,
|
||||
};
|
||||
}, [state]);
|
||||
|
|
|
|||
|
|
@ -284,31 +284,43 @@ namespace Prompt {
|
|||
return lines.join('\n');
|
||||
}
|
||||
|
||||
const objectUrls = new Map<string, string>();
|
||||
function stripDataUrlImages(text: string): string {
|
||||
return text.replace(/!\[([^\]]*)\]\((data:([^;]*);base64,[^)]+)\)/g, (_, alt, dataUrl, type) => {
|
||||
let blobUrl = objectUrls.get(dataUrl);
|
||||
if (!blobUrl) {
|
||||
const b64 = dataUrl.replace(/^data:[^;]*;base64,/, '');
|
||||
const imageData = atob(b64);
|
||||
const blob = new Blob([imageData], { type });
|
||||
blobUrl = URL.createObjectURL(blob);
|
||||
objectUrls.set(dataUrl, blobUrl);
|
||||
/**
|
||||
* Inline images are meaningless to the model and their data/blob URLs are
|
||||
* unstable across sessions (which also breaks provider prompt caching).
|
||||
*/
|
||||
function stripImages(text: string): string {
|
||||
return text.replace(/!\[([^\]]*)\]\((?:data|blob):[^)]*\)/g, (_match, alt: string) =>
|
||||
alt ? `[image: ${alt}]` : '[image]'
|
||||
);
|
||||
}
|
||||
return ``;
|
||||
});
|
||||
|
||||
/** Values used by {@link substituteVars}, extracted so they can be memoized independently of the full app state. */
|
||||
export interface SubstitutionVars {
|
||||
charName: string;
|
||||
userName: string;
|
||||
chatSystemInstruction: string;
|
||||
}
|
||||
|
||||
export function getSubstitutionVars(state: AppState): SubstitutionVars {
|
||||
return {
|
||||
charName: state.currentWorld?.title || 'Assistant',
|
||||
userName: state.userName || 'User',
|
||||
chatSystemInstruction: state.chatSystemInstruction,
|
||||
};
|
||||
}
|
||||
|
||||
export function substitute(text: string, vars: SubstitutionVars): string {
|
||||
return text
|
||||
.replaceAll('{{system}}', vars.chatSystemInstruction)
|
||||
.replaceAll('{{char}}', vars.charName)
|
||||
.replaceAll('{{user}}', vars.userName);
|
||||
}
|
||||
|
||||
export function substituteVars(state: AppState, text: string): string {
|
||||
const charName = state.currentWorld?.title || 'Assistant';
|
||||
const userName = state.userName || 'User';
|
||||
return text
|
||||
.replaceAll('{{system}}', state.chatSystemInstruction)
|
||||
.replaceAll('{{char}}', charName)
|
||||
.replaceAll('{{user}}', userName);
|
||||
return substitute(text, getSubstitutionVars(state));
|
||||
}
|
||||
|
||||
export function formatSystemPrompt(state: AppState, storyTokenBudget: number = 0): string {
|
||||
export function formatSystemPrompt(state: AppState): string {
|
||||
const { currentStory, currentWorld } = state;
|
||||
if (!currentStory) {
|
||||
return state.effectiveSystemInstruction;
|
||||
|
|
@ -350,11 +362,10 @@ namespace Prompt {
|
|||
parts.push(`## Scratchpad`, currentStory.scratchpad);
|
||||
}
|
||||
|
||||
if (currentStory.text && storyTokenBudget > 0) {
|
||||
const storyText = formatStoryChunks(currentStory.text, currentStory.chapters ?? [], storyTokenBudget);
|
||||
if (storyText) {
|
||||
parts.push(`## Story Text:`, storyText);
|
||||
}
|
||||
// The story is the source of truth and is sent in full, exactly as it
|
||||
// currently is in the editor (including manual user edits).
|
||||
if (currentStory.text) {
|
||||
parts.push(`## Story Text:`, currentStory.text);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -368,6 +379,62 @@ namespace Prompt {
|
|||
keepThinking?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reply used when an assistant turn consisted only of tool calls and has no
|
||||
* text left. Keeps every user message paired with a reply so older
|
||||
* instructions don't read like one continuous current instruction.
|
||||
*/
|
||||
const EMPTY_ASSISTANT_REPLY = 'Done.';
|
||||
|
||||
/**
|
||||
* Story-mode transcript: older tool traffic is dropped because the story and
|
||||
* scratchpad are sent in full inside the system prompt, while all
|
||||
* user/assistant text is kept so earlier behavioural instructions survive.
|
||||
*
|
||||
* The most recent tool request/response round is kept so the model can see
|
||||
* the result of its last action (including errors) and does not retry it.
|
||||
*/
|
||||
function toTranscript(chatMessages: ChatMessage[]): LLM.ChatMessage[] {
|
||||
const transcript: LLM.ChatMessage[] = [];
|
||||
|
||||
for (let i = 0; i < chatMessages.length; i++) {
|
||||
const message = chatMessages[i];
|
||||
|
||||
if (message.role === 'tool') {
|
||||
transcript.push({
|
||||
role: 'tool',
|
||||
content: message.content,
|
||||
tool_call_id: message.tool_call_id,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (message.role === 'assistant') {
|
||||
if (message.tool_calls?.length) {
|
||||
transcript.push({
|
||||
role: 'assistant',
|
||||
content: message.content ?? '',
|
||||
tool_calls: message.tool_calls,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (!message.content.trim()) continue;
|
||||
transcript.push({ role: 'assistant', content: message.content });
|
||||
continue;
|
||||
}
|
||||
|
||||
transcript.push({ role: message.role, content: message.content });
|
||||
}
|
||||
|
||||
for (let i = transcript.length - 1; i > 0; i--) {
|
||||
if (transcript[i].role === 'user' && transcript[i - 1].role === 'user') {
|
||||
transcript.splice(i, 0, { role: 'assistant', content: EMPTY_ASSISTANT_REPLY });
|
||||
}
|
||||
}
|
||||
|
||||
return transcript;
|
||||
}
|
||||
|
||||
export function compilePrompt(
|
||||
state: AppState,
|
||||
config: CompileConfig = { keepThinking: true },
|
||||
|
|
@ -404,7 +471,7 @@ namespace Prompt {
|
|||
const applyVars = (msgs: ChatMessage[]) =>
|
||||
msgs.map(m => ({
|
||||
...m,
|
||||
content: stripDataUrlImages(substituteVars(state, m.content)),
|
||||
content: stripImages(substituteVars(state, m.content)),
|
||||
reasoning_content: config.keepThinking && ('reasoning_content' in m)
|
||||
? m.reasoning_content
|
||||
: undefined,
|
||||
|
|
@ -421,7 +488,7 @@ namespace Prompt {
|
|||
formattedMessages.unshift({
|
||||
id: crypto.randomUUID(),
|
||||
role: 'system',
|
||||
content: formatSystemPrompt(state, 0),
|
||||
content: formatSystemPrompt(state),
|
||||
});
|
||||
|
||||
return {
|
||||
|
|
@ -436,25 +503,44 @@ namespace Prompt {
|
|||
};
|
||||
}
|
||||
|
||||
// Estimate token budget for story text
|
||||
let storyTokenBudget = 0;
|
||||
if (model.context_length) {
|
||||
const nonStorySystem = formatSystemPrompt(state, 0);
|
||||
const chatText = messages.map(m => m.content).join('\n');
|
||||
const maxOutput = model.top_provider.max_completion_tokens || 2048;
|
||||
const otherTokens = approxTokens(nonStorySystem) + approxTokens(chatText) + maxOutput;
|
||||
storyTokenBudget = model.context_length - otherTokens;
|
||||
// Story mode: the current story text (with any manual user edits) lives
|
||||
// in the system prompt, so the transcript only carries user/assistant
|
||||
// text plus the most recent tool round. App-only fields (ids, reasoning)
|
||||
// are never sent.
|
||||
const transcript: LLM.ChatMessage[] = toTranscript(messages).map((message) => {
|
||||
const content = stripImages(substituteVars(state, message.content));
|
||||
|
||||
if (message.role === 'assistant' && message.tool_calls?.length) {
|
||||
return {
|
||||
role: 'assistant',
|
||||
content,
|
||||
tool_calls: message.tool_calls.map(toolCall => ({
|
||||
...toolCall,
|
||||
function: {
|
||||
...toolCall.function,
|
||||
arguments: typeof toolCall.function.arguments === 'string'
|
||||
? stripImages(toolCall.function.arguments)
|
||||
: toolCall.function.arguments,
|
||||
},
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
messages.unshift({
|
||||
id: crypto.randomUUID(),
|
||||
if (message.role === 'tool') {
|
||||
return { role: 'tool', content, tool_call_id: message.tool_call_id };
|
||||
}
|
||||
|
||||
return { role: message.role, content } as LLM.ChatMessage;
|
||||
});
|
||||
|
||||
transcript.unshift({
|
||||
role: 'system',
|
||||
content: formatSystemPrompt(state, storyTokenBudget),
|
||||
content: stripImages(substituteVars(state, formatSystemPrompt(state))),
|
||||
});
|
||||
|
||||
return {
|
||||
model: model.id,
|
||||
messages: applyVars(messages),
|
||||
messages: transcript,
|
||||
tools: Tools.getTools(),
|
||||
banned_tokens: state.bannedTokens,
|
||||
reasoning: { effort: enableThinking ? 'high' : 'none' },
|
||||
|
|
@ -465,13 +551,13 @@ namespace Prompt {
|
|||
|
||||
export function applyDelta(input: any, delta: any) {
|
||||
if (Array.isArray(delta)) {
|
||||
const result = [...(input ?? [])];
|
||||
const result = input ?? [];
|
||||
for (const item of delta) {
|
||||
result[item.index] = applyDelta(result[item.index], item);
|
||||
}
|
||||
return result;
|
||||
} else if (typeof delta === 'object') {
|
||||
const result = { ...input };
|
||||
const result = input ?? {};
|
||||
for (const key of Object.keys(delta)) {
|
||||
const value = delta[key];
|
||||
result[key] = applyDelta(result[key], value);
|
||||
|
|
|
|||
|
|
@ -377,14 +377,12 @@ export namespace Tools {
|
|||
cropped = true;
|
||||
}
|
||||
|
||||
dispatchEdit(currentText.trim() + '\n' + args.new_text.trim());
|
||||
dispatchEdit(currentText.trim() + '\n\n' + args.new_text.trim());
|
||||
appState.dispatch({ type: 'SET_CURRENT_TAB', tab });
|
||||
let message = cropped
|
||||
? `Added text:\n${args.new_text.split('\n').filter(l => l.trim()).map(l => '> ' + l).join('\n')}\n\nNote: The rest was cropped due to ${LINES_LIMIT} lines limit!`
|
||||
: `Text appended to ${target} successfully.`;
|
||||
|
||||
message += `\nTool note: you're not allowed to add more text now. Do not retry nor continue.`
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue