allozaur/feat/chat form contenteditable (#26717)
* feat: Add contenteditable tokenizer for badge/code-chip chat input * feat: Add source-space undo/redo history for the rich input * feat: Split text glued to a closing code fence onto its own line * feat: Add ChatFormContenteditable rich input renderer * feat : wire the contenteditable into ChatForm with auto-switch gating
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
import {
|
||||
ChatAttachmentsList,
|
||||
ChatFormActions,
|
||||
ChatFormContenteditable,
|
||||
ChatFormFileInputInvisible,
|
||||
ChatFormMcpResourcesList,
|
||||
ChatFormPickers,
|
||||
@@ -46,10 +47,12 @@
|
||||
} from '$lib/types';
|
||||
import {
|
||||
buildMentionInsertion,
|
||||
containsCodeSpan,
|
||||
containsFileMentionLink,
|
||||
findCommandToken,
|
||||
findMentionToken,
|
||||
isIMEComposing,
|
||||
mentionLinkEndingAt,
|
||||
isOffsetInCodeBlock,
|
||||
parseClipboardContent,
|
||||
uuid
|
||||
} from '$lib/utils';
|
||||
@@ -109,12 +112,28 @@
|
||||
onValueChange
|
||||
}: Props = $props();
|
||||
|
||||
// Component References
|
||||
// Shared handle of the two input renderers (textarea + contenteditable).
|
||||
type ChatInputHandle = {
|
||||
focus(): void;
|
||||
resetHeight(): void;
|
||||
getElement(): HTMLElement | undefined;
|
||||
getCaretOffset(): number;
|
||||
setCaretOffset(offset: number): void;
|
||||
};
|
||||
|
||||
let audioRecorder: AudioRecorder | undefined;
|
||||
let chatFormActionsRef: ChatFormActions | undefined = $state(undefined);
|
||||
let fileInputRef: ChatFormFileInputInvisible | undefined = $state(undefined);
|
||||
let pickersRef: { handleKeydown: (event: KeyboardEvent) => boolean } | undefined =
|
||||
$state(undefined);
|
||||
let inputRef: ChatFormTextarea | undefined = $state(undefined);
|
||||
let inputRef: ChatInputHandle | undefined = $state(undefined);
|
||||
|
||||
// Render-mode gate: the plain textarea by default, the contenteditable
|
||||
// while the buffer carries a `file://` mention link or a complete code
|
||||
// span (badges and code chips need a DOM the textarea cannot provide).
|
||||
// Demotes back once neither remains.
|
||||
let useContenteditable = $state(false);
|
||||
|
||||
// Audio Recording State
|
||||
let isRecording = $state(false);
|
||||
@@ -201,6 +220,34 @@
|
||||
);
|
||||
let canSubmit = $derived(value.trim().length > 0 || hasAttachments);
|
||||
|
||||
// Caret offset restored after a renderer swap. Callers that mutate
|
||||
// `value` themselves (e.g. the mention picker) pin the target offset
|
||||
// BEFORE the assignment; otherwise the swap effect snapshots the
|
||||
// current caret.
|
||||
let pendingCaretOffset = 0;
|
||||
let caretOffsetPinned = false;
|
||||
|
||||
function queueCaretRestore() {
|
||||
queueMicrotask(() => {
|
||||
inputRef?.focus();
|
||||
inputRef?.setCaretOffset(pendingCaretOffset);
|
||||
caretOffsetPinned = false;
|
||||
});
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const wantContenteditable =
|
||||
containsFileMentionLink(value ?? '') || containsCodeSpan(value ?? '');
|
||||
if (useContenteditable === wantContenteditable) return;
|
||||
|
||||
if (!caretOffsetPinned) {
|
||||
pendingCaretOffset = inputRef?.getCaretOffset() ?? (value ?? '').length;
|
||||
}
|
||||
|
||||
useContenteditable = wantContenteditable;
|
||||
queueCaretRestore();
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
recordingSupported = isAudioRecordingSupported();
|
||||
audioRecorder = new AudioRecorder();
|
||||
@@ -252,25 +299,19 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Backspace at a mention link's end deletes the whole token at once.
|
||||
if (event.key === KeyboardKey.BACKSPACE && !event.ctrlKey && !event.metaKey && !event.altKey) {
|
||||
const el = inputRef?.getElement();
|
||||
if (el instanceof HTMLTextAreaElement && el.selectionStart === el.selectionEnd) {
|
||||
const link = mentionLinkEndingAt(value, el.selectionStart);
|
||||
if (link) {
|
||||
event.preventDefault();
|
||||
value = value.slice(0, link.start) + value.slice(link.end);
|
||||
onValueChange?.(value);
|
||||
queueMicrotask(() => inputRef?.setCaretOffset(link.start));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (event.key === KeyboardKey.ENTER && !event.shiftKey && !isIMEComposing(event)) {
|
||||
const isModifier = event.ctrlKey || event.metaKey;
|
||||
const sendOnEnter = currentConfig.sendOnEnter !== false;
|
||||
|
||||
// Caret inside a fenced code block (closed, or still open
|
||||
// while being typed): Enter adds a line, never submits. The
|
||||
// contenteditable consumes this case locally; this gate
|
||||
// covers the plain textarea, where skipping submit lets the
|
||||
// native newline through.
|
||||
if (!isModifier && isOffsetInCodeBlock(value ?? '', inputRef?.getCaretOffset() ?? 0)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (sendOnEnter || isModifier) {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -444,14 +485,20 @@
|
||||
const built = buildMentionInsertion(entry, value, token);
|
||||
if (!built) return;
|
||||
|
||||
// Pin the post-insertion caret BEFORE the swap effect runs;
|
||||
// otherwise the effect clobbers it with the textarea's selection
|
||||
// at promotion time (browser-dependent: usually reset to 0).
|
||||
pendingCaretOffset = built.caretOffset;
|
||||
caretOffsetPinned = true;
|
||||
|
||||
value = built.newValue;
|
||||
onValueChange?.(built.newValue);
|
||||
|
||||
// bind:value applies on the next microtask; restore the caret after.
|
||||
queueMicrotask(() => {
|
||||
inputRef?.focus();
|
||||
inputRef?.setCaretOffset(built.caretOffset);
|
||||
});
|
||||
// Already in contenteditable mode: no renderer flip, so the swap
|
||||
// effect's caret restore never runs.
|
||||
if (useContenteditable) {
|
||||
queueCaretRestore();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMicClick() {
|
||||
@@ -541,19 +588,35 @@
|
||||
<div
|
||||
class="flex-column relative min-h-12 items-center rounded-4xl md:rounded-3xl py-2 pb-2.25 shadow-sm transition-all focus-within:shadow-md md:py-3!"
|
||||
>
|
||||
<ChatFormTextarea
|
||||
class="px-5 py-1.5 md:pt-0"
|
||||
bind:this={inputRef}
|
||||
bind:value
|
||||
onKeydown={handleKeydown}
|
||||
onInput={() => {
|
||||
pickers.handleInput();
|
||||
onValueChange?.(value);
|
||||
}}
|
||||
onPaste={handlePaste}
|
||||
{disabled}
|
||||
{placeholder}
|
||||
/>
|
||||
{#if useContenteditable}
|
||||
<ChatFormContenteditable
|
||||
class="px-5 py-1.5 md:pt-0 mb-0.5"
|
||||
bind:this={inputRef}
|
||||
bind:value
|
||||
onKeydown={handleKeydown}
|
||||
onInput={() => {
|
||||
pickers.handleInput();
|
||||
onValueChange?.(value);
|
||||
}}
|
||||
onPaste={handlePaste}
|
||||
{disabled}
|
||||
{placeholder}
|
||||
/>
|
||||
{:else}
|
||||
<ChatFormTextarea
|
||||
class="px-5 py-1.5 md:pt-0"
|
||||
bind:this={inputRef}
|
||||
bind:value
|
||||
onKeydown={handleKeydown}
|
||||
onInput={() => {
|
||||
pickers.handleInput();
|
||||
onValueChange?.(value);
|
||||
}}
|
||||
onPaste={handlePaste}
|
||||
{disabled}
|
||||
{placeholder}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if mcpHasResourceAttachments()}
|
||||
<ChatFormMcpResourcesList
|
||||
|
||||
@@ -0,0 +1,788 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount, untrack } from 'svelte';
|
||||
import { mode } from 'mode-watcher';
|
||||
import githubDarkCss from 'highlight.js/styles/github-dark.css?inline';
|
||||
import githubLightCss from 'highlight.js/styles/github.css?inline';
|
||||
import { isMobile } from '$lib/stores/viewport.svelte';
|
||||
import { ColorMode } from '$lib/enums';
|
||||
import { TRIM_LEADING_PADDING_REGEX, TRIM_TRAILING_PADDING_REGEX } from '$lib/constants';
|
||||
import {
|
||||
badgeAwareWordJump,
|
||||
buildFragment,
|
||||
domMatchesTokens,
|
||||
highlightCode,
|
||||
isIMEComposing,
|
||||
isOffsetInCodeBlock,
|
||||
leadingBadgeEdgeOffset,
|
||||
rangeToTextOffset,
|
||||
serializeContent,
|
||||
SourceHistory,
|
||||
stripBlockBoundaryLineBreaks,
|
||||
syncCodeBlockHatches,
|
||||
tokenizeContent,
|
||||
textOffsetToRange
|
||||
} from '$lib/utils';
|
||||
import type { ContentToken, SourceHistoryEntry } from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
disabled?: boolean;
|
||||
onInput?: () => void;
|
||||
onKeydown?: (event: KeyboardEvent) => void;
|
||||
onPaste?: (event: ClipboardEvent) => void;
|
||||
placeholder?: string;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
class: className = '',
|
||||
disabled = false,
|
||||
onInput,
|
||||
onKeydown,
|
||||
onPaste,
|
||||
placeholder = 'Ask anything...',
|
||||
value = $bindable('')
|
||||
}: Props = $props();
|
||||
|
||||
let rootElement: HTMLDivElement | undefined = $state();
|
||||
let lastEmittedValue = '';
|
||||
let isComposing = $state(false);
|
||||
|
||||
// Undo/redo in source space: the imperative token rebuilds destroy the
|
||||
// browser's native undo stack.
|
||||
const history = new SourceHistory();
|
||||
|
||||
// Browsers disagree on what an empty contenteditable contains (`<br>`,
|
||||
// `<div><br></div>`, or nothing), so emptiness is decided by the
|
||||
// serialized source, not the DOM shape.
|
||||
function syncEmptyState(serialized?: string) {
|
||||
if (!rootElement) return;
|
||||
const source = serialized ?? serializeContent(rootElement);
|
||||
rootElement.dataset.empty = source.length === 0 ? 'true' : 'false';
|
||||
}
|
||||
|
||||
function renderTokens(tokens: ContentToken[]) {
|
||||
if (!rootElement) return;
|
||||
|
||||
const caret = rangeToTextOffset(rootElement, safeRange());
|
||||
|
||||
// eslint-disable-next-line svelte/no-dom-manipulating -- the token layer is owned imperatively; Svelte renders only the contenteditable host, never its children
|
||||
rootElement.replaceChildren(buildFragment(tokens));
|
||||
|
||||
syncCodeBlockHatches(rootElement);
|
||||
highlightCodeBlocks(rootElement);
|
||||
|
||||
restoreCaret(caret);
|
||||
resizeHeight();
|
||||
syncEmptyState();
|
||||
}
|
||||
|
||||
// Last highlighted source segment per block element - typing inside
|
||||
// a block re-highlights only when the segment actually changed.
|
||||
const highlightedSegments = new WeakMap<HTMLElement, string>();
|
||||
|
||||
const CODE_BLOCK_OPEN_RE = /^```([^\n`]*)\n/;
|
||||
|
||||
/**
|
||||
* Apply syntax highlighting to a code block element's CONTENT. The
|
||||
* fence lines stay plain text, and the blank padding that
|
||||
* `highlightCode` trims is re-added as plain text, so the element's
|
||||
* textContent stays byte-exact with the source segment. Replaces
|
||||
* the element's children - callers restore the caret afterwards.
|
||||
* Returns false when nothing changed.
|
||||
*/
|
||||
function highlightCodeBlockElement(el: HTMLElement): boolean {
|
||||
const segment = el.textContent ?? '';
|
||||
if (highlightedSegments.get(el) === segment) return false;
|
||||
|
||||
const open = CODE_BLOCK_OPEN_RE.exec(segment);
|
||||
if (!open) return false;
|
||||
|
||||
const prefix = open[0];
|
||||
const language = open[1].trim().split(/\s+/)[0] ?? '';
|
||||
const content = segment.slice(prefix.length, -3);
|
||||
|
||||
const leading = content.match(TRIM_LEADING_PADDING_REGEX)?.[0] ?? '';
|
||||
const trailing = content.match(TRIM_TRAILING_PADDING_REGEX)?.[0] ?? '';
|
||||
const core = content.slice(leading.length, content.length - trailing.length);
|
||||
|
||||
// autoDetect off: re-guessing the language on every keystroke
|
||||
// costs ~38ms a call and flickers while typing
|
||||
const html = core ? highlightCode(core, language || 'text', false) : '';
|
||||
const tpl = document.createElement('template');
|
||||
tpl.innerHTML = html;
|
||||
|
||||
el.replaceChildren(
|
||||
document.createTextNode(prefix + leading),
|
||||
tpl.content.cloneNode(true),
|
||||
document.createTextNode(trailing + '```')
|
||||
);
|
||||
highlightedSegments.set(el, segment);
|
||||
return true;
|
||||
}
|
||||
|
||||
function highlightCodeBlocks(root: HTMLElement) {
|
||||
for (const el of root.querySelectorAll<HTMLElement>('code[data-code-token="block"]')) {
|
||||
highlightCodeBlockElement(el);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-highlight the code block the caret sits in after an edit.
|
||||
* Skipped when the block's segment is unchanged since its last
|
||||
* highlight, so edits outside blocks cost nothing.
|
||||
*/
|
||||
function rehighlightCaretCodeBlock() {
|
||||
if (!rootElement) return;
|
||||
|
||||
const range = safeRange();
|
||||
if (!range) return;
|
||||
|
||||
let node: Node | null = range.startContainer;
|
||||
if (node === rootElement) {
|
||||
node = rootElement.childNodes[range.startOffset - 1] ?? null;
|
||||
}
|
||||
|
||||
while (node && node !== rootElement) {
|
||||
if (node instanceof HTMLElement && node.dataset.codeToken === 'block') {
|
||||
const caret = rangeToTextOffset(rootElement, range);
|
||||
if (highlightCodeBlockElement(node)) {
|
||||
restoreCaret(caret);
|
||||
}
|
||||
return;
|
||||
}
|
||||
node = node.parentNode;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the caret inside a fenced code block region? Source-level
|
||||
* (not DOM-level) so the still-OPEN fence counts too: while the
|
||||
* user is typing a block, no closing ``` exists yet and the
|
||||
* buffer is plain text with no block element to find. Root-level
|
||||
* caret positions right at a closed block's edge (escape
|
||||
* hatches, element boundaries restored by `textOffsetToRange`)
|
||||
* resolve past the closing fence, so they count as OUTSIDE.
|
||||
*/
|
||||
function caretInCodeBlock(): boolean {
|
||||
if (!rootElement) return false;
|
||||
|
||||
return isOffsetInCodeBlock(
|
||||
serializeContent(rootElement),
|
||||
rangeToTextOffset(rootElement, safeRange())
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* hljs theme for the highlighted code blocks. Mirrors
|
||||
* SyntaxHighlightedCode.svelte: one shared style element
|
||||
* (deduped via the data attribute) swapped on mode change.
|
||||
*/
|
||||
function loadHighlightTheme(isDark: boolean) {
|
||||
document.querySelectorAll('style[data-highlight-theme-preview]').forEach((s) => s.remove());
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.setAttribute('data-highlight-theme-preview', 'true');
|
||||
style.textContent = isDark ? githubDarkCss : githubLightCss;
|
||||
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
loadHighlightTheme(mode.current === ColorMode.DARK);
|
||||
});
|
||||
|
||||
function safeRange(): Range | null {
|
||||
if (!rootElement) return null;
|
||||
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) return null;
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
|
||||
if (!rootElement.contains(range.startContainer) || !rootElement.contains(range.endContainer)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return range;
|
||||
}
|
||||
|
||||
function restoreCaret(offset: number, extend = false) {
|
||||
if (!rootElement) return;
|
||||
|
||||
const target = textOffsetToRange(rootElement, offset);
|
||||
const selection = window.getSelection();
|
||||
if (!selection) return;
|
||||
|
||||
if (extend && selection.anchorNode) {
|
||||
selection.setBaseAndExtent(
|
||||
selection.anchorNode,
|
||||
selection.anchorOffset,
|
||||
target.startContainer,
|
||||
target.startOffset
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(target);
|
||||
}
|
||||
|
||||
function resizeHeight() {
|
||||
if (!rootElement) return;
|
||||
rootElement.style.height = 'auto';
|
||||
rootElement.style.height = `${rootElement.scrollHeight}px`;
|
||||
}
|
||||
|
||||
function recordHistory(newGroup: boolean) {
|
||||
if (!rootElement) return;
|
||||
history.push(
|
||||
{ value: lastEmittedValue, caret: rangeToTextOffset(rootElement, safeRange()) },
|
||||
Date.now(),
|
||||
newGroup
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-emit the current markdown source value to the parent, then
|
||||
* reconcile the DOM against the token stream: when a code span
|
||||
* was just completed or broken, the token boundaries no longer
|
||||
* match the element structure and the DOM is rebuilt (caret
|
||||
* preserved through the source-offset mapping).
|
||||
*/
|
||||
function processInput(inputType?: string) {
|
||||
if (isComposing || !rootElement) return;
|
||||
|
||||
syncEmptyState();
|
||||
resizeHeight();
|
||||
|
||||
// Shift+Enter right after a code block leaves an all-newline
|
||||
// text node (the fence's separator line plus Chromium's
|
||||
// artificial end-of-buffer line break). Strip both so the caret
|
||||
// lands on the line directly below the block.
|
||||
if (inputType === 'insertLineBreak' || inputType === 'insertParagraph') {
|
||||
const caret = rangeToTextOffset(rootElement, safeRange());
|
||||
if (stripBlockBoundaryLineBreaks(rootElement)) {
|
||||
restoreCaret(caret);
|
||||
} else {
|
||||
const source = serializeContent(rootElement);
|
||||
let end = caret;
|
||||
|
||||
// the caret must end up after the inserted \n; some browsers
|
||||
// leave it before (stuck at the end of the old line). A
|
||||
// preceding \n means it already sits past the break
|
||||
// (Chromium's artificial trailing newline) - leave it.
|
||||
if (source[end] === '\n' && source[end - 1] !== '\n') {
|
||||
end += 1;
|
||||
restoreCaret(end);
|
||||
}
|
||||
|
||||
// a line break at the buffer end renders only with a second,
|
||||
// artificial trailing \n: a lone trailing \n is collapsed, so
|
||||
// the new line is invisible and the next typed character
|
||||
// consumes it. Append it when missing - unless the trailing
|
||||
// \n doubles as a block's separator line (source ends with
|
||||
// \n\n) or sits inside a block element.
|
||||
let last = rootElement.lastChild;
|
||||
while (last && last.nodeName === 'BR') last = last.previousSibling;
|
||||
if (
|
||||
end === source.length &&
|
||||
source.endsWith('\n') &&
|
||||
source[source.length - 2] !== '\n' &&
|
||||
last?.nodeType === Node.TEXT_NODE
|
||||
) {
|
||||
// eslint-disable-next-line svelte/no-dom-manipulating -- the token layer is owned imperatively; Svelte renders only the contenteditable host, never its children
|
||||
rootElement.appendChild(document.createTextNode('\n'));
|
||||
restoreCaret(source.length);
|
||||
resizeHeight();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
syncCodeBlockHatches(rootElement);
|
||||
|
||||
const serialized = serializeContent(rootElement);
|
||||
syncEmptyState(serialized);
|
||||
if (serialized === lastEmittedValue) return;
|
||||
|
||||
// Plain typing/deletes coalesce per time window; structural edits
|
||||
// (paste, newline, cut, autocorrect) start a new undo group.
|
||||
recordHistory(inputType !== 'insertText' && !inputType?.startsWith('deleteContent'));
|
||||
|
||||
lastEmittedValue = serialized;
|
||||
value = serialized;
|
||||
|
||||
// Rebuild when token boundaries shifted (a code span was just
|
||||
// completed or broken) - the browser-owned text nodes cannot
|
||||
// restyle themselves across element boundaries.
|
||||
const tokens = tokenizeContent(serialized);
|
||||
if (!domMatchesTokens(rootElement, tokens)) {
|
||||
renderTokens(tokens);
|
||||
|
||||
// The rebuild can re-shape the DOM in a way that changes the
|
||||
// serialization (e.g. Chromium merged trailing text into the
|
||||
// block element and the rebuild splits it back out, which
|
||||
// synthesizes the separator newline) - keep value in sync.
|
||||
const reserialized = serializeContent(rootElement);
|
||||
if (reserialized !== serialized) {
|
||||
lastEmittedValue = reserialized;
|
||||
value = reserialized;
|
||||
}
|
||||
} else {
|
||||
rehighlightCaretCodeBlock();
|
||||
}
|
||||
|
||||
onInput?.();
|
||||
}
|
||||
|
||||
function handleInput(event: Event) {
|
||||
processInput((event as InputEvent).inputType);
|
||||
}
|
||||
|
||||
function handleCompositionStart() {
|
||||
isComposing = true;
|
||||
}
|
||||
|
||||
function handleCompositionEnd() {
|
||||
isComposing = false;
|
||||
processInput();
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a line break at the caret MANUALLY. Native Shift+Enter at
|
||||
* the buffer end varies across browsers (a lone trailing \n that the
|
||||
* renderer collapses, or a <br> that the hatch sync strips), which
|
||||
* can leave the caret stuck on the old line; splitting the text node
|
||||
* ourselves keeps the DOM shape - and the caret - deterministic.
|
||||
* `processInput` then appends the artificial trailing \n when the
|
||||
* break lands at the buffer end.
|
||||
*/
|
||||
function insertLineBreak() {
|
||||
if (!rootElement) return;
|
||||
|
||||
const range = safeRange();
|
||||
if (!range) return;
|
||||
|
||||
if (!range.collapsed) {
|
||||
range.deleteContents();
|
||||
}
|
||||
|
||||
const container = range.startContainer;
|
||||
const offset = range.startOffset;
|
||||
const nl = document.createTextNode('\n');
|
||||
|
||||
// a break at the very end of a code block exits the block (the
|
||||
// new line belongs below it, not inside)
|
||||
let exitBlock: HTMLElement | null = null;
|
||||
if (container.nodeType === Node.TEXT_NODE) {
|
||||
let node: Node | null = container.parentNode;
|
||||
while (node && node !== rootElement) {
|
||||
if (node instanceof HTMLElement && node.dataset.codeToken === 'block') {
|
||||
const tail = document.createRange();
|
||||
tail.setStart(container, offset);
|
||||
tail.setEnd(node, node.childNodes.length);
|
||||
if (tail.toString().length === 0) exitBlock = node;
|
||||
break;
|
||||
}
|
||||
node = node.parentNode;
|
||||
}
|
||||
}
|
||||
|
||||
if (exitBlock) {
|
||||
exitBlock.after(nl);
|
||||
} else if (container.nodeType === Node.TEXT_NODE) {
|
||||
const text = container as Text;
|
||||
if (offset === 0) {
|
||||
text.before(nl);
|
||||
} else if (offset === text.length) {
|
||||
text.after(nl);
|
||||
} else {
|
||||
text.splitText(offset).before(nl);
|
||||
}
|
||||
} else {
|
||||
container.insertBefore(nl, container.childNodes[offset] ?? null);
|
||||
}
|
||||
|
||||
const selection = window.getSelection();
|
||||
const after = document.createRange();
|
||||
after.setStartAfter(nl);
|
||||
after.collapse(true);
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(after);
|
||||
|
||||
processInput('insertLineBreak');
|
||||
}
|
||||
|
||||
/**
|
||||
* Arrow escape to the line BEFORE a leading code block. Native
|
||||
* caret movement has no position above a buffer-starting block,
|
||||
* so a transient `<br>` hatch is created on demand: it gives the
|
||||
* caret a visible line, is consumed by the first character typed
|
||||
* on it, and is removed again when the caret leaves (see
|
||||
* handleSelectionChange). Returns true when the caret was moved.
|
||||
*/
|
||||
function moveCaretBeforeLeadingCodeBlock(key: string, extend: boolean): boolean {
|
||||
if (!rootElement) return false;
|
||||
|
||||
// a hatch already exists - native movement handles it
|
||||
if (rootElement.firstChild?.nodeName === 'BR') return false;
|
||||
|
||||
const first = rootElement.firstChild;
|
||||
if (!(first instanceof HTMLElement) || first.dataset.codeToken !== 'block') return false;
|
||||
|
||||
const range = safeRange();
|
||||
if (!range || !range.collapsed) return false;
|
||||
|
||||
// the caret must sit inside the block: on its very first
|
||||
// character for ArrowLeft, anywhere on its first line for
|
||||
// ArrowUp
|
||||
if (!first.contains(range.startContainer)) return false;
|
||||
|
||||
const caret = rangeToTextOffset(rootElement, range);
|
||||
if (key === 'ArrowLeft') {
|
||||
if (caret !== 0) return false;
|
||||
} else {
|
||||
const firstLineEnd = (first.textContent ?? '').indexOf('\n');
|
||||
if (firstLineEnd !== -1 && caret > firstLineEnd) return false;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line svelte/no-dom-manipulating -- the token layer is owned imperatively; Svelte renders only the contenteditable host, never its children
|
||||
rootElement.prepend(document.createElement('br'));
|
||||
restoreCaret(0, extend);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the transient leading hatch once the caret leaves it.
|
||||
* The hatch only exists to give the caret a line above a leading
|
||||
* code block; with the caret anywhere else the empty line would
|
||||
* just be visual noise. Typing on the hatch line consumes it via
|
||||
* the stale-hatch removal in `syncCodeBlockHatches` instead (the
|
||||
* new text node takes its place before the block).
|
||||
*/
|
||||
function handleSelectionChange() {
|
||||
if (!rootElement) return;
|
||||
|
||||
const first = rootElement.firstChild;
|
||||
if (first?.nodeName !== 'BR') return;
|
||||
|
||||
const second = first.nextSibling;
|
||||
if (!(second instanceof HTMLElement) || second.dataset.codeToken !== 'block') return;
|
||||
|
||||
const range = safeRange();
|
||||
const onHatch =
|
||||
range !== null && range.startContainer === rootElement && range.startOffset === 0;
|
||||
if (!onHatch) {
|
||||
first.remove();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo/redo is replayed from source snapshots (the token rebuilds
|
||||
* destroy the native undo stack). Arrow keys around badges are
|
||||
* repaired locally: a badge is a non-editable island, so plain
|
||||
* ArrowLeft after a leading badge has no native previous position
|
||||
* and word jumps overshoot it by a word.
|
||||
*
|
||||
* Plain Enter inside a fenced code block (closed, or still open
|
||||
* while being typed) acts as Shift+Enter and adds a line instead of
|
||||
* submitting. ArrowLeft/ArrowUp at the edge of a leading code block
|
||||
* create the transient before-block hatch.
|
||||
*/
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
const mod = event.ctrlKey || event.metaKey;
|
||||
if (mod && !event.altKey && !isComposing && rootElement) {
|
||||
const key = event.key.toLowerCase();
|
||||
const isUndo = key === 'z' && !event.shiftKey;
|
||||
const isRedo = key === 'y' || (key === 'z' && event.shiftKey);
|
||||
|
||||
if (isUndo || isRedo) {
|
||||
event.preventDefault();
|
||||
const current = {
|
||||
value: lastEmittedValue,
|
||||
caret: rangeToTextOffset(rootElement, safeRange())
|
||||
};
|
||||
const entry = isUndo ? history.undo(current) : history.redo(current);
|
||||
if (entry) applyHistoryEntry(entry);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
event.key === 'Enter' &&
|
||||
event.shiftKey &&
|
||||
!event.ctrlKey &&
|
||||
!event.metaKey &&
|
||||
!event.altKey &&
|
||||
!isIMEComposing(event) &&
|
||||
!disabled &&
|
||||
!caretInCodeBlock() &&
|
||||
safeRange()
|
||||
) {
|
||||
// Own the break outside code blocks: native end-of-buffer
|
||||
// behavior varies across browsers and can leave the caret
|
||||
// stuck on the old line (see insertLineBreak).
|
||||
event.preventDefault();
|
||||
insertLineBreak();
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
event.key === 'Enter' &&
|
||||
!event.shiftKey &&
|
||||
!event.ctrlKey &&
|
||||
!event.metaKey &&
|
||||
!event.altKey &&
|
||||
!isIMEComposing(event) &&
|
||||
caretInCodeBlock()
|
||||
) {
|
||||
// The native plain-Enter path must never run: it splits the
|
||||
// buffer into `<div>` wrappers that `serializeContent` cannot
|
||||
// see. `insertLineBreak` reproduces the Shift+Enter DOM (a `\n`
|
||||
// text node) and fires `input` synchronously, so the usual
|
||||
// re-tokenize/re-highlight follows.
|
||||
event.preventDefault();
|
||||
document.execCommand('insertLineBreak');
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
rootElement &&
|
||||
(event.key === 'ArrowLeft' || event.key === 'ArrowUp') &&
|
||||
!event.altKey &&
|
||||
!event.ctrlKey &&
|
||||
!event.metaKey
|
||||
) {
|
||||
if (moveCaretBeforeLeadingCodeBlock(event.key, event.shiftKey)) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (rootElement && (event.key === 'ArrowLeft' || event.key === 'ArrowRight')) {
|
||||
const isWordJump = (event.altKey || event.ctrlKey) && !event.metaKey;
|
||||
const isPlainLeft =
|
||||
event.key === 'ArrowLeft' && !event.altKey && !event.ctrlKey && !event.metaKey;
|
||||
|
||||
if (isWordJump || isPlainLeft) {
|
||||
const source = serializeContent(rootElement);
|
||||
const caret = rangeToTextOffset(rootElement, safeRange());
|
||||
const target = isWordJump
|
||||
? badgeAwareWordJump(source, caret, event.key === 'ArrowRight' ? 'forward' : 'backward')
|
||||
: leadingBadgeEdgeOffset(source, caret);
|
||||
|
||||
if (target !== null) {
|
||||
event.preventDefault();
|
||||
restoreCaret(target, event.shiftKey);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onKeydown?.(event);
|
||||
}
|
||||
|
||||
// lastEmittedValue is set before `value` so the sync effect treats the
|
||||
// change as our own and does not re-render.
|
||||
function applyHistoryEntry(entry: SourceHistoryEntry) {
|
||||
if (!rootElement) return;
|
||||
renderTokens(tokenizeContent(entry.value));
|
||||
lastEmittedValue = entry.value;
|
||||
value = entry.value;
|
||||
onInput?.();
|
||||
restoreCaret(entry.caret);
|
||||
}
|
||||
|
||||
/**
|
||||
* Plain-text paste. preventDefault + manual insertText keeps the
|
||||
* browser from producing stray `<div>` wrappers mid-paste; insertText
|
||||
* fires `input` synchronously, so `processInput` re-tokenizes the
|
||||
* buffer and rebuilds when the pasted text carries badge or code
|
||||
* tokens.
|
||||
*/
|
||||
function handlePasteEvent(event: ClipboardEvent) {
|
||||
const pasted = event.clipboardData?.getData('text/plain');
|
||||
if (pasted && pasted.length > 0) {
|
||||
event.preventDefault();
|
||||
|
||||
// Snap a collapsed caret through the offset mapping first: at
|
||||
// element-boundary carets (e.g. right before a badge) Chromium's
|
||||
// insertText can drop the preceding text node's trailing whitespace.
|
||||
const range = safeRange();
|
||||
if (rootElement && range && range.collapsed) {
|
||||
restoreCaret(rangeToTextOffset(rootElement, range));
|
||||
}
|
||||
|
||||
document.execCommand('insertText', false, pasted);
|
||||
}
|
||||
}
|
||||
|
||||
// The parent's paste handler runs first and preventDefaults when it
|
||||
// consumes the event (files, quoted prompts, long text).
|
||||
function handlePaste(event: ClipboardEvent) {
|
||||
onPaste?.(event);
|
||||
if (!event.defaultPrevented) {
|
||||
handlePasteEvent(event);
|
||||
}
|
||||
}
|
||||
|
||||
// The selection as markdown SOURCE (each badge contributes its full
|
||||
// `[name](file://...)` link), so copy/cut carry raw markdown and
|
||||
// pasting back re-renders the badges. Null for collapsed/outside
|
||||
// selections - native clipboard behavior is fine there.
|
||||
function selectionSourceSlice(): { text: string; range: Range } | null {
|
||||
if (!rootElement) return null;
|
||||
|
||||
const range = safeRange();
|
||||
if (!range || range.collapsed) return null;
|
||||
|
||||
const startRange = range.cloneRange();
|
||||
startRange.collapse(true);
|
||||
|
||||
const source = serializeContent(rootElement);
|
||||
const start = rangeToTextOffset(rootElement, startRange);
|
||||
const end = rangeToTextOffset(rootElement, range);
|
||||
|
||||
return { text: source.slice(start, end), range };
|
||||
}
|
||||
|
||||
function handleCopy(event: ClipboardEvent) {
|
||||
const slice = selectionSourceSlice();
|
||||
if (!slice) return;
|
||||
|
||||
event.clipboardData?.setData('text/plain', slice.text);
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
function handleCut(event: ClipboardEvent) {
|
||||
const slice = selectionSourceSlice();
|
||||
if (!slice) return;
|
||||
|
||||
event.clipboardData?.setData('text/plain', slice.text);
|
||||
event.preventDefault();
|
||||
|
||||
// preventDefault suppresses the native deletion, so remove the
|
||||
// selection manually and re-emit.
|
||||
slice.range.deleteContents();
|
||||
processInput('deleteByCut');
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
// untrack: the DOM is managed manually from input events, so the
|
||||
// initial render must not subscribe to the value.
|
||||
renderTokens(tokenizeContent(untrack(() => value)));
|
||||
lastEmittedValue = untrack(() => value ?? '');
|
||||
resizeHeight();
|
||||
syncEmptyState();
|
||||
document.addEventListener('selectionchange', handleSelectionChange);
|
||||
if (!isMobile.current) {
|
||||
rootElement?.focus({ preventScroll: true });
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
document.removeEventListener('selectionchange', handleSelectionChange);
|
||||
});
|
||||
|
||||
// External `value` updates. When incoming === lastEmittedValue the
|
||||
// change came from our own input, so leave the DOM alone - the
|
||||
// browser already owns the right shape.
|
||||
$effect(() => {
|
||||
const incoming = value ?? '';
|
||||
if (incoming === lastEmittedValue) return;
|
||||
|
||||
recordHistory(true); // external edit (mention insert, clear, ...): own undo step
|
||||
renderTokens(tokenizeContent(incoming));
|
||||
lastEmittedValue = incoming;
|
||||
});
|
||||
|
||||
export function getElement() {
|
||||
return rootElement;
|
||||
}
|
||||
|
||||
export function getCaretOffset(): number {
|
||||
if (!rootElement) return 0;
|
||||
return rangeToTextOffset(rootElement, safeRange());
|
||||
}
|
||||
|
||||
// Focus first: `selection.addRange` requires it on some browsers.
|
||||
export function setCaretOffset(offset: number) {
|
||||
if (rootElement && rootElement !== document.activeElement) {
|
||||
rootElement.focus({ preventScroll: true });
|
||||
}
|
||||
restoreCaret(offset);
|
||||
}
|
||||
|
||||
export function focus() {
|
||||
if (isMobile.current) return;
|
||||
rootElement?.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
export function resetHeight() {
|
||||
if (rootElement) {
|
||||
rootElement.style.height = '';
|
||||
resizeHeight();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex-1 {className}">
|
||||
<div
|
||||
bind:this={rootElement}
|
||||
contenteditable={!disabled}
|
||||
role="textbox"
|
||||
aria-multiline="true"
|
||||
aria-disabled={disabled}
|
||||
aria-placeholder={placeholder}
|
||||
data-placeholder={placeholder}
|
||||
tabindex={disabled ? -1 : 0}
|
||||
class={[
|
||||
'chat-form-contenteditable text-md min-h-12 w-full whitespace-pre-wrap wrap-break-word border-0 bg-transparent p-0 leading-6 outline-none focus-visible:ring-0 focus-visible:ring-offset-0',
|
||||
disabled && 'cursor-not-allowed'
|
||||
]}
|
||||
style="max-height: var(--max-message-height);"
|
||||
oncompositionstart={handleCompositionStart}
|
||||
oncompositionend={handleCompositionEnd}
|
||||
oninput={handleInput}
|
||||
onkeydown={handleKeydown}
|
||||
onpaste={handlePaste}
|
||||
oncopy={handleCopy}
|
||||
oncut={handleCut}
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* pre-wrap is load-bearing: without it Chromium collapses \n in
|
||||
text nodes and converts them to spaces while typing */
|
||||
.chat-form-contenteditable {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.chat-form-contenteditable:global([data-empty='true'])::before {
|
||||
content: attr(data-placeholder);
|
||||
color: var(--muted-foreground);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Inline code - mirrors markdown-content.css */
|
||||
.chat-form-contenteditable :global(code[data-code-token='inline']) {
|
||||
background: var(--muted);
|
||||
color: var(--muted-foreground);
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Fenced code block - mirrors .code-block-wrapper in markdown-content.css */
|
||||
.chat-form-contenteditable :global(code[data-code-token='block']) {
|
||||
display: block;
|
||||
margin: 0.25rem 0;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid color-mix(in oklch, var(--border) 30%, transparent);
|
||||
border-radius: 0.75rem;
|
||||
background: var(--code-background);
|
||||
color: var(--code-foreground);
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
</style>
|
||||
+12
-1
@@ -2,7 +2,7 @@
|
||||
import { File, Folder } from '@lucide/svelte';
|
||||
import { abbreviateHome, runGlobSearchWithChildren, type GlobEntryResult } from '$lib/utils';
|
||||
import { toolsStore } from '$lib/stores/tools.svelte';
|
||||
import { BuiltInTool, FileMentionEntryType, GlobSearchType } from '$lib/enums';
|
||||
import { BuiltInTool, FileMentionEntryType, GlobSearchType, KeyboardKey } from '$lib/enums';
|
||||
import { isMobile } from '$lib/stores/viewport.svelte';
|
||||
import { config } from '$lib/stores/settings.svelte';
|
||||
import * as Popover from '$lib/components/ui/popover';
|
||||
@@ -162,6 +162,17 @@
|
||||
}
|
||||
|
||||
export function handleKeydown(event: KeyboardEvent): boolean {
|
||||
// Always consume Enter while the picker is open - even with no
|
||||
// result yet (skeletons) or no matches - so the chat form's
|
||||
// Enter-to-submit never fires mid-search.
|
||||
if (isOpen && event.key === KeyboardKey.ENTER) {
|
||||
event.preventDefault();
|
||||
if (nav.hoveredIndex >= 0 && displayedItems[nav.hoveredIndex]) {
|
||||
handleSelect(displayedItems[nav.hoveredIndex]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return nav.handleKeydown(event);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -48,7 +48,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Plain-text caret offsets for the picker/paste/mention-splice flows.
|
||||
// Plain-text caret offsets, shared with the contenteditable variant so
|
||||
// the picker/paste flows can address either renderer through one handle.
|
||||
export function getCaretOffset(): number {
|
||||
if (!textareaElement) return 0;
|
||||
return textareaElement.selectionStart ?? textareaElement.value.length;
|
||||
|
||||
@@ -120,7 +120,8 @@ export { default as ChatAttachmentsPreviewCurrentItem } from './ChatAttachments/
|
||||
* Used by ChatScreenForm and ChatMessageEditForm for both new conversations and message editing.
|
||||
*
|
||||
* **Architecture:**
|
||||
* - Composes ChatFormTextarea, ChatFormActions, and ChatFormPickerMcpPrompts
|
||||
* - Composes ChatFormTextarea (or ChatFormContenteditable for messages with
|
||||
* file mention links), ChatFormActions, and ChatFormPickerMcpPrompts
|
||||
* - Manages file upload state via `uploadedFiles` bindable prop
|
||||
* - Integrates with ModelsSelectorDropdown for model selection in router mode
|
||||
* - Communicates with parent via callbacks (onSubmit, onFilesAdd, onStop, etc.)
|
||||
@@ -266,9 +267,16 @@ export { default as ChatFormFileInputInvisible } from './ChatForm/ChatFormFileIn
|
||||
export { default as ChatFormMcpResourcesList } from './ChatForm/ChatFormMcpResourcesList.svelte';
|
||||
|
||||
/**
|
||||
* Auto-resizing textarea with IME composition support. Mention links stay
|
||||
* plain markdown text in the input; the chip rendering happens in the
|
||||
* message view via the rehype file-badge plugin.
|
||||
* Auto-resizing contenteditable input that renders `[name](file://...)`
|
||||
* mention links as inline chips while keeping the value as the markdown
|
||||
* source string. ChatForm swaps it in once a mention link lands in the
|
||||
* buffer. Shares the focus()/resetHeight()/caret handle with the textarea.
|
||||
*/
|
||||
export { default as ChatFormContenteditable } from './ChatForm/ChatFormContenteditable.svelte';
|
||||
|
||||
/**
|
||||
* Plain auto-resizing textarea with IME composition support. Default input
|
||||
* renderer inside ChatForm until a file mention lands.
|
||||
*/
|
||||
export { default as ChatFormTextarea } from './ChatForm/ChatFormTextarea.svelte';
|
||||
|
||||
|
||||
@@ -34,7 +34,8 @@
|
||||
preprocessLaTeX,
|
||||
getImageErrorFallbackHtml,
|
||||
copyCodeToClipboard,
|
||||
copyToClipboard
|
||||
copyToClipboard,
|
||||
splitGluedClosingCodeFences
|
||||
} from '$lib/utils';
|
||||
import {
|
||||
IMAGE_NOT_ERROR_BOUND_SELECTOR,
|
||||
@@ -342,7 +343,11 @@
|
||||
* Incomplete code blocks are rendered using SyntaxHighlightedCode to maintain interactivity.
|
||||
* @param markdown - The raw markdown string to process
|
||||
*/
|
||||
async function processMarkdown(markdown: string) {
|
||||
async function processMarkdown(rawMarkdown: string) {
|
||||
// Text glued to a closing code fence is not a fence to the parser -
|
||||
// the block would swallow it. Split it onto its own line first.
|
||||
const markdown = splitGluedClosingCodeFences(rawMarkdown);
|
||||
|
||||
// Early exit if content unchanged (can happen with rapid coalescing)
|
||||
if (markdown === previousContent) {
|
||||
return;
|
||||
|
||||
@@ -243,7 +243,6 @@ div.markdown-user-content :global(.table-wrapper) {
|
||||
/* Code blocks */
|
||||
|
||||
.markdown-content :global(.code-block-wrapper) {
|
||||
margin: 1.5rem 0;
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
border: 1px solid color-mix(in oklch, var(--border) 30%, transparent);
|
||||
@@ -253,6 +252,14 @@ div.markdown-user-content :global(.table-wrapper) {
|
||||
max-height: var(--max-message-height);
|
||||
}
|
||||
|
||||
.markdown-content .markdown-block:not(:first-child) :global(.code-block-wrapper) {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.markdown-content .markdown-block:not(:last-child) :global(.code-block-wrapper) {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.markdown-content:global(.dark) :global(.code-block-wrapper) {
|
||||
border-color: color-mix(in oklch, var(--border) 20%, transparent);
|
||||
}
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Rehype plugin that rewrites `file://` markdown anchors into the inline
|
||||
* @-mention chip, reusing the visual contract from
|
||||
* `$lib/constants/mention-badge`.
|
||||
* mention chip, sharing the class string with the contenteditable
|
||||
* tokenizer via `$lib/constants/mention-badge`.
|
||||
*
|
||||
* The chip is presentational: `file://` navigation is blocked from
|
||||
* http(s) pages, so the anchor becomes a plain `<span>` (no link role,
|
||||
|
||||
Reference in New Issue
Block a user