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 {
|
import {
|
||||||
ChatAttachmentsList,
|
ChatAttachmentsList,
|
||||||
ChatFormActions,
|
ChatFormActions,
|
||||||
|
ChatFormContenteditable,
|
||||||
ChatFormFileInputInvisible,
|
ChatFormFileInputInvisible,
|
||||||
ChatFormMcpResourcesList,
|
ChatFormMcpResourcesList,
|
||||||
ChatFormPickers,
|
ChatFormPickers,
|
||||||
@@ -46,10 +47,12 @@
|
|||||||
} from '$lib/types';
|
} from '$lib/types';
|
||||||
import {
|
import {
|
||||||
buildMentionInsertion,
|
buildMentionInsertion,
|
||||||
|
containsCodeSpan,
|
||||||
|
containsFileMentionLink,
|
||||||
findCommandToken,
|
findCommandToken,
|
||||||
findMentionToken,
|
findMentionToken,
|
||||||
isIMEComposing,
|
isIMEComposing,
|
||||||
mentionLinkEndingAt,
|
isOffsetInCodeBlock,
|
||||||
parseClipboardContent,
|
parseClipboardContent,
|
||||||
uuid
|
uuid
|
||||||
} from '$lib/utils';
|
} from '$lib/utils';
|
||||||
@@ -109,12 +112,28 @@
|
|||||||
onValueChange
|
onValueChange
|
||||||
}: Props = $props();
|
}: 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 audioRecorder: AudioRecorder | undefined;
|
||||||
let chatFormActionsRef: ChatFormActions | undefined = $state(undefined);
|
let chatFormActionsRef: ChatFormActions | undefined = $state(undefined);
|
||||||
let fileInputRef: ChatFormFileInputInvisible | undefined = $state(undefined);
|
let fileInputRef: ChatFormFileInputInvisible | undefined = $state(undefined);
|
||||||
let pickersRef: { handleKeydown: (event: KeyboardEvent) => boolean } | undefined =
|
let pickersRef: { handleKeydown: (event: KeyboardEvent) => boolean } | undefined =
|
||||||
$state(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
|
// Audio Recording State
|
||||||
let isRecording = $state(false);
|
let isRecording = $state(false);
|
||||||
@@ -201,6 +220,34 @@
|
|||||||
);
|
);
|
||||||
let canSubmit = $derived(value.trim().length > 0 || hasAttachments);
|
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(() => {
|
onMount(() => {
|
||||||
recordingSupported = isAudioRecordingSupported();
|
recordingSupported = isAudioRecordingSupported();
|
||||||
audioRecorder = new AudioRecorder();
|
audioRecorder = new AudioRecorder();
|
||||||
@@ -252,25 +299,19 @@
|
|||||||
return;
|
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)) {
|
if (event.key === KeyboardKey.ENTER && !event.shiftKey && !isIMEComposing(event)) {
|
||||||
const isModifier = event.ctrlKey || event.metaKey;
|
const isModifier = event.ctrlKey || event.metaKey;
|
||||||
const sendOnEnter = currentConfig.sendOnEnter !== false;
|
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) {
|
if (sendOnEnter || isModifier) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
@@ -444,14 +485,20 @@
|
|||||||
const built = buildMentionInsertion(entry, value, token);
|
const built = buildMentionInsertion(entry, value, token);
|
||||||
if (!built) return;
|
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;
|
value = built.newValue;
|
||||||
onValueChange?.(built.newValue);
|
onValueChange?.(built.newValue);
|
||||||
|
|
||||||
// bind:value applies on the next microtask; restore the caret after.
|
// Already in contenteditable mode: no renderer flip, so the swap
|
||||||
queueMicrotask(() => {
|
// effect's caret restore never runs.
|
||||||
inputRef?.focus();
|
if (useContenteditable) {
|
||||||
inputRef?.setCaretOffset(built.caretOffset);
|
queueCaretRestore();
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleMicClick() {
|
async function handleMicClick() {
|
||||||
@@ -541,19 +588,35 @@
|
|||||||
<div
|
<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!"
|
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
|
{#if useContenteditable}
|
||||||
class="px-5 py-1.5 md:pt-0"
|
<ChatFormContenteditable
|
||||||
bind:this={inputRef}
|
class="px-5 py-1.5 md:pt-0 mb-0.5"
|
||||||
bind:value
|
bind:this={inputRef}
|
||||||
onKeydown={handleKeydown}
|
bind:value
|
||||||
onInput={() => {
|
onKeydown={handleKeydown}
|
||||||
pickers.handleInput();
|
onInput={() => {
|
||||||
onValueChange?.(value);
|
pickers.handleInput();
|
||||||
}}
|
onValueChange?.(value);
|
||||||
onPaste={handlePaste}
|
}}
|
||||||
{disabled}
|
onPaste={handlePaste}
|
||||||
{placeholder}
|
{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()}
|
{#if mcpHasResourceAttachments()}
|
||||||
<ChatFormMcpResourcesList
|
<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 { File, Folder } from '@lucide/svelte';
|
||||||
import { abbreviateHome, runGlobSearchWithChildren, type GlobEntryResult } from '$lib/utils';
|
import { abbreviateHome, runGlobSearchWithChildren, type GlobEntryResult } from '$lib/utils';
|
||||||
import { toolsStore } from '$lib/stores/tools.svelte';
|
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 { isMobile } from '$lib/stores/viewport.svelte';
|
||||||
import { config } from '$lib/stores/settings.svelte';
|
import { config } from '$lib/stores/settings.svelte';
|
||||||
import * as Popover from '$lib/components/ui/popover';
|
import * as Popover from '$lib/components/ui/popover';
|
||||||
@@ -162,6 +162,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function handleKeydown(event: KeyboardEvent): boolean {
|
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);
|
return nav.handleKeydown(event);
|
||||||
}
|
}
|
||||||
</script>
|
</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 {
|
export function getCaretOffset(): number {
|
||||||
if (!textareaElement) return 0;
|
if (!textareaElement) return 0;
|
||||||
return textareaElement.selectionStart ?? textareaElement.value.length;
|
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.
|
* Used by ChatScreenForm and ChatMessageEditForm for both new conversations and message editing.
|
||||||
*
|
*
|
||||||
* **Architecture:**
|
* **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
|
* - Manages file upload state via `uploadedFiles` bindable prop
|
||||||
* - Integrates with ModelsSelectorDropdown for model selection in router mode
|
* - Integrates with ModelsSelectorDropdown for model selection in router mode
|
||||||
* - Communicates with parent via callbacks (onSubmit, onFilesAdd, onStop, etc.)
|
* - 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';
|
export { default as ChatFormMcpResourcesList } from './ChatForm/ChatFormMcpResourcesList.svelte';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Auto-resizing textarea with IME composition support. Mention links stay
|
* Auto-resizing contenteditable input that renders `[name](file://...)`
|
||||||
* plain markdown text in the input; the chip rendering happens in the
|
* mention links as inline chips while keeping the value as the markdown
|
||||||
* message view via the rehype file-badge plugin.
|
* 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';
|
export { default as ChatFormTextarea } from './ChatForm/ChatFormTextarea.svelte';
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,8 @@
|
|||||||
preprocessLaTeX,
|
preprocessLaTeX,
|
||||||
getImageErrorFallbackHtml,
|
getImageErrorFallbackHtml,
|
||||||
copyCodeToClipboard,
|
copyCodeToClipboard,
|
||||||
copyToClipboard
|
copyToClipboard,
|
||||||
|
splitGluedClosingCodeFences
|
||||||
} from '$lib/utils';
|
} from '$lib/utils';
|
||||||
import {
|
import {
|
||||||
IMAGE_NOT_ERROR_BOUND_SELECTOR,
|
IMAGE_NOT_ERROR_BOUND_SELECTOR,
|
||||||
@@ -342,7 +343,11 @@
|
|||||||
* Incomplete code blocks are rendered using SyntaxHighlightedCode to maintain interactivity.
|
* Incomplete code blocks are rendered using SyntaxHighlightedCode to maintain interactivity.
|
||||||
* @param markdown - The raw markdown string to process
|
* @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)
|
// Early exit if content unchanged (can happen with rapid coalescing)
|
||||||
if (markdown === previousContent) {
|
if (markdown === previousContent) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -243,7 +243,6 @@ div.markdown-user-content :global(.table-wrapper) {
|
|||||||
/* Code blocks */
|
/* Code blocks */
|
||||||
|
|
||||||
.markdown-content :global(.code-block-wrapper) {
|
.markdown-content :global(.code-block-wrapper) {
|
||||||
margin: 1.5rem 0;
|
|
||||||
border-radius: 0.75rem;
|
border-radius: 0.75rem;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid color-mix(in oklch, var(--border) 30%, transparent);
|
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);
|
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) {
|
.markdown-content:global(.dark) :global(.code-block-wrapper) {
|
||||||
border-color: color-mix(in oklch, var(--border) 20%, transparent);
|
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
|
* Rehype plugin that rewrites `file://` markdown anchors into the inline
|
||||||
* @-mention chip, reusing the visual contract from
|
* mention chip, sharing the class string with the contenteditable
|
||||||
* `$lib/constants/mention-badge`.
|
* tokenizer via `$lib/constants/mention-badge`.
|
||||||
*
|
*
|
||||||
* The chip is presentational: `file://` navigation is blocked from
|
* The chip is presentational: `file://` navigation is blocked from
|
||||||
* http(s) pages, so the anchor becomes a plain `<span>` (no link role,
|
* http(s) pages, so the anchor becomes a plain `<span>` (no link role,
|
||||||
|
|||||||
@@ -19,8 +19,9 @@ export const PANEL_CLASSES = `
|
|||||||
export const CHAT_FORM_POPOVER_MAX_HEIGHT = 'max-h-80';
|
export const CHAT_FORM_POPOVER_MAX_HEIGHT = 'max-h-80';
|
||||||
export const DIALOG_SUBMENU_CONTENT = 'w-60';
|
export const DIALOG_SUBMENU_CONTENT = 'w-60';
|
||||||
|
|
||||||
/** Selects the chat-form input to restore focus after model actions. */
|
/** Selects the focused chat-form input (either renderer) to restore focus after model actions. */
|
||||||
export const CHAT_INPUT_FOCUS_SELECTOR = '[data-slot="input-area"] textarea';
|
export const CHAT_INPUT_FOCUS_SELECTOR =
|
||||||
|
'[data-slot="input-area"] textarea, [data-slot="input-area"] [contenteditable="true"]';
|
||||||
|
|
||||||
/** Default Tailwind size class for inline icon components (lucide, etc.). */
|
/** Default Tailwind size class for inline icon components (lucide, etc.). */
|
||||||
export const ICON_CLASS_DEFAULT = 'h-4 w-4';
|
export const ICON_CLASS_DEFAULT = 'h-4 w-4';
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* Visual contract for message @-mention badges. Svelte cannot be mounted
|
* Shared visual contract between the two DOM-only badge paths (the
|
||||||
* from a hast tree, so the rehype file-badge plugin emits the shared class
|
* contenteditable tokenizer + the rehype plugin). Svelte cannot be
|
||||||
* string below; keeping it here as a literal lets Tailwind's source
|
* mounted at the per-keystroke tokenizer hot path nor from a hast tree,
|
||||||
* scanner generate the utility classes.
|
* so both emit the badge with the same class string literal; Tailwind's
|
||||||
|
* scanner picks it up in both sources.
|
||||||
*/
|
*/
|
||||||
export const MENTION_BADGE_CLASSNAME =
|
export const MENTION_BADGE_CLASSNAME =
|
||||||
'inline-flex w-fit shrink-0 items-center gap-1 whitespace-nowrap rounded-md border border-border/50 bg-foreground/5 px-1.5 py-0.5 text-xs font-mono text-foreground hover:bg-foreground/10 dark:bg-foreground/10 dark:text-secondary-foreground';
|
'inline-flex w-fit shrink-0 items-center gap-1 whitespace-nowrap rounded-md border border-border/50 bg-foreground/5 px-1.5 py-0.5 text-xs font-mono text-foreground hover:bg-foreground/10 dark:bg-foreground/10 dark:text-secondary-foreground';
|
||||||
@@ -10,8 +11,10 @@ export const MENTION_BADGE_CLASSNAME =
|
|||||||
export const MENTION_BADGE_ICON_CLASSNAME = 'h-3 w-3 shrink-0';
|
export const MENTION_BADGE_ICON_CLASSNAME = 'h-3 w-3 shrink-0';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SVG attributes shared by the hast-built badge icons; the rehype plugin
|
* SVG attributes shared by the DOM-built and hast-built badge icons.
|
||||||
* spreads them onto the `<svg>` `properties`.
|
* The tokenizer applies them via `setAttribute`, the rehype plugin
|
||||||
|
* spreads them onto the hast `<svg>` `properties`; string values are
|
||||||
|
* valid for both.
|
||||||
*/
|
*/
|
||||||
export const MENTION_BADGE_SVG_ATTRIBUTES: Readonly<Record<string, string>> = {
|
export const MENTION_BADGE_SVG_ATTRIBUTES: Readonly<Record<string, string>> = {
|
||||||
xmlns: 'http://www.w3.org/2000/svg',
|
xmlns: 'http://www.w3.org/2000/svg',
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ export enum KeyboardKey {
|
|||||||
ARROW_DOWN = 'ArrowDown',
|
ARROW_DOWN = 'ArrowDown',
|
||||||
ARROW_LEFT = 'ArrowLeft',
|
ARROW_LEFT = 'ArrowLeft',
|
||||||
ARROW_RIGHT = 'ArrowRight',
|
ARROW_RIGHT = 'ArrowRight',
|
||||||
BACKSPACE = 'Backspace',
|
|
||||||
TAB = 'Tab',
|
TAB = 'Tab',
|
||||||
B_LOWER = 'b',
|
B_LOWER = 'b',
|
||||||
D_LOWER = 'd',
|
D_LOWER = 'd',
|
||||||
|
|||||||
@@ -17,6 +17,56 @@ export interface IncompleteCodeBlock {
|
|||||||
openingIndex: number;
|
openingIndex: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A fence line: up to 3 leading spaces (CommonMark), 3+ backticks, then
|
||||||
|
// whatever trails on the same line.
|
||||||
|
const FENCE_LINE_REGEX = /^ {0,3}(`{3,})(.*)$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Splits text glued to a closing code fence onto its own line:
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* let foo = 'bar';
|
||||||
|
* ```create this file on ...
|
||||||
|
*
|
||||||
|
* A closing fence with trailing text is not a fence to the markdown
|
||||||
|
* parser, so the block would swallow the text as code. The chat form
|
||||||
|
* normally keeps the fence on its own line, but older messages and
|
||||||
|
* hand-pasted content can carry the glued form.
|
||||||
|
*
|
||||||
|
* Only trailing text containing whitespace is split: a single word
|
||||||
|
* after the backticks inside a fenced block is more likely nested
|
||||||
|
* markdown (a ```python example inside a ```md block) than glued prose.
|
||||||
|
*/
|
||||||
|
export function splitGluedClosingCodeFences(markdown: string): string {
|
||||||
|
if (!markdown.includes('```')) return markdown;
|
||||||
|
|
||||||
|
const lines = markdown.split(NEWLINE);
|
||||||
|
let inside = false;
|
||||||
|
let changed = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const match = FENCE_LINE_REGEX.exec(lines[i]);
|
||||||
|
if (!match) continue;
|
||||||
|
|
||||||
|
if (!inside) {
|
||||||
|
inside = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
inside = false;
|
||||||
|
|
||||||
|
const trailing = match[2];
|
||||||
|
if (trailing.includes('`') || !/\s/.test(trailing)) continue;
|
||||||
|
|
||||||
|
lines[i] = lines[i].slice(0, lines[i].length - trailing.length);
|
||||||
|
lines.splice(i + 1, 0, trailing.trim());
|
||||||
|
i++;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return changed ? lines.join(NEWLINE) : markdown;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Strips empty lines (whitespace-only) from the start and end of code.
|
* Strips empty lines (whitespace-only) from the start and end of code.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -0,0 +1,890 @@
|
|||||||
|
/**
|
||||||
|
* Maps between the chat-form contenteditable's markdown source and the
|
||||||
|
* badge/code/text token stream the DOM is built from. A badge is one
|
||||||
|
* opaque source contribution (`[name](file://path)`); its own subtree
|
||||||
|
* is never walked, and the caret cannot land inside it, so offsets
|
||||||
|
* resolve to the nearest badge edge. Code spans (`<code data-code-token>`)
|
||||||
|
* are EDITABLE, unlike badges: they carry the full source segment
|
||||||
|
* (backtick fences included) as their text, so their textContent
|
||||||
|
* serializes verbatim and source offsets map 1:1 to text offsets.
|
||||||
|
*
|
||||||
|
* The tokenizer emits a flat DOM (text nodes + badges + code spans),
|
||||||
|
* but browsers restructure it on Enter (`<div>` line wrappers, `<br>`
|
||||||
|
* shapes). Serialization folds those back into `\n` so the source
|
||||||
|
* never diverges from what is on screen; both offset mappers
|
||||||
|
* understand the same shapes.
|
||||||
|
*
|
||||||
|
* The newline separating a fenced block from adjacent content is a
|
||||||
|
* SOURCE-level concept, never stored in the DOM: the block is
|
||||||
|
* display:block, so a leading `\n` in the following text node would
|
||||||
|
* render as a phantom empty line. Serialization synthesizes exactly
|
||||||
|
* one `\n` at every block boundary and `buildFragment` strips it from
|
||||||
|
* text tokens. A text node's own leading/trailing `\n` next to a
|
||||||
|
* block is an ADDITIONAL blank line.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
decodeFileLinkPath,
|
||||||
|
fileMentionLinkRe,
|
||||||
|
getMentionBadgeIconPaths,
|
||||||
|
getMentionBadgeLabel
|
||||||
|
} from './mention-badge';
|
||||||
|
import {
|
||||||
|
MENTION_BADGE_CLASSNAME,
|
||||||
|
MENTION_BADGE_ICON_CLASSNAME,
|
||||||
|
MENTION_BADGE_SVG_ATTRIBUTES,
|
||||||
|
SETTINGS_KEYS
|
||||||
|
} from '$lib/constants';
|
||||||
|
import { settingsStore } from '$lib/stores/settings.svelte';
|
||||||
|
import { toolsStore } from '$lib/stores/tools.svelte';
|
||||||
|
|
||||||
|
export type ContentToken =
|
||||||
|
| { kind: 'text'; text: string }
|
||||||
|
| { kind: 'badge'; name: string; path: string }
|
||||||
|
| { kind: 'inlineCode'; text: string }
|
||||||
|
| { kind: 'codeBlock'; text: string };
|
||||||
|
|
||||||
|
// Block wrappers browsers insert for newlines; each folds back into a
|
||||||
|
// single `\n` during serialization.
|
||||||
|
const BLOCK_TAG_NAMES = new Set(['DIV', 'P']);
|
||||||
|
|
||||||
|
// `file://` is required so plain URLs stay as text; `)` terminates only
|
||||||
|
// when not followed by whitespace or `[` (adjacent badges keep working).
|
||||||
|
const MENTION_BADGE_RE = fileMentionLinkRe('g');
|
||||||
|
|
||||||
|
function badgeSourceLength(name: string, path: string): number {
|
||||||
|
if (!name || !path) return 0;
|
||||||
|
return `[${name}](file://${path})`.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recognize complete code spans. Fenced blocks (triple backticks,
|
||||||
|
* optional language, possibly multiline) take priority over inline
|
||||||
|
* spans (single backticks, single line, non-empty). Only CLOSED
|
||||||
|
* spans match: an unclosed fence stays plain text until the closing
|
||||||
|
* backticks land. The match includes the fences so the token's
|
||||||
|
* source length equals its rendered text length.
|
||||||
|
*/
|
||||||
|
const CODE_SPAN_RE = /(```[\s\S]*?```)|(`[^`\n]+`)/g;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cheap gate check for `ChatForm`: does the buffer contain a
|
||||||
|
* complete code span (inline or fenced)? Used to promote the plain
|
||||||
|
* textarea to the contenteditable renderer.
|
||||||
|
*/
|
||||||
|
export function containsCodeSpan(value: string): boolean {
|
||||||
|
CODE_SPAN_RE.lastIndex = 0;
|
||||||
|
return CODE_SPAN_RE.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const CODE_FENCE_RE = /```/g;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is `offset` inside a fenced code block region? Toggle-based: an
|
||||||
|
* odd number of ``` fences before the offset means the position
|
||||||
|
* sits in block content. Unlike `containsCodeSpan` this also
|
||||||
|
* counts the still-OPEN fence while the user is typing a block
|
||||||
|
* (no closing ``` yet), so Enter can add a line instead of
|
||||||
|
* submitting the message.
|
||||||
|
*/
|
||||||
|
export function isOffsetInCodeBlock(source: string, offset: number): boolean {
|
||||||
|
let inside = false;
|
||||||
|
CODE_FENCE_RE.lastIndex = 0;
|
||||||
|
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
while ((match = CODE_FENCE_RE.exec(source)) !== null) {
|
||||||
|
if (match.index + match[0].length > offset) break;
|
||||||
|
inside = !inside;
|
||||||
|
}
|
||||||
|
|
||||||
|
return inside;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tokenize a markdown source value into the segments the
|
||||||
|
* contenteditable will render. Code spans are carved out first
|
||||||
|
* (their content is literal - a `file://` link inside backticks
|
||||||
|
* must NOT render as a badge), then plain text and badges
|
||||||
|
* interleave in the remaining gaps. Any whitespace after a badge
|
||||||
|
* stays in a plain text token so the round trip is byte-exact.
|
||||||
|
*/
|
||||||
|
export function tokenizeContent(input: string): ContentToken[] {
|
||||||
|
const tokens: ContentToken[] = [];
|
||||||
|
let cursor = 0;
|
||||||
|
CODE_SPAN_RE.lastIndex = 0;
|
||||||
|
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
while ((match = CODE_SPAN_RE.exec(input)) !== null) {
|
||||||
|
const start = match.index;
|
||||||
|
|
||||||
|
if (start > cursor) {
|
||||||
|
pushTextAndBadgeTokens(input.slice(cursor, start), tokens);
|
||||||
|
}
|
||||||
|
|
||||||
|
tokens.push(
|
||||||
|
match[1] !== undefined
|
||||||
|
? { kind: 'codeBlock', text: match[1] }
|
||||||
|
: { kind: 'inlineCode', text: match[2] }
|
||||||
|
);
|
||||||
|
cursor = start + match[0].length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cursor < input.length) {
|
||||||
|
pushTextAndBadgeTokens(input.slice(cursor), tokens);
|
||||||
|
}
|
||||||
|
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tokenize a code-free segment into text and badge tokens.
|
||||||
|
*/
|
||||||
|
function pushTextAndBadgeTokens(input: string, tokens: ContentToken[]) {
|
||||||
|
let cursor = 0;
|
||||||
|
MENTION_BADGE_RE.lastIndex = 0;
|
||||||
|
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
while ((match = MENTION_BADGE_RE.exec(input)) !== null) {
|
||||||
|
const [whole, name, path] = match;
|
||||||
|
const start = match.index;
|
||||||
|
|
||||||
|
if (start > cursor) {
|
||||||
|
tokens.push({ kind: 'text', text: input.slice(cursor, start) });
|
||||||
|
}
|
||||||
|
|
||||||
|
tokens.push({ kind: 'badge', name, path });
|
||||||
|
cursor = start + whole.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cursor < input.length) {
|
||||||
|
tokens.push({ kind: 'text', text: input.slice(cursor) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCodeBlockElement(node: Node | null): node is HTMLElement {
|
||||||
|
return node instanceof HTMLElement && node.dataset.codeToken === 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize a contenteditable subtree back to source. `<br>` and block
|
||||||
|
* wrappers the browser inserted for newlines fold back into `\n` (a
|
||||||
|
* trailing `<br>` is the browser's caret placeholder, not a newline);
|
||||||
|
* any other element is transparent. Code spans serialize their
|
||||||
|
* textContent verbatim (fences included). One separator `\n` is
|
||||||
|
* synthesized at every fenced-block boundary (the DOM never stores
|
||||||
|
* it), and a `<br>` adjacent to a code block is an escape hatch, not
|
||||||
|
* a newline.
|
||||||
|
*/
|
||||||
|
export function serializeContent(root: HTMLElement): string {
|
||||||
|
let out = '';
|
||||||
|
let pendingBlockBoundary = false;
|
||||||
|
|
||||||
|
const walk = (parent: Node) => {
|
||||||
|
let first = true; // no source-contributing sibling seen yet
|
||||||
|
|
||||||
|
for (const child of Array.from(parent.childNodes)) {
|
||||||
|
if (child.nodeType === Node.TEXT_NODE) {
|
||||||
|
const text = child.textContent ?? '';
|
||||||
|
if (text.length > 0) {
|
||||||
|
if (pendingBlockBoundary) {
|
||||||
|
out += '\n';
|
||||||
|
pendingBlockBoundary = false;
|
||||||
|
}
|
||||||
|
out += text;
|
||||||
|
first = false;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (child.nodeType !== Node.ELEMENT_NODE) continue;
|
||||||
|
|
||||||
|
const el = child as HTMLElement;
|
||||||
|
|
||||||
|
if (el.dataset.mentionBadge === 'true') {
|
||||||
|
const name = el.dataset.mentionName ?? '';
|
||||||
|
const path = el.dataset.mentionPath ?? '';
|
||||||
|
if (name && path) {
|
||||||
|
if (pendingBlockBoundary) {
|
||||||
|
out += '\n';
|
||||||
|
pendingBlockBoundary = false;
|
||||||
|
}
|
||||||
|
out += `[${name}](file://${path})`;
|
||||||
|
first = false;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (el.dataset.codeToken !== undefined) {
|
||||||
|
const isBlock = el.dataset.codeToken === 'block';
|
||||||
|
if (isBlock && (pendingBlockBoundary || !first)) out += '\n';
|
||||||
|
pendingBlockBoundary = false;
|
||||||
|
walk(el);
|
||||||
|
first = false;
|
||||||
|
if (isBlock) pendingBlockBoundary = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (el.tagName === 'BR') {
|
||||||
|
const isHatch =
|
||||||
|
isCodeBlockElement(el.previousSibling) || isCodeBlockElement(el.nextSibling);
|
||||||
|
if (!isHatch && el.nextSibling) {
|
||||||
|
if (pendingBlockBoundary) {
|
||||||
|
out += '\n';
|
||||||
|
pendingBlockBoundary = false;
|
||||||
|
}
|
||||||
|
out += '\n';
|
||||||
|
first = false;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (BLOCK_TAG_NAMES.has(el.tagName)) {
|
||||||
|
if (pendingBlockBoundary || !first) out += '\n';
|
||||||
|
pendingBlockBoundary = false;
|
||||||
|
walk(el);
|
||||||
|
first = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
walk(el);
|
||||||
|
if (pendingBlockBoundary) first = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
walk(root);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare the live DOM's non-text structure against a token stream.
|
||||||
|
* Only element contributions are compared (badges by name/path, code
|
||||||
|
* spans by kind and source segment): text nodes are owned by the
|
||||||
|
* browser between rebuilds, so their split/merge state is irrelevant.
|
||||||
|
* A mismatch means token boundaries shifted (a code span was just
|
||||||
|
* completed or broken) and the DOM needs a rebuild to restyle.
|
||||||
|
*/
|
||||||
|
export function domMatchesTokens(root: HTMLElement, tokens: ContentToken[]): boolean {
|
||||||
|
const expected = tokens.filter((token) => token.kind !== 'text');
|
||||||
|
let index = 0;
|
||||||
|
|
||||||
|
const walk = (parent: Node): boolean => {
|
||||||
|
for (const child of Array.from(parent.childNodes)) {
|
||||||
|
if (child.nodeType !== Node.ELEMENT_NODE) continue;
|
||||||
|
|
||||||
|
const el = child as HTMLElement;
|
||||||
|
const isBadge = el.dataset.mentionBadge === 'true';
|
||||||
|
const isCode = el.dataset.codeToken !== undefined;
|
||||||
|
|
||||||
|
if (!isBadge && !isCode) {
|
||||||
|
if (!walk(el)) return false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = expected[index++];
|
||||||
|
if (!token) return false;
|
||||||
|
|
||||||
|
if (isBadge) {
|
||||||
|
if (token.kind !== 'badge') return false;
|
||||||
|
if (token.name !== (el.dataset.mentionName ?? '')) return false;
|
||||||
|
if (token.path !== (el.dataset.mentionPath ?? '')) return false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const codeKind = el.dataset.codeToken === 'block' ? 'codeBlock' : 'inlineCode';
|
||||||
|
if (token.kind !== codeKind) return false;
|
||||||
|
if (
|
||||||
|
(token.kind === 'inlineCode' || token.kind === 'codeBlock') &&
|
||||||
|
token.text !== (el.textContent ?? '')
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
return walk(root) && index === expected.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plain-text offset of a `Range` in the root; null range (selection
|
||||||
|
* lost) falls back to buffer length. Walked against the live DOM (not
|
||||||
|
* a clone) so a `<br>` keeps its trailing/not-trailing context. Code
|
||||||
|
* spans count their full textContent (fences included) and the caret
|
||||||
|
* may land inside them; synthesized block boundaries count one `\n`
|
||||||
|
* once the caret is past them.
|
||||||
|
*/
|
||||||
|
export function rangeToTextOffset(root: HTMLElement, range: Range | null): number {
|
||||||
|
if (!range) return serializeContent(root).length;
|
||||||
|
|
||||||
|
// A point is at/before the caret iff it falls inside [root start, caret].
|
||||||
|
const pre = range.cloneRange();
|
||||||
|
pre.selectNodeContents(root);
|
||||||
|
pre.setEnd(range.endContainer, range.endOffset);
|
||||||
|
const atOrBeforeCaret = (node: Node, offset: number) => pre.comparePoint(node, offset) !== 1;
|
||||||
|
|
||||||
|
let total = 0;
|
||||||
|
let done = false;
|
||||||
|
// DOM position of a code block's synthesized after-boundary, set
|
||||||
|
// when walking past a block and consumed by the next contributing
|
||||||
|
// sibling (counts one `\n` once the caret is past it).
|
||||||
|
let pendingPoint: { node: Node; index: number } | null = null;
|
||||||
|
|
||||||
|
const walk = (parent: Node) => {
|
||||||
|
let first = true;
|
||||||
|
|
||||||
|
for (const child of Array.from(parent.childNodes)) {
|
||||||
|
if (done) return;
|
||||||
|
|
||||||
|
if (child.nodeType === Node.TEXT_NODE) {
|
||||||
|
const text = child.textContent ?? '';
|
||||||
|
if (text.length === 0) continue;
|
||||||
|
if (pendingPoint) {
|
||||||
|
const { node, index } = pendingPoint;
|
||||||
|
pendingPoint = null;
|
||||||
|
if (!atOrBeforeCaret(node, index)) {
|
||||||
|
done = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
total += 1;
|
||||||
|
}
|
||||||
|
if (!atOrBeforeCaret(child, 0)) {
|
||||||
|
done = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (range.endContainer === child) {
|
||||||
|
total += range.endOffset;
|
||||||
|
done = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
total += text.length;
|
||||||
|
first = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (child.nodeType !== Node.ELEMENT_NODE) continue;
|
||||||
|
|
||||||
|
const el = child as HTMLElement;
|
||||||
|
const parentNode = el.parentNode as Node;
|
||||||
|
const elIndex = Array.prototype.indexOf.call(parentNode.childNodes, el);
|
||||||
|
|
||||||
|
if (pendingPoint) {
|
||||||
|
const { node, index } = pendingPoint;
|
||||||
|
pendingPoint = null;
|
||||||
|
if (!atOrBeforeCaret(node, index)) {
|
||||||
|
done = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
total += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (el.dataset.mentionBadge === 'true') {
|
||||||
|
const len = badgeSourceLength(el.dataset.mentionName ?? '', el.dataset.mentionPath ?? '');
|
||||||
|
if (len === 0) continue;
|
||||||
|
if (!atOrBeforeCaret(parentNode, elIndex + 1)) {
|
||||||
|
done = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
total += len;
|
||||||
|
first = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (el.dataset.codeToken !== undefined) {
|
||||||
|
const isBlock = el.dataset.codeToken === 'block';
|
||||||
|
if (isBlock && !first) {
|
||||||
|
if (!atOrBeforeCaret(el, 0)) {
|
||||||
|
done = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
total += 1;
|
||||||
|
}
|
||||||
|
walk(el);
|
||||||
|
first = false;
|
||||||
|
if (isBlock) pendingPoint = { node: parentNode, index: elIndex + 1 };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (el.tagName === 'BR') {
|
||||||
|
const isHatch =
|
||||||
|
isCodeBlockElement(el.previousSibling) || isCodeBlockElement(el.nextSibling);
|
||||||
|
if (isHatch || !el.nextSibling) continue;
|
||||||
|
if (!atOrBeforeCaret(parentNode, elIndex + 1)) {
|
||||||
|
done = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
total += 1;
|
||||||
|
first = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (BLOCK_TAG_NAMES.has(el.tagName)) {
|
||||||
|
if (!first) {
|
||||||
|
if (!atOrBeforeCaret(el, 0)) {
|
||||||
|
done = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
total += 1;
|
||||||
|
}
|
||||||
|
walk(el);
|
||||||
|
first = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const before = total;
|
||||||
|
walk(el);
|
||||||
|
if (total > before) first = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
walk(root);
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Materialize a token stream into a DOM subtree: text nodes for text
|
||||||
|
* tokens, `<span data-mention-badge="true">` elements for badges,
|
||||||
|
* `<code data-code-token>` elements for code spans. The badge's class
|
||||||
|
* string + inline SVG are shared with the rehype plugin via
|
||||||
|
* `$lib/constants/mention-badge`.
|
||||||
|
*/
|
||||||
|
export function buildFragment(tokens: ContentToken[]): DocumentFragment {
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
|
||||||
|
for (let index = 0; index < tokens.length; index++) {
|
||||||
|
const token = tokens[index];
|
||||||
|
|
||||||
|
if (token.kind === 'text') {
|
||||||
|
let text = token.text;
|
||||||
|
|
||||||
|
// The separator \n at a fenced-block boundary is synthesized
|
||||||
|
// at serialization time; keeping it in the DOM would render a
|
||||||
|
// phantom empty line next to the block.
|
||||||
|
if (tokens[index - 1]?.kind === 'codeBlock' && text.startsWith('\n')) {
|
||||||
|
text = text.slice(1);
|
||||||
|
}
|
||||||
|
if (tokens[index + 1]?.kind === 'codeBlock' && text.endsWith('\n')) {
|
||||||
|
text = text.slice(0, -1);
|
||||||
|
}
|
||||||
|
if (text.length === 0) continue;
|
||||||
|
|
||||||
|
fragment.appendChild(document.createTextNode(text));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token.kind === 'inlineCode' || token.kind === 'codeBlock') {
|
||||||
|
const code = document.createElement('code');
|
||||||
|
code.dataset.codeToken = token.kind === 'codeBlock' ? 'block' : 'inline';
|
||||||
|
code.textContent = token.text;
|
||||||
|
fragment.appendChild(code);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A leading badge gets an empty text node prepended: without a real
|
||||||
|
// text position at the buffer start, the spot before the badge is
|
||||||
|
// unreachable via keyboard (ArrowLeft/Home).
|
||||||
|
if (!fragment.lastChild) {
|
||||||
|
fragment.appendChild(document.createTextNode(''));
|
||||||
|
}
|
||||||
|
|
||||||
|
const badge = document.createElement('span');
|
||||||
|
badge.dataset.mentionBadge = 'true';
|
||||||
|
badge.dataset.mentionName = token.name;
|
||||||
|
badge.dataset.mentionPath = token.path;
|
||||||
|
badge.title = decodeFileLinkPath(token.path);
|
||||||
|
badge.className = MENTION_BADGE_CLASSNAME;
|
||||||
|
badge.contentEditable = 'false';
|
||||||
|
|
||||||
|
const svg = document.createElementNS(MENTION_BADGE_SVG_ATTRIBUTES['xmlns'], 'svg');
|
||||||
|
for (const [attr, value] of Object.entries(MENTION_BADGE_SVG_ATTRIBUTES)) {
|
||||||
|
svg.setAttribute(attr, value);
|
||||||
|
}
|
||||||
|
for (const cls of MENTION_BADGE_ICON_CLASSNAME.split(/\s+/).filter(Boolean)) {
|
||||||
|
svg.classList.add(cls);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const d of getMentionBadgeIconPaths(token.path)) {
|
||||||
|
const path = document.createElementNS(MENTION_BADGE_SVG_ATTRIBUTES['xmlns'], 'path');
|
||||||
|
path.setAttribute('d', d);
|
||||||
|
svg.appendChild(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
const label = document.createElement('span');
|
||||||
|
label.classList.add('shrink-0', 'truncate');
|
||||||
|
label.textContent = getMentionBadgeLabel(
|
||||||
|
token.name,
|
||||||
|
decodeFileLinkPath(token.path),
|
||||||
|
settingsStore.getConfig(SETTINGS_KEYS.SHOW_FULL_PATH_IN_MENTIONS),
|
||||||
|
toolsStore.serverHome
|
||||||
|
);
|
||||||
|
|
||||||
|
badge.appendChild(svg);
|
||||||
|
badge.appendChild(label);
|
||||||
|
fragment.appendChild(badge);
|
||||||
|
}
|
||||||
|
|
||||||
|
return fragment;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A sibling provides a reachable caret line when it is an element
|
||||||
|
// (badge, another block, an existing hatch) or a non-empty text node.
|
||||||
|
function hasLineBeside(node: Node | null): boolean {
|
||||||
|
if (!node) return false;
|
||||||
|
if (node.nodeType === Node.ELEMENT_NODE) return true;
|
||||||
|
return (node.textContent ?? '') !== '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A code block at the END of the buffer needs an editable line after
|
||||||
|
* it: without one the caret cannot leave the block with
|
||||||
|
* ArrowDown/ArrowRight. A trailing `<br>` provides that line while
|
||||||
|
* staying transparent to serialization (skipped as a hatch), and is
|
||||||
|
* removed again once real content takes its place.
|
||||||
|
*
|
||||||
|
* No hatch is added BEFORE a leading block: the empty line above it
|
||||||
|
* is transient and managed by the component (created when the caret
|
||||||
|
* arrows onto it, removed when the caret leaves). A transient
|
||||||
|
* leading hatch found here is kept; the browser's lone placeholder
|
||||||
|
* `<br>` in an empty root is left untouched.
|
||||||
|
*/
|
||||||
|
export function syncCodeBlockHatches(root: HTMLElement) {
|
||||||
|
for (const child of Array.from(root.childNodes)) {
|
||||||
|
if (child.nodeName !== 'BR') continue;
|
||||||
|
|
||||||
|
const isPlaceholder = root.childNodes.length === 1;
|
||||||
|
const isLeadingHatch = !child.previousSibling && isCodeBlockElement(child.nextSibling);
|
||||||
|
const isTrailingHatch = !child.nextSibling && isCodeBlockElement(child.previousSibling);
|
||||||
|
|
||||||
|
// A hatch goes stale once real content takes over its line:
|
||||||
|
// content before a leading hatch, content after a trailing one,
|
||||||
|
// or a text node after the block already providing the line.
|
||||||
|
// A `<br>` with no code block around is a real newline (browser
|
||||||
|
// Shift+Enter shape) and stays.
|
||||||
|
let prevElement = child.previousSibling;
|
||||||
|
while (prevElement && prevElement.nodeType !== Node.ELEMENT_NODE) {
|
||||||
|
prevElement = prevElement.previousSibling;
|
||||||
|
}
|
||||||
|
const nearBlock =
|
||||||
|
isCodeBlockElement(child.nextSibling) ||
|
||||||
|
isCodeBlockElement(child.previousSibling) ||
|
||||||
|
isCodeBlockElement(prevElement);
|
||||||
|
|
||||||
|
if (!isPlaceholder && !isLeadingHatch && !isTrailingHatch && nearBlock) {
|
||||||
|
child.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const child of Array.from(root.childNodes)) {
|
||||||
|
if (!isCodeBlockElement(child)) continue;
|
||||||
|
|
||||||
|
if (!hasLineBeside(child.nextSibling)) {
|
||||||
|
child.after(document.createElement('br'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip the separator and artificial newlines from an all-newline text
|
||||||
|
* node directly after a fenced block. Chromium's line break at the
|
||||||
|
* buffer end inserts an extra artificial `\n` so the new line has
|
||||||
|
* height, and the first `\n` after a block doubles as the fence's
|
||||||
|
* separator line (synthesized at serialization time). Removing both
|
||||||
|
* makes Shift+Enter after a block land the caret on the line directly
|
||||||
|
* below the block, like a plain textarea would.
|
||||||
|
*
|
||||||
|
* Only all-newline text nodes are touched: a node with real content
|
||||||
|
* carries intentional blank lines and is left alone. Returns true when
|
||||||
|
* the DOM changed.
|
||||||
|
*/
|
||||||
|
export function stripBlockBoundaryLineBreaks(root: HTMLElement): boolean {
|
||||||
|
let changed = false;
|
||||||
|
|
||||||
|
for (const child of Array.from(root.childNodes)) {
|
||||||
|
if (child.nodeType !== Node.TEXT_NODE) continue;
|
||||||
|
if (!isCodeBlockElement(child.previousSibling)) continue;
|
||||||
|
|
||||||
|
let text = child.textContent ?? '';
|
||||||
|
if (!/^\n{2,}$/.test(text)) continue;
|
||||||
|
|
||||||
|
text = text.slice(1);
|
||||||
|
|
||||||
|
const atBufferEnd = !child.nextSibling || child.nextSibling.nodeName === 'BR';
|
||||||
|
if (atBufferEnd) {
|
||||||
|
text = text.slice(0, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
child.textContent = text;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
const WORD_CHAR_RE = /[\p{L}\p{N}_]/u;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Word-jump target (Option+Arrow / Ctrl+Arrow) in source offsets, or null
|
||||||
|
* when the jump crosses no badge and native word movement should handle
|
||||||
|
* it. Badge spans are masked to word characters, so a badge counts as
|
||||||
|
* exactly one word.
|
||||||
|
*/
|
||||||
|
export function badgeAwareWordJump(
|
||||||
|
source: string,
|
||||||
|
offset: number,
|
||||||
|
direction: 'forward' | 'backward'
|
||||||
|
): number | null {
|
||||||
|
let masked = '';
|
||||||
|
const badgeSpans: Array<[number, number]> = [];
|
||||||
|
|
||||||
|
for (const token of tokenizeContent(source)) {
|
||||||
|
const len =
|
||||||
|
token.kind === 'badge' ? badgeSourceLength(token.name, token.path) : token.text.length;
|
||||||
|
if (token.kind === 'badge') badgeSpans.push([masked.length, masked.length + len]);
|
||||||
|
masked += token.kind === 'badge' ? 'a'.repeat(len) : token.text;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (badgeSpans.length === 0) return null;
|
||||||
|
|
||||||
|
const isWord = (index: number) => WORD_CHAR_RE.test(masked[index]);
|
||||||
|
const spanStartingAt = (index: number) => badgeSpans.find(([start]) => start === index);
|
||||||
|
const spanEndingAt = (index: number) => badgeSpans.find(([, end]) => end === index);
|
||||||
|
const n = masked.length;
|
||||||
|
let i = offset;
|
||||||
|
|
||||||
|
if (direction === 'forward') {
|
||||||
|
// Entering a badge completes the word phase at the badge's end edge.
|
||||||
|
if (!(i < n && isWord(i))) {
|
||||||
|
while (i < n && !isWord(i)) i++;
|
||||||
|
}
|
||||||
|
while (i < n && isWord(i)) {
|
||||||
|
const span = spanStartingAt(i);
|
||||||
|
if (span) {
|
||||||
|
i = span[1];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!(i > 0 && isWord(i - 1))) {
|
||||||
|
while (i > 0 && !isWord(i - 1)) i--;
|
||||||
|
}
|
||||||
|
while (i > 0 && isWord(i - 1)) {
|
||||||
|
const span = spanEndingAt(i);
|
||||||
|
if (span) {
|
||||||
|
i = span[0];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
i--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (i === offset) return null;
|
||||||
|
|
||||||
|
const lo = Math.min(offset, i);
|
||||||
|
const hi = Math.max(offset, i);
|
||||||
|
return badgeSpans.some(([start, end]) => start < hi && end > lo) ? i : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 0 when `caret` sits exactly at a leading badge's end edge, null
|
||||||
|
* otherwise. Plain ArrowLeft there has no native previous position, so
|
||||||
|
* the host snaps the caret to the buffer start manually.
|
||||||
|
*/
|
||||||
|
export function leadingBadgeEdgeOffset(source: string, caret: number): number | null {
|
||||||
|
const [first] = tokenizeContent(source);
|
||||||
|
if (!first || first.kind !== 'badge') return null;
|
||||||
|
return caret === badgeSourceLength(first.name, first.path) ? 0 : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Translate a plain-text offset into a degenerate `Range` at that
|
||||||
|
* position in the DOM; out-of-range offsets clamp to buffer end (before
|
||||||
|
* a trailing escape hatch, not after it). Zero offset lands BEFORE a
|
||||||
|
* badge or code span, and an offset exactly at a code span's end lands
|
||||||
|
* AFTER it, so typing at a code span's edge extends the surrounding
|
||||||
|
* text. Interior code-span offsets land in the element's text.
|
||||||
|
* Understands the same block/`<br>` newline shapes as
|
||||||
|
* `serializeContent`.
|
||||||
|
*/
|
||||||
|
export function textOffsetToRange(root: HTMLElement, offset: number): Range {
|
||||||
|
const range = document.createRange();
|
||||||
|
let remaining = offset;
|
||||||
|
let landed = false;
|
||||||
|
let pendingBlockBoundary = false;
|
||||||
|
|
||||||
|
const land = (node: Node, nodeOffset: number) => {
|
||||||
|
range.setStart(node, nodeOffset);
|
||||||
|
range.setEnd(node, nodeOffset);
|
||||||
|
landed = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const walk = (parent: Node) => {
|
||||||
|
let first = true;
|
||||||
|
|
||||||
|
for (const child of Array.from(parent.childNodes)) {
|
||||||
|
if (landed) return;
|
||||||
|
|
||||||
|
if (child.nodeType === Node.TEXT_NODE) {
|
||||||
|
const text = child.textContent ?? '';
|
||||||
|
if (text.length === 0) continue;
|
||||||
|
if (pendingBlockBoundary) {
|
||||||
|
// The synthesized separator maps to the near edge of the
|
||||||
|
// content that follows the block.
|
||||||
|
pendingBlockBoundary = false;
|
||||||
|
if (remaining === 0) {
|
||||||
|
land(child, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
remaining -= 1;
|
||||||
|
}
|
||||||
|
if (remaining <= text.length) {
|
||||||
|
land(child, remaining);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
remaining -= text.length;
|
||||||
|
first = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (child.nodeType !== Node.ELEMENT_NODE) continue;
|
||||||
|
|
||||||
|
const el = child as HTMLElement;
|
||||||
|
|
||||||
|
if (el.dataset.mentionBadge === 'true') {
|
||||||
|
const len = badgeSourceLength(el.dataset.mentionName ?? '', el.dataset.mentionPath ?? '');
|
||||||
|
if (len === 0) continue;
|
||||||
|
if (pendingBlockBoundary) {
|
||||||
|
pendingBlockBoundary = false;
|
||||||
|
if (remaining === 0) {
|
||||||
|
range.setStartBefore(el);
|
||||||
|
range.setEndBefore(el);
|
||||||
|
landed = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
remaining -= 1;
|
||||||
|
}
|
||||||
|
if (remaining <= len) {
|
||||||
|
if (remaining === 0) {
|
||||||
|
range.setStartBefore(el);
|
||||||
|
range.setEndBefore(el);
|
||||||
|
} else {
|
||||||
|
range.setStartAfter(el);
|
||||||
|
range.setEndAfter(el);
|
||||||
|
}
|
||||||
|
landed = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
remaining -= len;
|
||||||
|
first = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (el.dataset.codeToken !== undefined) {
|
||||||
|
const isBlock = el.dataset.codeToken === 'block';
|
||||||
|
if (isBlock && (pendingBlockBoundary || !first)) {
|
||||||
|
pendingBlockBoundary = false;
|
||||||
|
if (remaining === 0) {
|
||||||
|
range.setStartBefore(el);
|
||||||
|
range.setEndBefore(el);
|
||||||
|
landed = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
remaining -= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const len = (el.textContent ?? '').length;
|
||||||
|
if (remaining === 0) {
|
||||||
|
range.setStartBefore(el);
|
||||||
|
range.setEndBefore(el);
|
||||||
|
landed = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (remaining === len) {
|
||||||
|
range.setStartAfter(el);
|
||||||
|
range.setEndAfter(el);
|
||||||
|
landed = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (remaining < len) {
|
||||||
|
walk(el);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
remaining -= len;
|
||||||
|
if (isBlock) remaining -= 1;
|
||||||
|
first = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (el.tagName === 'BR') {
|
||||||
|
const isHatch =
|
||||||
|
isCodeBlockElement(el.previousSibling) || isCodeBlockElement(el.nextSibling);
|
||||||
|
if (isHatch) {
|
||||||
|
// Escape hatch: no source length; offset 0 lands before it
|
||||||
|
// so text typed there takes its place.
|
||||||
|
if (remaining === 0) {
|
||||||
|
range.setStartBefore(el);
|
||||||
|
range.setEndBefore(el);
|
||||||
|
landed = true;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!el.nextSibling) continue;
|
||||||
|
if (pendingBlockBoundary) {
|
||||||
|
pendingBlockBoundary = false;
|
||||||
|
if (remaining === 0) {
|
||||||
|
range.setStartBefore(el);
|
||||||
|
range.setEndBefore(el);
|
||||||
|
landed = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
remaining -= 1;
|
||||||
|
}
|
||||||
|
if (remaining === 0) {
|
||||||
|
range.setStartBefore(el);
|
||||||
|
range.setEndBefore(el);
|
||||||
|
landed = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
remaining -= 1;
|
||||||
|
first = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (BLOCK_TAG_NAMES.has(el.tagName)) {
|
||||||
|
if (pendingBlockBoundary || !first) {
|
||||||
|
pendingBlockBoundary = false;
|
||||||
|
if (remaining === 0) {
|
||||||
|
// The boundary newline belongs to the previous line.
|
||||||
|
range.setStartBefore(el);
|
||||||
|
range.setEndBefore(el);
|
||||||
|
landed = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
remaining -= 1;
|
||||||
|
}
|
||||||
|
walk(el);
|
||||||
|
first = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const before = remaining;
|
||||||
|
walk(el);
|
||||||
|
if (remaining < before) first = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
walk(root);
|
||||||
|
|
||||||
|
if (!landed) {
|
||||||
|
const last = root.lastChild;
|
||||||
|
if (last && last.nodeName === 'BR') {
|
||||||
|
range.setStartBefore(last);
|
||||||
|
range.setEndBefore(last);
|
||||||
|
} else {
|
||||||
|
range.selectNodeContents(root);
|
||||||
|
range.collapse(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return range;
|
||||||
|
}
|
||||||
@@ -33,6 +33,7 @@ export {
|
|||||||
export {
|
export {
|
||||||
highlightCode,
|
highlightCode,
|
||||||
detectIncompleteCodeBlock,
|
detectIncompleteCodeBlock,
|
||||||
|
splitGluedClosingCodeFences,
|
||||||
trimCodePadding,
|
trimCodePadding,
|
||||||
type IncompleteCodeBlock
|
type IncompleteCodeBlock
|
||||||
} from './code';
|
} from './code';
|
||||||
@@ -205,9 +206,30 @@ export {
|
|||||||
type CommandDismissSnapshot
|
type CommandDismissSnapshot
|
||||||
} from './command-token';
|
} from './command-token';
|
||||||
|
|
||||||
// Mention-chip visual contract shared by the rehype file-badge plugin,
|
// Tokenization for the chat-form contenteditable (mention links + code spans <-> chip DOM)
|
||||||
// plus the `[name](file://...)` link helpers the mention picker splices in
|
|
||||||
export {
|
export {
|
||||||
|
tokenizeContent,
|
||||||
|
containsCodeSpan,
|
||||||
|
isOffsetInCodeBlock,
|
||||||
|
domMatchesTokens,
|
||||||
|
syncCodeBlockHatches,
|
||||||
|
stripBlockBoundaryLineBreaks,
|
||||||
|
serializeContent,
|
||||||
|
buildFragment,
|
||||||
|
rangeToTextOffset,
|
||||||
|
textOffsetToRange,
|
||||||
|
badgeAwareWordJump,
|
||||||
|
leadingBadgeEdgeOffset,
|
||||||
|
type ContentToken
|
||||||
|
} from './contenteditable-tokenizer';
|
||||||
|
|
||||||
|
// Source-space undo/redo history for the chat-form contenteditable
|
||||||
|
export { SourceHistory, type SourceHistoryEntry } from './source-history';
|
||||||
|
|
||||||
|
// Mention-badge visual contract (used by the contenteditable / rehype
|
||||||
|
// DOM paths that build the same chip without a Svelte mount)
|
||||||
|
export {
|
||||||
|
containsFileMentionLink,
|
||||||
fileMentionLinkRe,
|
fileMentionLinkRe,
|
||||||
encodeFileLinkPath,
|
encodeFileLinkPath,
|
||||||
decodeFileLinkPath,
|
decodeFileLinkPath,
|
||||||
@@ -218,8 +240,7 @@ export {
|
|||||||
MENTION_BADGE_FOLDER_ICON_PATHS,
|
MENTION_BADGE_FOLDER_ICON_PATHS,
|
||||||
getMentionBadgeIconPaths,
|
getMentionBadgeIconPaths,
|
||||||
getMentionBadgeLabel,
|
getMentionBadgeLabel,
|
||||||
buildMentionInsertion,
|
buildMentionInsertion
|
||||||
mentionLinkEndingAt
|
|
||||||
} from './mention-badge';
|
} from './mention-badge';
|
||||||
|
|
||||||
// Agentic content utilities (structured section derivation)
|
// Agentic content utilities (structured section derivation)
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ export function fileMentionLinkRe(flags = ''): RegExp {
|
|||||||
return new RegExp(FILE_MENTION_LINK_SOURCE, flags);
|
return new RegExp(FILE_MENTION_LINK_SOURCE, flags);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function containsFileMentionLink(value: string): boolean {
|
||||||
|
return fileMentionLinkRe().test(value);
|
||||||
|
}
|
||||||
|
|
||||||
// Escape each path segment for a markdown link destination (spaces/parens
|
// Escape each path segment for a markdown link destination (spaces/parens
|
||||||
// break CommonMark); keeps the trailing slash that marks a directory.
|
// break CommonMark); keeps the trailing slash that marks a directory.
|
||||||
export function encodeFileLinkPath(path: string): string {
|
export function encodeFileLinkPath(path: string): string {
|
||||||
@@ -60,25 +64,6 @@ export function getMentionBadgeLabel(
|
|||||||
return abbreviateHome(decoded, home);
|
return abbreviateHome(decoded, home);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Extent of the mention link ending exactly at `caret`, so Backspace there
|
|
||||||
* deletes the whole `[name](file://...)` token in one keystroke instead of
|
|
||||||
* unraveling it character by character. Null when no link ends at `caret`.
|
|
||||||
*/
|
|
||||||
export function mentionLinkEndingAt(
|
|
||||||
value: string,
|
|
||||||
caret: number
|
|
||||||
): { start: number; end: number } | null {
|
|
||||||
const re = fileMentionLinkRe('g');
|
|
||||||
let match: RegExpExecArray | null;
|
|
||||||
while ((match = re.exec(value)) !== null) {
|
|
||||||
const end = match.index + match[0].length;
|
|
||||||
if (end === caret) return { start: match.index, end };
|
|
||||||
if (end > caret) break;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the markdown link that replaces a mention token. Entry `path` is
|
* Build the markdown link that replaces a mention token. Entry `path` is
|
||||||
* already rooted, so `file://` + `/abs` yields the canonical `file:///`.
|
* already rooted, so `file://` + `/abs` yields the canonical `file:///`.
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
/**
|
||||||
|
* Source-space undo/redo history for the chat-form contenteditable, whose
|
||||||
|
* imperative DOM rebuilds destroy the browser's native undo stack.
|
||||||
|
* Entries record the state BEFORE an edit; edits within `groupWindowMs`
|
||||||
|
* extend the open group so a typing burst undoes as a unit, while
|
||||||
|
* structural edits (paste, mention insert, clear) pass `newGroup`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface SourceHistoryEntry {
|
||||||
|
value: string;
|
||||||
|
caret: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SourceHistory {
|
||||||
|
private undoStack: SourceHistoryEntry[] = [];
|
||||||
|
private redoStack: SourceHistoryEntry[] = [];
|
||||||
|
private lastPush = 0;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private limit = 100,
|
||||||
|
private groupWindowMs = 800
|
||||||
|
) {}
|
||||||
|
|
||||||
|
push(entry: SourceHistoryEntry, now: number, newGroup = false): void {
|
||||||
|
if (newGroup || now - this.lastPush >= this.groupWindowMs || this.undoStack.length === 0) {
|
||||||
|
this.undoStack.push(entry);
|
||||||
|
if (this.undoStack.length > this.limit) this.undoStack.shift();
|
||||||
|
}
|
||||||
|
this.lastPush = now;
|
||||||
|
this.redoStack = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
undo(current: SourceHistoryEntry): SourceHistoryEntry | null {
|
||||||
|
const entry = this.undoStack.pop();
|
||||||
|
if (!entry) return null;
|
||||||
|
this.redoStack.push(current);
|
||||||
|
this.lastPush = 0; // the next edit after an undo starts a new group
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
redo(current: SourceHistoryEntry): SourceHistoryEntry | null {
|
||||||
|
const entry = this.redoStack.pop();
|
||||||
|
if (!entry) return null;
|
||||||
|
this.undoStack.push(current);
|
||||||
|
this.lastPush = 0;
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
// Guards the newline contract of the chat-form contenteditable: browsers
|
||||||
|
// restructure the flat DOM on Enter (`<div>` wrappers, `<br>` shapes) and
|
||||||
|
// serialization must fold those back into `\n` so the emitted value never
|
||||||
|
// diverges from what is on screen.
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render } from 'vitest-browser-svelte';
|
||||||
|
import { tick } from 'svelte';
|
||||||
|
import ChatFormContenteditableHarness from './components/ChatFormContenteditableHarness.svelte';
|
||||||
|
|
||||||
|
const SOURCE = 'see [docs](file:///a/b) here';
|
||||||
|
|
||||||
|
function editableIn(container: HTMLElement): HTMLElement {
|
||||||
|
const el = container.querySelector('[role="textbox"]');
|
||||||
|
if (!(el instanceof HTMLElement)) throw new Error('contenteditable not rendered');
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fireInput(root: HTMLElement) {
|
||||||
|
root.dispatchEvent(new InputEvent('input', { bubbles: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCaret(node: Node, offset: number) {
|
||||||
|
const range = document.createRange();
|
||||||
|
range.setStart(node, offset);
|
||||||
|
range.setEnd(node, offset);
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (!selection) throw new Error('no selection');
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(range);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ChatFormContenteditable browser newline shapes', () => {
|
||||||
|
it('serializes a Chromium Enter <div> wrapper as a newline', async () => {
|
||||||
|
const screen = render(ChatFormContenteditableHarness, { value: SOURCE });
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
const root = editableIn(screen.container);
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = 'second line';
|
||||||
|
root.appendChild(div);
|
||||||
|
fireInput(root);
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
expect(screen.component.getValue()).toBe(`${SOURCE}\nsecond line`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serializes a Firefox full <div> wrap as lines, badge included', async () => {
|
||||||
|
const screen = render(ChatFormContenteditableHarness, { value: SOURCE });
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
const root = editableIn(screen.container);
|
||||||
|
const first = document.createElement('div');
|
||||||
|
while (root.firstChild) first.appendChild(root.firstChild);
|
||||||
|
const second = document.createElement('div');
|
||||||
|
second.textContent = 'second line';
|
||||||
|
root.appendChild(first);
|
||||||
|
root.appendChild(second);
|
||||||
|
fireInput(root);
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
expect(screen.component.getValue()).toBe(`${SOURCE}\nsecond line`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serializes a <br> as a newline', async () => {
|
||||||
|
const screen = render(ChatFormContenteditableHarness, { value: 'here' });
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
const root = editableIn(screen.container);
|
||||||
|
root.appendChild(document.createElement('br'));
|
||||||
|
root.appendChild(document.createTextNode('second line'));
|
||||||
|
fireInput(root);
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
expect(screen.component.getValue()).toBe('here\nsecond line');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a trailing <br> (browser caret placeholder)', async () => {
|
||||||
|
const screen = render(ChatFormContenteditableHarness, { value: 'abc' });
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
const root = editableIn(screen.container);
|
||||||
|
root.appendChild(document.createElement('br'));
|
||||||
|
fireInput(root);
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
expect(screen.component.getValue()).toBe('abc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serializes one newline per empty-line <div><br></div>', async () => {
|
||||||
|
const screen = render(ChatFormContenteditableHarness, { value: 'abc' });
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
const root = editableIn(screen.container);
|
||||||
|
for (let i = 0; i < 2; i++) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.appendChild(document.createElement('br'));
|
||||||
|
root.appendChild(div);
|
||||||
|
}
|
||||||
|
fireInput(root);
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
expect(screen.component.getValue()).toBe('abc\n\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats a <div><br></div>-only buffer as empty for the placeholder', async () => {
|
||||||
|
const screen = render(ChatFormContenteditableHarness, { value: 'abc' });
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
const root = editableIn(screen.container);
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.appendChild(document.createElement('br'));
|
||||||
|
root.replaceChildren(div);
|
||||||
|
fireInput(root);
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
expect(screen.component.getValue()).toBe('');
|
||||||
|
expect(root.dataset.empty).toBe('true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps the caret across block boundaries in both directions', async () => {
|
||||||
|
const screen = render(ChatFormContenteditableHarness, { value: 'abc\ndef' });
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
// Rebuild into the Chromium block shape; the source is unchanged,
|
||||||
|
// so no re-render fires.
|
||||||
|
const root = editableIn(screen.container);
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = 'def';
|
||||||
|
root.replaceChildren(document.createTextNode('abc'), div);
|
||||||
|
fireInput(root);
|
||||||
|
await tick();
|
||||||
|
expect(screen.component.getValue()).toBe('abc\ndef');
|
||||||
|
|
||||||
|
const divText = div.firstChild;
|
||||||
|
if (!divText) throw new Error('div text missing');
|
||||||
|
|
||||||
|
setCaret(divText, 2);
|
||||||
|
expect(screen.component.getCaretOffset()).toBe(6);
|
||||||
|
|
||||||
|
screen.component.setCaretOffset(6);
|
||||||
|
const selection = window.getSelection();
|
||||||
|
expect(selection?.anchorNode).toBe(divText);
|
||||||
|
expect(selection?.anchorOffset).toBe(2);
|
||||||
|
|
||||||
|
// The boundary newline itself: offset 3 is the end of "abc", offset
|
||||||
|
// 4 the start of the "def" line.
|
||||||
|
screen.component.setCaretOffset(4);
|
||||||
|
expect(window.getSelection()?.anchorNode).toBe(divText);
|
||||||
|
expect(window.getSelection()?.anchorOffset).toBe(0);
|
||||||
|
|
||||||
|
screen.component.setCaretOffset(3);
|
||||||
|
expect(window.getSelection()?.anchorNode).toBe(root.firstChild);
|
||||||
|
expect(window.getSelection()?.anchorOffset).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
// Guards the editing-key contract of the chat-form contenteditable:
|
||||||
|
// undo/redo is replayed from source snapshots (the token rebuilds destroy
|
||||||
|
// the native undo stack), and Tab is NOT intercepted (WCAG 2.1.2 no
|
||||||
|
// keyboard trap), matching the plain textarea.
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render } from 'vitest-browser-svelte';
|
||||||
|
import { tick } from 'svelte';
|
||||||
|
import ChatFormContenteditableHarness from './components/ChatFormContenteditableHarness.svelte';
|
||||||
|
|
||||||
|
const SOURCE = 'see [docs](file:///a/b)';
|
||||||
|
|
||||||
|
function editableIn(container: HTMLElement): HTMLElement {
|
||||||
|
const el = container.querySelector('[role="textbox"]');
|
||||||
|
if (!(el instanceof HTMLElement)) throw new Error('contenteditable not rendered');
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
function type(root: HTMLElement, text: string, inputType = 'insertText') {
|
||||||
|
root.appendChild(document.createTextNode(text));
|
||||||
|
root.dispatchEvent(new InputEvent('input', { bubbles: true, inputType }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function keydown(root: HTMLElement, init: KeyboardEventInit) {
|
||||||
|
const event = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ...init });
|
||||||
|
root.dispatchEvent(event);
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ChatFormContenteditable undo/redo', () => {
|
||||||
|
it('undoes and redoes an edit across a badge-containing buffer', async () => {
|
||||||
|
const screen = render(ChatFormContenteditableHarness, { value: SOURCE });
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
const root = editableIn(screen.container);
|
||||||
|
type(root, ' more');
|
||||||
|
await tick();
|
||||||
|
expect(screen.component.getValue()).toBe(`${SOURCE} more`);
|
||||||
|
|
||||||
|
const undoEvent = keydown(root, { key: 'z', ctrlKey: true });
|
||||||
|
await tick();
|
||||||
|
expect(undoEvent.defaultPrevented).toBe(true);
|
||||||
|
expect(screen.component.getValue()).toBe(SOURCE);
|
||||||
|
|
||||||
|
const redoEvent = keydown(root, { key: 'z', ctrlKey: true, shiftKey: true });
|
||||||
|
await tick();
|
||||||
|
expect(redoEvent.defaultPrevented).toBe(true);
|
||||||
|
expect(screen.component.getValue()).toBe(`${SOURCE} more`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redoes with Ctrl+Y as well', async () => {
|
||||||
|
const screen = render(ChatFormContenteditableHarness, { value: SOURCE });
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
const root = editableIn(screen.container);
|
||||||
|
type(root, ' more');
|
||||||
|
await tick();
|
||||||
|
keydown(root, { key: 'z', metaKey: true });
|
||||||
|
await tick();
|
||||||
|
expect(screen.component.getValue()).toBe(SOURCE);
|
||||||
|
|
||||||
|
keydown(root, { key: 'y', ctrlKey: true });
|
||||||
|
await tick();
|
||||||
|
expect(screen.component.getValue()).toBe(`${SOURCE} more`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('coalesces a typing burst into one undo step', async () => {
|
||||||
|
const screen = render(ChatFormContenteditableHarness, { value: 'abc' });
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
const root = editableIn(screen.container);
|
||||||
|
type(root, 'd');
|
||||||
|
type(root, 'e');
|
||||||
|
await tick();
|
||||||
|
expect(screen.component.getValue()).toBe('abcde');
|
||||||
|
|
||||||
|
keydown(root, { key: 'z', ctrlKey: true });
|
||||||
|
await tick();
|
||||||
|
expect(screen.component.getValue()).toBe('abc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a newline as its own undo step', async () => {
|
||||||
|
const screen = render(ChatFormContenteditableHarness, { value: 'abc' });
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
const root = editableIn(screen.container);
|
||||||
|
type(root, 'd');
|
||||||
|
type(root, '\n', 'insertLineBreak');
|
||||||
|
await tick();
|
||||||
|
expect(screen.component.getValue()).toBe('abcd\n');
|
||||||
|
|
||||||
|
keydown(root, { key: 'z', ctrlKey: true });
|
||||||
|
await tick();
|
||||||
|
expect(screen.component.getValue()).toBe('abcd');
|
||||||
|
|
||||||
|
keydown(root, { key: 'z', ctrlKey: true });
|
||||||
|
await tick();
|
||||||
|
expect(screen.component.getValue()).toBe('abc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a no-op when there is nothing to undo', async () => {
|
||||||
|
const screen = render(ChatFormContenteditableHarness, { value: 'abc' });
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
const root = editableIn(screen.container);
|
||||||
|
const event = keydown(root, { key: 'z', ctrlKey: true });
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
expect(event.defaultPrevented).toBe(true);
|
||||||
|
expect(screen.component.getValue()).toBe('abc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('abandons the redo branch after a fresh edit', async () => {
|
||||||
|
const screen = render(ChatFormContenteditableHarness, { value: 'abc' });
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
const root = editableIn(screen.container);
|
||||||
|
type(root, 'd');
|
||||||
|
await tick();
|
||||||
|
keydown(root, { key: 'z', ctrlKey: true });
|
||||||
|
await tick();
|
||||||
|
expect(screen.component.getValue()).toBe('abc');
|
||||||
|
|
||||||
|
type(root, 'e');
|
||||||
|
await tick();
|
||||||
|
keydown(root, { key: 'z', ctrlKey: true, shiftKey: true });
|
||||||
|
await tick();
|
||||||
|
expect(screen.component.getValue()).toBe('abce');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ChatFormContenteditable Tab key', () => {
|
||||||
|
it('does not trap Tab (focus can leave the editable)', async () => {
|
||||||
|
const screen = render(ChatFormContenteditableHarness, { value: SOURCE });
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
const root = editableIn(screen.container);
|
||||||
|
const event = keydown(root, { key: 'Tab' });
|
||||||
|
|
||||||
|
expect(event.defaultPrevented).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,701 @@
|
|||||||
|
// Guards the clipboard contract of the chat-form contenteditable:
|
||||||
|
// copy/cut expose the markdown SOURCE of the selection (each badge
|
||||||
|
// contributes its full `[name](file://...)` link) and pasting such
|
||||||
|
// markdown re-renders the badges.
|
||||||
|
|
||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render } from 'vitest-browser-svelte';
|
||||||
|
import { userEvent } from 'vitest/browser';
|
||||||
|
import { tick } from 'svelte';
|
||||||
|
import { rangeToTextOffset, serializeContent, textOffsetToRange } from '$lib/utils';
|
||||||
|
import ChatFormContenteditable from '$lib/components/app/chat/ChatForm/ChatFormContenteditable.svelte';
|
||||||
|
|
||||||
|
const SOURCE = 'hello [docs](file:///a/b) world';
|
||||||
|
const BADGE_SELECTOR = '[data-mention-badge="true"]';
|
||||||
|
|
||||||
|
function editableIn(container: HTMLElement): HTMLElement {
|
||||||
|
const el = container.querySelector('[role="textbox"]');
|
||||||
|
if (!(el instanceof HTMLElement)) throw new Error('contenteditable not rendered');
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSelection(root: HTMLElement, place: (range: Range, root: HTMLElement) => void) {
|
||||||
|
const range = document.createRange();
|
||||||
|
place(range, root);
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (!selection) throw new Error('no selection');
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(range);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clipboardEvent(type: 'copy' | 'cut' | 'paste', text = '') {
|
||||||
|
const data = new DataTransfer();
|
||||||
|
if (text) data.setData('text/plain', text);
|
||||||
|
const event = new ClipboardEvent(type, { clipboardData: data, bubbles: true, cancelable: true });
|
||||||
|
return { event, data };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ChatFormContenteditable clipboard', () => {
|
||||||
|
it('copy exposes the markdown source of the selection', async () => {
|
||||||
|
const { container } = render(ChatFormContenteditable, { value: SOURCE });
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
const root = editableIn(container);
|
||||||
|
setSelection(root, (range) => range.selectNodeContents(root));
|
||||||
|
|
||||||
|
const { event, data } = clipboardEvent('copy');
|
||||||
|
root.dispatchEvent(event);
|
||||||
|
|
||||||
|
expect(event.defaultPrevented).toBe(true);
|
||||||
|
expect(data.getData('text/plain')).toBe(SOURCE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cut exposes the markdown source and removes the slice', async () => {
|
||||||
|
const { container } = render(ChatFormContenteditable, { value: SOURCE });
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
const root = editableIn(container);
|
||||||
|
setSelection(root, (range) => {
|
||||||
|
const badge = root.querySelector(BADGE_SELECTOR);
|
||||||
|
if (!badge) throw new Error('badge not rendered');
|
||||||
|
range.setStartBefore(badge);
|
||||||
|
range.setEndAfter(badge);
|
||||||
|
});
|
||||||
|
|
||||||
|
const { event, data } = clipboardEvent('cut');
|
||||||
|
root.dispatchEvent(event);
|
||||||
|
|
||||||
|
expect(event.defaultPrevented).toBe(true);
|
||||||
|
expect(data.getData('text/plain')).toBe('[docs](file:///a/b)');
|
||||||
|
expect(root.querySelector(BADGE_SELECTOR)).toBeNull();
|
||||||
|
expect(root.textContent).toBe('hello world');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('paste of markdown mention links re-renders badges', async () => {
|
||||||
|
const { container } = render(ChatFormContenteditable, { value: 'hello ' });
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
const root = editableIn(container);
|
||||||
|
root.focus();
|
||||||
|
setSelection(root, (range) => {
|
||||||
|
range.selectNodeContents(root);
|
||||||
|
range.collapse(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
const { event } = clipboardEvent('paste', '[docs](file:///a/b) world');
|
||||||
|
root.dispatchEvent(event);
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
expect(event.defaultPrevented).toBe(true);
|
||||||
|
const badge = root.querySelector(BADGE_SELECTOR);
|
||||||
|
expect(badge).not.toBeNull();
|
||||||
|
expect(badge!.getAttribute('data-mention-name')).toBe('docs');
|
||||||
|
expect(root.textContent).toContain('world');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('paste without mention links keeps the DOM untouched', async () => {
|
||||||
|
const { container } = render(ChatFormContenteditable, { value: 'hello ' });
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
const root = editableIn(container);
|
||||||
|
root.focus();
|
||||||
|
setSelection(root, (range) => {
|
||||||
|
range.selectNodeContents(root);
|
||||||
|
range.collapse(false);
|
||||||
|
});
|
||||||
|
const firstChild = root.firstChild;
|
||||||
|
|
||||||
|
const { event } = clipboardEvent('paste', 'plain text');
|
||||||
|
root.dispatchEvent(event);
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
expect(event.defaultPrevented).toBe(true);
|
||||||
|
expect(root.querySelector(BADGE_SELECTOR)).toBeNull();
|
||||||
|
// no rebuild: the live text node is the same instance
|
||||||
|
expect(root.firstChild).toBe(firstChild);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ChatFormContenteditable code spans', () => {
|
||||||
|
it('renders inline code from the initial value', async () => {
|
||||||
|
const { container } = render(ChatFormContenteditable, { value: 'run `npm test` now' });
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
const root = editableIn(container);
|
||||||
|
const code = root.querySelector('code[data-code-token="inline"]');
|
||||||
|
expect(code).not.toBeNull();
|
||||||
|
expect(code!.textContent).toBe('`npm test`');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a fenced code block with a language', async () => {
|
||||||
|
const source = 'before\n```js\nconst a = 1;\n```\nafter';
|
||||||
|
const { container } = render(ChatFormContenteditable, { value: source });
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
const root = editableIn(container);
|
||||||
|
const code = root.querySelector('code[data-code-token="block"]');
|
||||||
|
expect(code).not.toBeNull();
|
||||||
|
expect(code!.textContent).toBe('```js\nconst a = 1;\n```');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('copy exposes the markdown source of a selection spanning code', async () => {
|
||||||
|
const source = 'run `npm test` now';
|
||||||
|
const { container } = render(ChatFormContenteditable, { value: source });
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
const root = editableIn(container);
|
||||||
|
setSelection(root, (range) => range.selectNodeContents(root));
|
||||||
|
|
||||||
|
const { event, data } = clipboardEvent('copy');
|
||||||
|
root.dispatchEvent(event);
|
||||||
|
|
||||||
|
expect(event.defaultPrevented).toBe(true);
|
||||||
|
expect(data.getData('text/plain')).toBe(source);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('paste of a code span renders the styled element', async () => {
|
||||||
|
const { container } = render(ChatFormContenteditable, { value: 'run ' });
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
const root = editableIn(container);
|
||||||
|
root.focus();
|
||||||
|
setSelection(root, (range) => {
|
||||||
|
range.selectNodeContents(root);
|
||||||
|
range.collapse(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
const { event } = clipboardEvent('paste', '`npm test` now');
|
||||||
|
root.dispatchEvent(event);
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
expect(event.defaultPrevented).toBe(true);
|
||||||
|
const code = root.querySelector('code[data-code-token="inline"]');
|
||||||
|
expect(code).not.toBeNull();
|
||||||
|
expect(code!.textContent).toBe('`npm test`');
|
||||||
|
expect(root.textContent).toContain('now');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('highlights a fenced block content and stays byte-exact', async () => {
|
||||||
|
const source = '```js\nconst a = 1;\n```';
|
||||||
|
const { container } = render(ChatFormContenteditable, { value: source });
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
const root = editableIn(container);
|
||||||
|
const code = root.querySelector('code[data-code-token="block"]');
|
||||||
|
expect(code).not.toBeNull();
|
||||||
|
expect(code!.querySelector('.hljs-keyword')).not.toBeNull();
|
||||||
|
expect(code!.textContent).toBe(source);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not highlight inline code', async () => {
|
||||||
|
const { container } = render(ChatFormContenteditable, { value: 'run `const` now' });
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
const root = editableIn(container);
|
||||||
|
expect(root.querySelector('[class*="hljs-"]')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ChatFormContenteditable code block escape hatches', () => {
|
||||||
|
const BLOCK_SOURCE = '```js\nconst a = 1;\n```';
|
||||||
|
const BLOCK_SELECTOR = 'code[data-code-token="block"]';
|
||||||
|
|
||||||
|
function blockIn(root: HTMLElement): HTMLElement {
|
||||||
|
const el = root.querySelector(BLOCK_SELECTOR);
|
||||||
|
if (!(el instanceof HTMLElement)) throw new Error('code block not rendered');
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Caret at the very start/end of the block's text (across highlight spans)
|
||||||
|
function placeCaretInBlock(root: HTMLElement, where: 'start' | 'end') {
|
||||||
|
const code = blockIn(root);
|
||||||
|
const walker = document.createTreeWalker(code, NodeFilter.SHOW_TEXT);
|
||||||
|
let target: Node | null = null;
|
||||||
|
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
|
||||||
|
target = where === 'start' ? (target ?? n) : n;
|
||||||
|
}
|
||||||
|
if (!target) throw new Error('no text inside code block');
|
||||||
|
setSelection(root, (range) => {
|
||||||
|
range.setStart(target!, where === 'start' ? 0 : (target!.textContent ?? '').length);
|
||||||
|
range.collapse(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function caretContainer(): Node {
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (!selection || selection.rangeCount === 0) throw new Error('no selection');
|
||||||
|
return selection.getRangeAt(0).startContainer;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('pads a trailing code block with a br hatch that stays invisible to copy', async () => {
|
||||||
|
const { container } = render(ChatFormContenteditable, { value: BLOCK_SOURCE });
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
const root = editableIn(container);
|
||||||
|
// no permanent empty line above a leading block
|
||||||
|
expect(root.firstChild).toBe(blockIn(root));
|
||||||
|
expect(root.lastChild?.nodeName).toBe('BR');
|
||||||
|
|
||||||
|
setSelection(root, (range) => range.selectNodeContents(root));
|
||||||
|
const { event, data } = clipboardEvent('copy');
|
||||||
|
root.dispatchEvent(event);
|
||||||
|
|
||||||
|
expect(data.getData('text/plain')).toBe(BLOCK_SOURCE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('escapes a trailing code block with ArrowDown and types after it', async () => {
|
||||||
|
const { container } = render(ChatFormContenteditable, { value: BLOCK_SOURCE });
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
const root = editableIn(container);
|
||||||
|
root.focus();
|
||||||
|
placeCaretInBlock(root, 'end');
|
||||||
|
|
||||||
|
await userEvent.keyboard('{ArrowDown}');
|
||||||
|
expect(blockIn(root).contains(caretContainer())).toBe(false);
|
||||||
|
|
||||||
|
await userEvent.keyboard('x');
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
expect(blockIn(root).textContent).toBe(BLOCK_SOURCE);
|
||||||
|
// the DOM holds no separator newline (it would render as a
|
||||||
|
// phantom empty line); serialization synthesizes it so the
|
||||||
|
// markdown source keeps the text below the block
|
||||||
|
expect(root.textContent).toBe(BLOCK_SOURCE + 'x');
|
||||||
|
expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\nx');
|
||||||
|
// the stale trailing hatch is removed once real text follows the block
|
||||||
|
expect(root.lastChild?.nodeName).not.toBe('BR');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('escapes a leading code block with ArrowUp and types before it', async () => {
|
||||||
|
const { container } = render(ChatFormContenteditable, { value: BLOCK_SOURCE });
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
const root = editableIn(container);
|
||||||
|
root.focus();
|
||||||
|
placeCaretInBlock(root, 'start');
|
||||||
|
|
||||||
|
await userEvent.keyboard('{ArrowUp}');
|
||||||
|
expect(blockIn(root).contains(caretContainer())).toBe(false);
|
||||||
|
// the transient hatch line exists while the caret sits on it
|
||||||
|
expect(root.firstChild?.nodeName).toBe('BR');
|
||||||
|
|
||||||
|
await userEvent.keyboard('y');
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
expect(blockIn(root).textContent).toBe(BLOCK_SOURCE);
|
||||||
|
expect(root.textContent).toBe('y' + BLOCK_SOURCE);
|
||||||
|
expect(serializeContent(root)).toBe('y\n' + BLOCK_SOURCE);
|
||||||
|
// the typed text consumed the hatch
|
||||||
|
expect(root.firstChild?.nodeName).not.toBe('BR');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('escapes a leading code block with ArrowLeft from its first character', async () => {
|
||||||
|
const { container } = render(ChatFormContenteditable, { value: BLOCK_SOURCE });
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
const root = editableIn(container);
|
||||||
|
root.focus();
|
||||||
|
placeCaretInBlock(root, 'start');
|
||||||
|
|
||||||
|
await userEvent.keyboard('{ArrowLeft}');
|
||||||
|
expect(blockIn(root).contains(caretContainer())).toBe(false);
|
||||||
|
expect(root.firstChild?.nodeName).toBe('BR');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes the transient leading hatch when the caret moves back into the block', async () => {
|
||||||
|
const { container } = render(ChatFormContenteditable, { value: BLOCK_SOURCE });
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
const root = editableIn(container);
|
||||||
|
root.focus();
|
||||||
|
placeCaretInBlock(root, 'start');
|
||||||
|
|
||||||
|
await userEvent.keyboard('{ArrowUp}');
|
||||||
|
expect(root.firstChild?.nodeName).toBe('BR');
|
||||||
|
|
||||||
|
await userEvent.keyboard('{ArrowDown}');
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
expect(blockIn(root).contains(caretContainer())).toBe(true);
|
||||||
|
expect(root.firstChild).toBe(blockIn(root));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extends the selection out of the block with Shift+ArrowDown', async () => {
|
||||||
|
const { container } = render(ChatFormContenteditable, { value: BLOCK_SOURCE });
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
const root = editableIn(container);
|
||||||
|
root.focus();
|
||||||
|
placeCaretInBlock(root, 'end');
|
||||||
|
|
||||||
|
await userEvent.keyboard('{Shift>}{ArrowDown}{/Shift}');
|
||||||
|
|
||||||
|
const selection = window.getSelection();
|
||||||
|
expect(selection).not.toBeNull();
|
||||||
|
expect(selection!.isCollapsed).toBe(false);
|
||||||
|
expect(blockIn(root).contains(selection!.getRangeAt(0).endContainer)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('line-separates text typed right after the closing fence', async () => {
|
||||||
|
const { container } = render(ChatFormContenteditable, { value: BLOCK_SOURCE });
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
const root = editableIn(container);
|
||||||
|
root.focus();
|
||||||
|
placeCaretInBlock(root, 'end');
|
||||||
|
|
||||||
|
// no arrow keys: the caret sits at the block's end edge, where the
|
||||||
|
// post-rebuild restore lands it, and the typed text renders on the
|
||||||
|
// line below the block
|
||||||
|
await userEvent.keyboard('x');
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
// the text stays on the caret's line in the DOM (no phantom empty
|
||||||
|
// line); the source gets the separator newline
|
||||||
|
expect(root.textContent).toBe(BLOCK_SOURCE + 'x');
|
||||||
|
expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\nx');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not double the newline when Shift+Enter already added one', async () => {
|
||||||
|
const { container } = render(ChatFormContenteditable, { value: BLOCK_SOURCE });
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
const root = editableIn(container);
|
||||||
|
root.focus();
|
||||||
|
placeCaretInBlock(root, 'end');
|
||||||
|
|
||||||
|
await userEvent.keyboard('{Shift>}{Enter}{/Shift}');
|
||||||
|
await userEvent.keyboard('x');
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
expect(root.textContent).toBe(BLOCK_SOURCE + 'x');
|
||||||
|
expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\nx');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves a caret stuck before the inserted newline onto the new line', async () => {
|
||||||
|
const { container } = render(ChatFormContenteditable, {
|
||||||
|
value: BLOCK_SOURCE + '\ntext after the code block'
|
||||||
|
});
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
const root = editableIn(container);
|
||||||
|
root.focus();
|
||||||
|
|
||||||
|
// post-break DOM some browsers produce: the inserted newline plus
|
||||||
|
// the artificial trailing one, with the caret stuck BEFORE the
|
||||||
|
// inserted one (visually at the end of the old line)
|
||||||
|
root.appendChild(document.createTextNode('\n'));
|
||||||
|
root.appendChild(document.createTextNode('\n'));
|
||||||
|
setSelection(root, (range) => {
|
||||||
|
range.setStart(root.childNodes[2], 0);
|
||||||
|
range.collapse(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
root.dispatchEvent(new InputEvent('input', { inputType: 'insertLineBreak', bubbles: true }));
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
const selection = window.getSelection();
|
||||||
|
expect(rangeToTextOffset(root, selection!.getRangeAt(0))).toBe(
|
||||||
|
(BLOCK_SOURCE + '\ntext after the code block\n').length
|
||||||
|
);
|
||||||
|
expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\ntext after the code block\n\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appends the artificial trailing newline when the browser did not add one', async () => {
|
||||||
|
const { container } = render(ChatFormContenteditable, {
|
||||||
|
value: BLOCK_SOURCE + '\ntext after the code block'
|
||||||
|
});
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
const root = editableIn(container);
|
||||||
|
root.focus();
|
||||||
|
|
||||||
|
// post-break DOM some browsers produce: a lone trailing \n (or a
|
||||||
|
// <br> the hatch sync strips). Collapsed by the renderer, so the
|
||||||
|
// caret looks stuck on the old line and the next typed character
|
||||||
|
// would consume the newline.
|
||||||
|
root.appendChild(document.createTextNode('\n'));
|
||||||
|
setSelection(root, (range) => {
|
||||||
|
range.setStart(root.childNodes[2], 1);
|
||||||
|
range.collapse(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
root.dispatchEvent(new InputEvent('input', { inputType: 'insertLineBreak', bubbles: true }));
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
const selection = window.getSelection();
|
||||||
|
expect(rangeToTextOffset(root, selection!.getRangeAt(0))).toBe(
|
||||||
|
(BLOCK_SOURCE + '\ntext after the code block\n').length
|
||||||
|
);
|
||||||
|
expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\ntext after the code block\n\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lands the caret on the new line with a single Shift+Enter after text below a block', async () => {
|
||||||
|
const { container } = render(ChatFormContenteditable, {
|
||||||
|
value: BLOCK_SOURCE + '\ntext after the code block'
|
||||||
|
});
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
const root = editableIn(container);
|
||||||
|
root.focus();
|
||||||
|
setSelection(root, (range) => {
|
||||||
|
const text = root.childNodes[1];
|
||||||
|
range.setStart(text, (text.textContent ?? '').length);
|
||||||
|
range.collapse(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
await userEvent.keyboard('{Shift>}{Enter}{/Shift}');
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
const selection = window.getSelection();
|
||||||
|
expect(rangeToTextOffset(root, selection!.getRangeAt(0))).toBe(
|
||||||
|
(BLOCK_SOURCE + '\ntext after the code block\n').length
|
||||||
|
);
|
||||||
|
expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\ntext after the code block\n\n');
|
||||||
|
|
||||||
|
// the next typed character lands on the new line
|
||||||
|
await userEvent.keyboard('x');
|
||||||
|
await tick();
|
||||||
|
expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\ntext after the code block\nx');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets Backspace at the text start move into the block without a source fight', async () => {
|
||||||
|
const { container } = render(ChatFormContenteditable, { value: BLOCK_SOURCE });
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
const root = editableIn(container);
|
||||||
|
root.focus();
|
||||||
|
placeCaretInBlock(root, 'end');
|
||||||
|
|
||||||
|
await userEvent.keyboard('{ArrowDown}');
|
||||||
|
await userEvent.keyboard('create');
|
||||||
|
await tick();
|
||||||
|
expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\ncreate');
|
||||||
|
|
||||||
|
// Backspace at the start of the text line: the separator newline
|
||||||
|
// is structural (synthesized while text follows the block), so
|
||||||
|
// the caret just moves to the block's edge - nothing is re-added
|
||||||
|
await userEvent.keyboard('{Home}');
|
||||||
|
await userEvent.keyboard('{Backspace}');
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\ncreate');
|
||||||
|
expect(caretContainer()).toBe(root);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets forward Delete eat the text after a block normally', async () => {
|
||||||
|
const { container } = render(ChatFormContenteditable, { value: BLOCK_SOURCE });
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
const root = editableIn(container);
|
||||||
|
root.focus();
|
||||||
|
placeCaretInBlock(root, 'end');
|
||||||
|
|
||||||
|
await userEvent.keyboard('{ArrowDown}');
|
||||||
|
await userEvent.keyboard('create');
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
await userEvent.keyboard('{Home}');
|
||||||
|
await userEvent.keyboard('{Delete}');
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\nreate');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders text after a block without a phantom empty line', async () => {
|
||||||
|
const { container } = render(ChatFormContenteditable, {
|
||||||
|
value: BLOCK_SOURCE + '\nhello'
|
||||||
|
});
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
const root = editableIn(container);
|
||||||
|
expect(root.textContent).toBe(BLOCK_SOURCE + 'hello');
|
||||||
|
expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\nhello');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps an intentional blank line after a block out of the separator', async () => {
|
||||||
|
const { container } = render(ChatFormContenteditable, {
|
||||||
|
value: BLOCK_SOURCE + '\n\nhello'
|
||||||
|
});
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
const root = editableIn(container);
|
||||||
|
expect(root.textContent).toBe(BLOCK_SOURCE + '\nhello');
|
||||||
|
expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\n\nhello');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-highlights while typing inside a block and keeps the caret', async () => {
|
||||||
|
const { container } = render(ChatFormContenteditable, { value: BLOCK_SOURCE });
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
const root = editableIn(container);
|
||||||
|
root.focus();
|
||||||
|
|
||||||
|
// caret at the start of the block content (after the opening fence)
|
||||||
|
setSelection(root, (range) => {
|
||||||
|
const target = textOffsetToRange(root, 6);
|
||||||
|
range.setStart(target.startContainer, target.startOffset);
|
||||||
|
range.collapse(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
await userEvent.keyboard('x');
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
const code = blockIn(root);
|
||||||
|
expect(serializeContent(root)).toBe('```js\nxconst a = 1;\n```');
|
||||||
|
expect(code.textContent).toBe('```js\nxconst a = 1;\n```');
|
||||||
|
expect(code.querySelector('.hljs-number')).not.toBeNull();
|
||||||
|
|
||||||
|
const selection = window.getSelection();
|
||||||
|
expect(code.contains(selection!.getRangeAt(0).startContainer)).toBe(true);
|
||||||
|
expect(rangeToTextOffset(root, selection!.getRangeAt(0))).toBe(7);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ChatFormContenteditable Enter in code blocks', () => {
|
||||||
|
const BLOCK_SOURCE = '```js\nconst a = 1;\n```';
|
||||||
|
|
||||||
|
it('adds a line instead of submitting on plain Enter inside a block', async () => {
|
||||||
|
const onKeydown = vi.fn();
|
||||||
|
const { container } = render(ChatFormContenteditable, {
|
||||||
|
value: BLOCK_SOURCE,
|
||||||
|
onKeydown
|
||||||
|
});
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
const root = editableIn(container);
|
||||||
|
root.focus();
|
||||||
|
|
||||||
|
// caret at the start of the block content (after the opening fence)
|
||||||
|
setSelection(root, (range) => {
|
||||||
|
const target = textOffsetToRange(root, 6);
|
||||||
|
range.setStart(target.startContainer, target.startOffset);
|
||||||
|
range.collapse(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
await userEvent.keyboard('{Enter}');
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
// consumed locally: the parent's submit handler never sees it
|
||||||
|
expect(onKeydown).not.toHaveBeenCalled();
|
||||||
|
expect(serializeContent(root)).toBe('```js\n\nconst a = 1;\n```');
|
||||||
|
|
||||||
|
const code = root.querySelector('code[data-code-token="block"]');
|
||||||
|
const selection = window.getSelection();
|
||||||
|
expect(code!.contains(selection!.getRangeAt(0).startContainer)).toBe(true);
|
||||||
|
expect(rangeToTextOffset(root, selection!.getRangeAt(0))).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds a line after a still-open fence (no closing ``` yet)', async () => {
|
||||||
|
const onKeydown = vi.fn();
|
||||||
|
const { container } = render(ChatFormContenteditable, {
|
||||||
|
value: '```js\nconst a = 1;',
|
||||||
|
onKeydown
|
||||||
|
});
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
const root = editableIn(container);
|
||||||
|
root.focus();
|
||||||
|
|
||||||
|
// caret at the start of the block content (after the opening fence)
|
||||||
|
setSelection(root, (range) => {
|
||||||
|
const target = textOffsetToRange(root, 6);
|
||||||
|
range.setStart(target.startContainer, target.startOffset);
|
||||||
|
range.collapse(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
await userEvent.keyboard('{Enter}');
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
expect(onKeydown).not.toHaveBeenCalled();
|
||||||
|
expect(serializeContent(root)).toBe('```js\n\nconst a = 1;');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forwards plain Enter to the parent when the caret is outside a block', async () => {
|
||||||
|
const onKeydown = vi.fn();
|
||||||
|
const { container } = render(ChatFormContenteditable, {
|
||||||
|
value: BLOCK_SOURCE + '\nafter',
|
||||||
|
onKeydown
|
||||||
|
});
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
const root = editableIn(container);
|
||||||
|
root.focus();
|
||||||
|
setSelection(root, (range) => {
|
||||||
|
range.selectNodeContents(root);
|
||||||
|
range.collapse(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
await userEvent.keyboard('{Enter}');
|
||||||
|
|
||||||
|
expect(onKeydown).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onKeydown.mock.calls[0][0].defaultPrevented).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forwards plain Enter on the trailing hatch line after a block', async () => {
|
||||||
|
const onKeydown = vi.fn();
|
||||||
|
const { container } = render(ChatFormContenteditable, {
|
||||||
|
value: BLOCK_SOURCE,
|
||||||
|
onKeydown
|
||||||
|
});
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
const root = editableIn(container);
|
||||||
|
root.focus();
|
||||||
|
// root-level caret between the block and its trailing br hatch
|
||||||
|
setSelection(root, (range) => {
|
||||||
|
range.setStart(root, 1);
|
||||||
|
range.collapse(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
await userEvent.keyboard('{Enter}');
|
||||||
|
|
||||||
|
expect(onKeydown).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forwards Ctrl+Enter inside a block so explicit submit survives', async () => {
|
||||||
|
const onKeydown = vi.fn();
|
||||||
|
const { container } = render(ChatFormContenteditable, {
|
||||||
|
value: BLOCK_SOURCE,
|
||||||
|
onKeydown
|
||||||
|
});
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
const root = editableIn(container);
|
||||||
|
root.focus();
|
||||||
|
setSelection(root, (range) => {
|
||||||
|
const target = textOffsetToRange(root, 6);
|
||||||
|
range.setStart(target.startContainer, target.startOffset);
|
||||||
|
range.collapse(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
await userEvent.keyboard('{Control>}{Enter}{/Control}');
|
||||||
|
|
||||||
|
expect(onKeydown).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ key: 'Enter', ctrlKey: true })
|
||||||
|
);
|
||||||
|
expect(serializeContent(root)).toBe(BLOCK_SOURCE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forwards Enter inside an inline code span', async () => {
|
||||||
|
const onKeydown = vi.fn();
|
||||||
|
const { container } = render(ChatFormContenteditable, {
|
||||||
|
value: 'run `npm test` now',
|
||||||
|
onKeydown
|
||||||
|
});
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
const root = editableIn(container);
|
||||||
|
root.focus();
|
||||||
|
const code = root.querySelector('code[data-code-token="inline"]')!;
|
||||||
|
setSelection(root, (range) => {
|
||||||
|
range.setStart(code.firstChild!, 3);
|
||||||
|
range.collapse(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
await userEvent.keyboard('{Enter}');
|
||||||
|
|
||||||
|
expect(onKeydown).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
// Guards the Enter-key contract of the chat form against the
|
||||||
|
// fenced-code-block flow: while the caret sits inside a fenced
|
||||||
|
// block region - closed, or still OPEN while the user is typing
|
||||||
|
// one - plain Enter adds a line instead of submitting the message.
|
||||||
|
// The textarea path is covered here end-to-end (the contenteditable
|
||||||
|
// consumes the same case locally; see chat-form-contenteditable).
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render } from 'vitest-browser-svelte';
|
||||||
|
import { userEvent } from 'vitest/browser';
|
||||||
|
import { tick } from 'svelte';
|
||||||
|
import { SETTINGS_KEYS } from '$lib/constants/settings-keys';
|
||||||
|
import { settingsStore } from '$lib/stores/settings.svelte';
|
||||||
|
import ChatFormTestWrapper from './components/ChatFormTestWrapper.svelte';
|
||||||
|
|
||||||
|
function textareaIn(container: HTMLElement): HTMLTextAreaElement {
|
||||||
|
const el = container.querySelector('textarea');
|
||||||
|
if (!(el instanceof HTMLTextAreaElement)) throw new Error('textarea not rendered');
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ChatForm Enter in code blocks', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
settingsStore.updateConfig(SETTINGS_KEYS.SEND_ON_ENTER, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds a line after a still-open fence instead of submitting', async () => {
|
||||||
|
const onSubmit = vi.fn();
|
||||||
|
const { container } = render(ChatFormTestWrapper, { onSubmit });
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
const textarea = textareaIn(container);
|
||||||
|
await userEvent.click(textarea);
|
||||||
|
await userEvent.keyboard('```');
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
await userEvent.keyboard('{Enter}');
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
expect(onSubmit).not.toHaveBeenCalled();
|
||||||
|
expect(textarea.value).toBe('```\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps adding lines while the block stays open', async () => {
|
||||||
|
const onSubmit = vi.fn();
|
||||||
|
const { container } = render(ChatFormTestWrapper, { onSubmit });
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
const textarea = textareaIn(container);
|
||||||
|
await userEvent.click(textarea);
|
||||||
|
await userEvent.keyboard('```js');
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
await userEvent.keyboard('{Enter}');
|
||||||
|
await userEvent.keyboard('const a = 1;');
|
||||||
|
await userEvent.keyboard('{Enter}');
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
expect(onSubmit).not.toHaveBeenCalled();
|
||||||
|
expect(textarea.value).toBe('```js\nconst a = 1;\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('submits when the caret is before the opening fence', async () => {
|
||||||
|
const onSubmit = vi.fn();
|
||||||
|
const { container } = render(ChatFormTestWrapper, { onSubmit });
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
const textarea = textareaIn(container);
|
||||||
|
await userEvent.click(textarea);
|
||||||
|
await userEvent.keyboard('```');
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
textarea.setSelectionRange(0, 0);
|
||||||
|
|
||||||
|
await userEvent.keyboard('{Enter}');
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('submits on Enter outside a code block', async () => {
|
||||||
|
const onSubmit = vi.fn();
|
||||||
|
const { container } = render(ChatFormTestWrapper, { onSubmit });
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
const textarea = textareaIn(container);
|
||||||
|
await userEvent.click(textarea);
|
||||||
|
await userEvent.keyboard('hello');
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
await userEvent.keyboard('{Enter}');
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('submits on Ctrl+Enter even inside a code block', async () => {
|
||||||
|
const onSubmit = vi.fn();
|
||||||
|
const { container } = render(ChatFormTestWrapper, { onSubmit });
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
const textarea = textareaIn(container);
|
||||||
|
await userEvent.click(textarea);
|
||||||
|
await userEvent.keyboard('```');
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
await userEvent.keyboard('{Control>}{Enter}{/Control}');
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { untrack } from 'svelte';
|
||||||
|
import ChatFormContenteditable from '$lib/components/app/chat/ChatForm/ChatFormContenteditable.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { value: initial = '' }: Props = $props();
|
||||||
|
|
||||||
|
let value = $state(untrack(() => initial));
|
||||||
|
let inputRef: ChatFormContenteditable | undefined = $state(undefined);
|
||||||
|
|
||||||
|
export function getValue() {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCaretOffset() {
|
||||||
|
return inputRef?.getCaretOffset();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setCaretOffset(offset: number) {
|
||||||
|
inputRef?.setCaretOffset(offset);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ChatFormContenteditable bind:this={inputRef} bind:value />
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||||
|
import ChatForm from '$lib/components/app/chat/ChatForm/ChatForm.svelte';
|
||||||
|
|
||||||
|
let { onSubmit }: { onSubmit?: () => void } = $props();
|
||||||
|
|
||||||
|
let value = $state('');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Tooltip.Provider>
|
||||||
|
<ChatForm bind:value {onSubmit} />
|
||||||
|
</Tooltip.Provider>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { highlightCode, trimCodePadding } from '$lib/utils/code';
|
import { highlightCode, splitGluedClosingCodeFences, trimCodePadding } from '$lib/utils/code';
|
||||||
|
|
||||||
describe('trimCodePadding', () => {
|
describe('trimCodePadding', () => {
|
||||||
it('removes a single leading newline', () => {
|
it('removes a single leading newline', () => {
|
||||||
@@ -101,3 +101,38 @@ describe('highlightCode', () => {
|
|||||||
expect(html).toBe('<script>a && b</script>');
|
expect(html).toBe('<script>a && b</script>');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('splitGluedClosingCodeFences', () => {
|
||||||
|
it('splits text glued to a closing fence onto its own line', () => {
|
||||||
|
const input = "```ts\nlet foo = 'bar';\n```create this file on [Desktop](file:///a/b/)";
|
||||||
|
expect(splitGluedClosingCodeFences(input)).toBe(
|
||||||
|
"```ts\nlet foo = 'bar';\n```\ncreate this file on [Desktop](file:///a/b/)"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves a well-formed code block untouched', () => {
|
||||||
|
const input = "```ts\nlet foo = 'bar';\n```\ncreate this file on [Desktop](file:///a/b/)";
|
||||||
|
expect(splitGluedClosingCodeFences(input)).toBe(input);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves content without fences untouched', () => {
|
||||||
|
expect(splitGluedClosingCodeFences('hello world')).toBe('hello world');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps nested markdown fences inside a block intact', () => {
|
||||||
|
const input = '```md\n# Example\n```python\nprint(1)\n```\n```';
|
||||||
|
expect(splitGluedClosingCodeFences(input)).toBe(input);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('splits every glued closing fence when several blocks are present', () => {
|
||||||
|
const input = '```ts\na\n```first words\n\n```js\nb\n```second words';
|
||||||
|
expect(splitGluedClosingCodeFences(input)).toBe(
|
||||||
|
'```ts\na\n```\nfirst words\n\n```js\nb\n```\nsecond words'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves a still-open fence untouched', () => {
|
||||||
|
const input = '```ts\nlet foo = 1;';
|
||||||
|
expect(splitGluedClosingCodeFences(input)).toBe(input);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { containsCodeSpan, isOffsetInCodeBlock, tokenizeContent } from '$lib/utils';
|
||||||
|
|
||||||
|
describe('tokenizeContent', () => {
|
||||||
|
it('tokenizes a plain text buffer with no badges', () => {
|
||||||
|
expect(tokenizeContent('hello world')).toEqual([{ kind: 'text', text: 'hello world' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tokenizes a single badge', () => {
|
||||||
|
expect(tokenizeContent('[docs](file:///a/b)')).toEqual([
|
||||||
|
{ kind: 'badge', name: 'docs', path: '/a/b' }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tokenizes text around a single badge', () => {
|
||||||
|
expect(tokenizeContent('hello [docs](file:///a/b) world')).toEqual([
|
||||||
|
{ kind: 'text', text: 'hello ' },
|
||||||
|
{ kind: 'badge', name: 'docs', path: '/a/b' },
|
||||||
|
{ kind: 'text', text: ' world' }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tokenizes adjacent badges as separate tokens', () => {
|
||||||
|
expect(tokenizeContent('[a](file:///x)[b](file:///y)')).toEqual([
|
||||||
|
{ kind: 'badge', name: 'a', path: '/x' },
|
||||||
|
{ kind: 'badge', name: 'b', path: '/y' }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves non-file links untouched in the stream', () => {
|
||||||
|
expect(tokenizeContent('see [foo](https://example.com) for details')).toEqual([
|
||||||
|
{ kind: 'text', text: 'see [foo](https://example.com) for details' }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recognizes badges whose path contains spaces (macOS screenshots)', () => {
|
||||||
|
const path = '/Users/allozaur/Desktop/Screenshot 2026-07-28 at 17.21.50.png';
|
||||||
|
const source = `[Screenshot 2026-07-28 at 17.21.50.png](file://${path}) `;
|
||||||
|
expect(tokenizeContent(source)).toEqual([
|
||||||
|
{ kind: 'badge', name: 'Screenshot 2026-07-28 at 17.21.50.png', path },
|
||||||
|
{ kind: 'text', text: ' ' }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recognizes badges whose path lives in the macOS temp folder', () => {
|
||||||
|
const path =
|
||||||
|
'/var/folders/78/j28m7pn57wb34bfjwlskh62h0000gn/T/TemporaryItems/NSIRD_screencaptureui_GD0A2R/Screenshot 2026-07-28 at 17.23.28.png';
|
||||||
|
const source = `[Screenshot 2026-07-28 at 17.23.28.png](file://${path}) `;
|
||||||
|
expect(tokenizeContent(source)).toEqual([
|
||||||
|
{ kind: 'badge', name: 'Screenshot 2026-07-28 at 17.23.28.png', path },
|
||||||
|
{ kind: 'text', text: ' ' }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps text around a badge with spaces in the path', () => {
|
||||||
|
const path = '/Users/allozaur/Desktop/Screenshot 2026-07-28 at 17.21.50.png';
|
||||||
|
const source = `see [Screenshot 2026-07-28 at 17.21.50.png](file://${path}) done`;
|
||||||
|
expect(tokenizeContent(source)).toEqual([
|
||||||
|
{ kind: 'text', text: 'see ' },
|
||||||
|
{ kind: 'badge', name: 'Screenshot 2026-07-28 at 17.21.50.png', path },
|
||||||
|
{ kind: 'text', text: ' done' }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recognizes badges whose path contains a close parenthesis (macOS duplicate files)', () => {
|
||||||
|
const path = '/Users/foo/Screenshot (1).png';
|
||||||
|
const source = `[Screenshot (1).png](file://${path}) `;
|
||||||
|
expect(tokenizeContent(source)).toEqual([
|
||||||
|
{ kind: 'badge', name: 'Screenshot (1).png', path },
|
||||||
|
{ kind: 'text', text: ' ' }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recognizes badges whose folder name is wrapped in parentheses', () => {
|
||||||
|
const path = '/Users/foo/Project (Stuff)/main.rs';
|
||||||
|
const source = `[main.rs](file://${path}) `;
|
||||||
|
expect(tokenizeContent(source)).toEqual([
|
||||||
|
{ kind: 'badge', name: 'main.rs', path },
|
||||||
|
{ kind: 'text', text: ' ' }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recognizes adjacent badges back-to-back with no separator', () => {
|
||||||
|
const source = '[a](file:///p)[b](file:///q)';
|
||||||
|
expect(tokenizeContent(source)).toEqual([
|
||||||
|
{ kind: 'badge', name: 'a', path: '/p' },
|
||||||
|
{ kind: 'badge', name: 'b', path: '/q' }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tokenizes inline code with the backticks included', () => {
|
||||||
|
expect(tokenizeContent('run `npm test` now')).toEqual([
|
||||||
|
{ kind: 'text', text: 'run ' },
|
||||||
|
{ kind: 'inlineCode', text: '`npm test`' },
|
||||||
|
{ kind: 'text', text: ' now' }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tokenizes a fenced code block without a language', () => {
|
||||||
|
const source = 'before\n```\nconst a = 1;\n```\nafter';
|
||||||
|
expect(tokenizeContent(source)).toEqual([
|
||||||
|
{ kind: 'text', text: 'before\n' },
|
||||||
|
{ kind: 'codeBlock', text: '```\nconst a = 1;\n```' },
|
||||||
|
{ kind: 'text', text: '\nafter' }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tokenizes a fenced code block with a language', () => {
|
||||||
|
const source = '```js\nconst a = 1;\n```';
|
||||||
|
expect(tokenizeContent(source)).toEqual([
|
||||||
|
{ kind: 'codeBlock', text: '```js\nconst a = 1;\n```' }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers the fenced block over inline spans at triple backticks', () => {
|
||||||
|
expect(tokenizeContent('```a``` ```b```')).toEqual([
|
||||||
|
{ kind: 'codeBlock', text: '```a```' },
|
||||||
|
{ kind: 'text', text: ' ' },
|
||||||
|
{ kind: 'codeBlock', text: '```b```' }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves an unclosed fence as plain text', () => {
|
||||||
|
expect(tokenizeContent('```js\nconst a = 1;')).toEqual([
|
||||||
|
{ kind: 'text', text: '```js\nconst a = 1;' }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves an unclosed inline backtick as plain text', () => {
|
||||||
|
expect(tokenizeContent('run `npm test')).toEqual([{ kind: 'text', text: 'run `npm test' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not recognize badges inside code spans', () => {
|
||||||
|
expect(tokenizeContent('`[a](file:///p)`')).toEqual([
|
||||||
|
{ kind: 'inlineCode', text: '`[a](file:///p)`' }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tokenizes badges and code spans side by side', () => {
|
||||||
|
expect(tokenizeContent('[a](file:///p) `x`')).toEqual([
|
||||||
|
{ kind: 'badge', name: 'a', path: '/p' },
|
||||||
|
{ kind: 'text', text: ' ' },
|
||||||
|
{ kind: 'inlineCode', text: '`x`' }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('containsCodeSpan', () => {
|
||||||
|
it('detects inline code', () => {
|
||||||
|
expect(containsCodeSpan('run `npm test` now')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects a fenced block with a language', () => {
|
||||||
|
expect(containsCodeSpan('```js\nconst a = 1;\n```')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects a fenced block without a language', () => {
|
||||||
|
expect(containsCodeSpan('```\ncode\n```')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores unclosed fences and lone backticks', () => {
|
||||||
|
expect(containsCodeSpan('```js\nconst a = 1;')).toBe(false);
|
||||||
|
expect(containsCodeSpan('run `npm test')).toBe(false);
|
||||||
|
expect(containsCodeSpan('``')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores plain text and mention links', () => {
|
||||||
|
expect(containsCodeSpan('hello world')).toBe(false);
|
||||||
|
expect(containsCodeSpan('[a](file:///p)')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isOffsetInCodeBlock', () => {
|
||||||
|
const BLOCK = '```js\nconst a = 1;\n```';
|
||||||
|
|
||||||
|
it('is false with no fences in the buffer', () => {
|
||||||
|
expect(isOffsetInCodeBlock('hello world', 5)).toBe(false);
|
||||||
|
expect(isOffsetInCodeBlock('run `npm test` now', 10)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is true right after the opening fence, before any content', () => {
|
||||||
|
expect(isOffsetInCodeBlock('```', 3)).toBe(true);
|
||||||
|
expect(isOffsetInCodeBlock('```js', 5)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is true inside a still-open block while it is being typed', () => {
|
||||||
|
const open = '```js\nconst a = 1;';
|
||||||
|
expect(isOffsetInCodeBlock(open, open.length)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is true inside a closed block and false outside it', () => {
|
||||||
|
expect(isOffsetInCodeBlock(BLOCK, 6)).toBe(true);
|
||||||
|
expect(isOffsetInCodeBlock(BLOCK, 0)).toBe(false);
|
||||||
|
expect(isOffsetInCodeBlock(BLOCK, BLOCK.length)).toBe(false);
|
||||||
|
expect(isOffsetInCodeBlock(BLOCK + '\nafter', BLOCK.length + 5)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toggles per fence across multiple blocks', () => {
|
||||||
|
const two = BLOCK + '\ntext\n' + BLOCK;
|
||||||
|
const secondBlock = two.lastIndexOf(BLOCK);
|
||||||
|
expect(isOffsetInCodeBlock(two, secondBlock - 2)).toBe(false);
|
||||||
|
expect(isOffsetInCodeBlock(two, secondBlock + 6)).toBe(true);
|
||||||
|
expect(isOffsetInCodeBlock(two, two.length)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { badgeAwareWordJump, leadingBadgeEdgeOffset } from '$lib/utils';
|
||||||
|
|
||||||
|
// Layout of `hello [docs](file:///a/b) world foo`:
|
||||||
|
// "hello" 0-4, " " 5, badge 6-24 (length 19), " " 25, "world" 26-30, " " 31, "foo" 32-34
|
||||||
|
const BADGE = '[docs](file:///a/b)';
|
||||||
|
const SOURCE = `hello ${BADGE} world foo`;
|
||||||
|
const BADGE_START = 6;
|
||||||
|
const BADGE_END = 25;
|
||||||
|
|
||||||
|
describe('badgeAwareWordJump', () => {
|
||||||
|
it('returns null when the buffer has no badge', () => {
|
||||||
|
expect(badgeAwareWordJump('hello world', 0, 'forward')).toBeNull();
|
||||||
|
expect(badgeAwareWordJump('hello world', 11, 'backward')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('jumps forward onto a badge landing at its end, not the next word', () => {
|
||||||
|
expect(badgeAwareWordJump(SOURCE, BADGE_START, 'forward')).toBe(BADGE_END);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('jumps forward from the space before a badge landing at its end', () => {
|
||||||
|
expect(badgeAwareWordJump(SOURCE, BADGE_START - 1, 'forward')).toBe(BADGE_END);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('jumps backward over a badge landing at its start', () => {
|
||||||
|
expect(badgeAwareWordJump(SOURCE, BADGE_END, 'backward')).toBe(BADGE_START);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('jumps backward from the next word onto the badge start', () => {
|
||||||
|
// caret at the start of "world"
|
||||||
|
expect(badgeAwareWordJump(SOURCE, BADGE_END + 1, 'backward')).toBe(BADGE_START);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for jumps that cross no badge', () => {
|
||||||
|
// forward over "hello" only
|
||||||
|
expect(badgeAwareWordJump(SOURCE, 0, 'forward')).toBeNull();
|
||||||
|
// backward over "foo" only
|
||||||
|
expect(badgeAwareWordJump(SOURCE, SOURCE.length, 'backward')).toBeNull();
|
||||||
|
// backward away from the badge (over "hello")
|
||||||
|
expect(badgeAwareWordJump(SOURCE, BADGE_START, 'backward')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats a leading badge as one word in both directions', () => {
|
||||||
|
const source = `${BADGE} rest`;
|
||||||
|
expect(badgeAwareWordJump(source, 0, 'forward')).toBe(BADGE.length);
|
||||||
|
expect(badgeAwareWordJump(source, BADGE.length, 'backward')).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats adjacent badges as separate words', () => {
|
||||||
|
// each badge is 14 chars: "[a](file:///x)" / "[b](file:///y)"
|
||||||
|
const source = '[a](file:///x)[b](file:///y)';
|
||||||
|
expect(badgeAwareWordJump(source, 0, 'forward')).toBe(14);
|
||||||
|
expect(badgeAwareWordJump(source, 14, 'forward')).toBe(28);
|
||||||
|
expect(badgeAwareWordJump(source, 28, 'backward')).toBe(14);
|
||||||
|
expect(badgeAwareWordJump(source, 14, 'backward')).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('jumps over a badge following punctuation', () => {
|
||||||
|
// "foo," 0-3, " " 4, badge 5-23 (end 24), " bar" 24-27
|
||||||
|
const source = `foo, ${BADGE} bar`;
|
||||||
|
expect(badgeAwareWordJump(source, 0, 'forward')).toBeNull();
|
||||||
|
expect(badgeAwareWordJump(source, 3, 'forward')).toBe(24);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('leadingBadgeEdgeOffset', () => {
|
||||||
|
it('returns 0 when the caret sits exactly at a leading badge end', () => {
|
||||||
|
expect(leadingBadgeEdgeOffset(`${BADGE} rest`, BADGE.length)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when the caret is anywhere else', () => {
|
||||||
|
expect(leadingBadgeEdgeOffset(`${BADGE} rest`, 0)).toBeNull();
|
||||||
|
expect(leadingBadgeEdgeOffset(`${BADGE} rest`, BADGE.length + 2)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when the buffer does not start with a badge', () => {
|
||||||
|
expect(leadingBadgeEdgeOffset(SOURCE, BADGE_END)).toBeNull();
|
||||||
|
expect(leadingBadgeEdgeOffset('', 0)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,12 +3,12 @@ import {
|
|||||||
MENTION_BADGE_FILE_ICON_PATHS,
|
MENTION_BADGE_FILE_ICON_PATHS,
|
||||||
MENTION_BADGE_FOLDER_ICON_PATHS,
|
MENTION_BADGE_FOLDER_ICON_PATHS,
|
||||||
buildMentionInsertion,
|
buildMentionInsertion,
|
||||||
|
containsFileMentionLink,
|
||||||
decodeFileLinkPath,
|
decodeFileLinkPath,
|
||||||
encodeFileLinkPath,
|
encodeFileLinkPath,
|
||||||
fileMentionLinkRe,
|
fileMentionLinkRe,
|
||||||
getMentionBadgeIconPaths,
|
getMentionBadgeIconPaths,
|
||||||
getMentionBadgeLabel,
|
getMentionBadgeLabel
|
||||||
mentionLinkEndingAt
|
|
||||||
} from '$lib/utils';
|
} from '$lib/utils';
|
||||||
import { FileMentionEntryType } from '$lib/enums';
|
import { FileMentionEntryType } from '$lib/enums';
|
||||||
|
|
||||||
@@ -35,6 +35,7 @@ describe('encodeFileLinkPath', () => {
|
|||||||
describe('fileMentionLinkRe', () => {
|
describe('fileMentionLinkRe', () => {
|
||||||
it('matches a standard mention link', () => {
|
it('matches a standard mention link', () => {
|
||||||
expect(fileMentionLinkRe().test('[docs](file:///a/b)')).toBe(true);
|
expect(fileMentionLinkRe().test('[docs](file:///a/b)')).toBe(true);
|
||||||
|
expect(containsFileMentionLink('[docs](file:///a/b)')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not match non-file links', () => {
|
it('does not match non-file links', () => {
|
||||||
@@ -171,32 +172,3 @@ describe('buildMentionInsertion', () => {
|
|||||||
expect(buildMentionInsertion(file('/a/b.txt', 'b.txt'), 'x', { start: 2, end: 1 })).toBeNull();
|
expect(buildMentionInsertion(file('/a/b.txt', 'b.txt'), 'x', { start: 2, end: 1 })).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('mentionLinkEndingAt', () => {
|
|
||||||
const LINK = '[docs](file:///a/b)';
|
|
||||||
|
|
||||||
it('returns the extent when the caret is exactly at the link end', () => {
|
|
||||||
expect(mentionLinkEndingAt(`see ${LINK} here`, 4 + LINK.length)).toEqual({
|
|
||||||
start: 4,
|
|
||||||
end: 4 + LINK.length
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null when the caret is inside or past the link', () => {
|
|
||||||
expect(mentionLinkEndingAt(LINK, LINK.length - 1)).toBeNull();
|
|
||||||
expect(mentionLinkEndingAt(`${LINK} `, LINK.length + 1)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null for non-file links and plain text', () => {
|
|
||||||
expect(mentionLinkEndingAt('[foo](https://example.com)', 26)).toBeNull();
|
|
||||||
expect(mentionLinkEndingAt('plain', 5)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('picks the link that ends at the caret when several exist', () => {
|
|
||||||
const value = `${LINK} and ${LINK}`;
|
|
||||||
expect(mentionLinkEndingAt(value, value.length)).toEqual({
|
|
||||||
start: value.length - LINK.length,
|
|
||||||
end: value.length
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { SourceHistory } from '$lib/utils';
|
||||||
|
|
||||||
|
describe('SourceHistory', () => {
|
||||||
|
it('coalesces pushes inside the group window into one undo step', () => {
|
||||||
|
const h = new SourceHistory(100, 800);
|
||||||
|
h.push({ value: '', caret: 0 }, 1000);
|
||||||
|
h.push({ value: 'a', caret: 1 }, 1200);
|
||||||
|
h.push({ value: 'ab', caret: 2 }, 1500);
|
||||||
|
|
||||||
|
expect(h.undo({ value: 'abc', caret: 3 })).toEqual({ value: '', caret: 0 });
|
||||||
|
expect(h.undo({ value: '', caret: 0 })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts a new group once the window has passed', () => {
|
||||||
|
const h = new SourceHistory(100, 800);
|
||||||
|
h.push({ value: '', caret: 0 }, 1000);
|
||||||
|
h.push({ value: 'abc', caret: 3 }, 2000);
|
||||||
|
|
||||||
|
expect(h.undo({ value: 'abcdef', caret: 6 })).toEqual({ value: 'abc', caret: 3 });
|
||||||
|
expect(h.undo({ value: 'abc', caret: 3 })).toEqual({ value: '', caret: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('newGroup forces a separate entry even inside the window', () => {
|
||||||
|
const h = new SourceHistory(100, 800);
|
||||||
|
h.push({ value: '', caret: 0 }, 1000);
|
||||||
|
h.push({ value: 'abc', caret: 3 }, 1100, true);
|
||||||
|
|
||||||
|
expect(h.undo({ value: 'abc\n', caret: 4 })).toEqual({ value: 'abc', caret: 3 });
|
||||||
|
expect(h.undo({ value: 'abc', caret: 3 })).toEqual({ value: '', caret: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redo round-trips and a fresh push clears the redo stack', () => {
|
||||||
|
const h = new SourceHistory(100, 800);
|
||||||
|
h.push({ value: '', caret: 0 }, 1000);
|
||||||
|
|
||||||
|
const undone = h.undo({ value: 'abc', caret: 3 });
|
||||||
|
expect(undone).toEqual({ value: '', caret: 0 });
|
||||||
|
expect(h.redo({ value: '', caret: 0 })).toEqual({ value: 'abc', caret: 3 });
|
||||||
|
|
||||||
|
h.undo({ value: 'abc', caret: 3 });
|
||||||
|
h.push({ value: '', caret: 0 }, 5000);
|
||||||
|
expect(h.redo({ value: 'x', caret: 1 })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts a new group on the first edit after an undo', () => {
|
||||||
|
const h = new SourceHistory(100, 800);
|
||||||
|
h.push({ value: '', caret: 0 }, 1000);
|
||||||
|
h.undo({ value: 'abc', caret: 3 });
|
||||||
|
|
||||||
|
h.push({ value: '', caret: 0 }, 1200);
|
||||||
|
expect(h.undo({ value: 'x', caret: 1 })).toEqual({ value: '', caret: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('evicts the oldest entry past the limit', () => {
|
||||||
|
const h = new SourceHistory(2, 800);
|
||||||
|
h.push({ value: 'one', caret: 0 }, 1000);
|
||||||
|
h.push({ value: 'two', caret: 0 }, 2000);
|
||||||
|
h.push({ value: 'three', caret: 0 }, 3000);
|
||||||
|
|
||||||
|
expect(h.undo({ value: 'cur', caret: 0 })).toEqual({ value: 'three', caret: 0 });
|
||||||
|
expect(h.undo({ value: 'three', caret: 0 })).toEqual({ value: 'two', caret: 0 });
|
||||||
|
expect(h.undo({ value: 'two', caret: 0 })).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user