// 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 ChatFormContentEditable from '$lib/components/app/chat/ChatForm/ChatFormContentEditable.svelte'; import { rangeToTextOffset, serializeContent, textOffsetToRange } from '$lib/utils'; import { tick } from 'svelte'; import { describe, expect, it, vi } from 'vitest'; import { userEvent } from 'vitest/browser'; import { render } from 'vitest-browser-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, { bubbles: true, cancelable: true, clipboardData: data }); return { data, event }; } 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 { data, event } = 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 { data, event } = 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 { data, event } = 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 { data, event } = 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', { bubbles: true, inputType: 'insertLineBreak' })); 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 //
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', { bubbles: true, inputType: 'insertLineBreak' })); 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, { onKeydown, 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('{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, { onKeydown, value: '```js\nconst a = 1;' }); 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, { onKeydown, value: BLOCK_SOURCE + '\nafter' }); 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, { onKeydown, value: BLOCK_SOURCE }); 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, { onKeydown, value: BLOCK_SOURCE }); 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({ ctrlKey: true, key: 'Enter' }) ); expect(serializeContent(root)).toBe(BLOCK_SOURCE); }); it('forwards Enter inside an inline code span', async () => { const onKeydown = vi.fn(); const { container } = render(ChatFormContentEditable, { onKeydown, value: 'run `npm test` now' }); 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); }); });