diff --git a/src/markup/codemirror/create.ts b/src/markup/codemirror/create.ts index 8396cf9b..0272afb8 100644 --- a/src/markup/codemirror/create.ts +++ b/src/markup/codemirror/create.ts @@ -46,6 +46,7 @@ import {MarkdownConverter} from './html-to-markdown/converters'; import {PairingCharactersExtension} from './pairing-chars'; import {ReactRendererFacet} from './react-facet'; import {SearchPanelPlugin} from './search-plugin/plugin'; +import {smartReindent} from './smart-reindent'; import {type YfmLangOptions, yfmLang} from './yfm'; export type {YfmLangOptions}; @@ -162,12 +163,17 @@ export function createCodemirror(params: CreateCodemirrorParams) { paste(event, editor) { if (!event.clipboardData) return; + const {from} = editor.state.selection.main; + const line = editor.state.doc.lineAt(from); + const currentLine = line.text; + // if clipboard contains YFM content - avoid any meddling with pasted content // since text/yfm will contain valid markdown const yfmContent = event.clipboardData.getData(DataTransferType.Yfm); if (yfmContent) { event.preventDefault(); - editor.dispatch(editor.state.replaceSelection(yfmContent)); + const reindentedYfmContent = smartReindent(yfmContent, currentLine); + editor.dispatch(editor.state.replaceSelection(reindentedYfmContent)); return; } @@ -195,7 +201,11 @@ export function createCodemirror(params: CreateCodemirrorParams) { if (parsedMarkdownMarkup !== undefined) { event.preventDefault(); - editor.dispatch(editor.state.replaceSelection(parsedMarkdownMarkup)); + const reindentedHtmlContent = smartReindent( + parsedMarkdownMarkup, + currentLine, + ); + editor.dispatch(editor.state.replaceSelection(reindentedHtmlContent)); return; } } @@ -206,19 +216,26 @@ export function createCodemirror(params: CreateCodemirrorParams) { event.clipboardData.getData(DataTransferType.Text) ?? '', ) || {}; - if (!imageUrl) { - return; + if (imageUrl) { + event.preventDefault(); + + insertImages([ + { + url: imageUrl, + alt: title, + title, + }, + ])(editor); } + } + // Reindenting pasted plain text + const pastedText = event.clipboardData.getData(DataTransferType.Text); + const reindentedText = smartReindent(pastedText, currentLine); + // but only if there is a need for reindentation + if (pastedText !== reindentedText) { + editor.dispatch(editor.state.replaceSelection(reindentedText)); event.preventDefault(); - - insertImages([ - { - url: imageUrl, - alt: title, - title, - }, - ])(editor); } }, }), diff --git a/src/markup/codemirror/smart-reindent/__tests__/index.test.ts b/src/markup/codemirror/smart-reindent/__tests__/index.test.ts new file mode 100644 index 00000000..3c1fdb02 --- /dev/null +++ b/src/markup/codemirror/smart-reindent/__tests__/index.test.ts @@ -0,0 +1,122 @@ +import {smartReindent} from '../index'; + +describe('smartReindent', () => { + // Basic functionality + it('should preserve pasted text when current line is empty', () => { + const pastedText = 'First line\nSecond line'; + const currentLine = ''; + + expect(smartReindent(pastedText, currentLine)).toBe(pastedText); + }); + + it('should preserve pasted text when current line has no markers', () => { + const pastedText = 'First line\nSecond line'; + const currentLine = 'Just plain text'; + + expect(smartReindent(pastedText, currentLine)).toBe(pastedText); + }); + + // List markers + it('should reindent with numeric list markers', () => { + const pastedText = 'First item\nSecond item\nThird item'; + const currentLine = '1. List item'; + + expect(smartReindent(pastedText, currentLine)).toBe( + 'First item\n Second item\n Third item', + ); + }); + + it('should reindent with dash list markers', () => { + const pastedText = 'First item\nSecond item'; + const currentLine = '- List item'; + + expect(smartReindent(pastedText, currentLine)).toBe('First item\n Second item'); + }); + + it('should reindent with asterisk list markers', () => { + const pastedText = 'First item\nSecond item'; + const currentLine = '* List item'; + + expect(smartReindent(pastedText, currentLine)).toBe('First item\n Second item'); + }); + + it('should reindent with plus list markers', () => { + const pastedText = 'First item\nSecond item'; + const currentLine = '+ List item'; + + expect(smartReindent(pastedText, currentLine)).toBe('First item\n Second item'); + }); + + // Edge cases + it('should handle multi-digit numeric markers correctly', () => { + const pastedText = 'First item\nSecond item'; + const currentLine = '123. List item'; + + expect(smartReindent(pastedText, currentLine)).toBe('First item\n Second item'); + }); + + it('should preserve empty lines with indentation', () => { + const pastedText = 'First item\n\nThird item'; + const currentLine = '- List item'; + + expect(smartReindent(pastedText, currentLine)).toBe('First item\n \n Third item'); + }); + + it('should handle multiple markers correctly', () => { + const pastedText = 'First item\nSecond item'; + const currentLine = ' - Nested list item'; + + expect(smartReindent(pastedText, currentLine)).toBe('First item\n Second item'); + }); + + it('should handle single-line paste correctly', () => { + const pastedText = 'Single line'; + const currentLine = '- List item'; + + expect(smartReindent(pastedText, currentLine)).toBe('Single line'); + }); + + it('should handle windows-style line endings', () => { + const pastedText = 'First item\r\nSecond item'; + const currentLine = '- List item'; + + expect(smartReindent(pastedText, currentLine)).toBe('First item\r\n Second item'); + }); + + // Block quotes + it('should reindent with blockquote markers', () => { + const pastedText = 'First quote\nSecond quote'; + const currentLine = '> Quoted text'; + + expect(smartReindent(pastedText, currentLine)).toBe('First quote\n> Second quote'); + }); + + it('should handle nested blockquotes', () => { + const pastedText = 'First quote\nSecond quote'; + const currentLine = '> > Nested quote'; + + expect(smartReindent(pastedText, currentLine)).toBe('First quote\n> > Second quote'); + }); + + // Spaces and indentation + it('should handle double space indentation', () => { + const pastedText = 'First line\nSecond line'; + const currentLine = ' Indented text'; + + expect(smartReindent(pastedText, currentLine)).toBe('First line\n Second line'); + }); + + it('should handle code block indentation (4 spaces)', () => { + const pastedText = 'var x = 1;\nvar y = 2;'; + const currentLine = ' Code block'; + + expect(smartReindent(pastedText, currentLine)).toBe('var x = 1;\n var y = 2;'); + }); + + it('should handle mixed markers correctly', () => { + const pastedText = 'First line\nSecond line'; + const currentLine = ' > - Nested quote with list'; + + expect(smartReindent(pastedText, currentLine)).toBe('First line\n > Second line'); + }); +}); diff --git a/src/markup/codemirror/smart-reindent/__tests__/utils.test.ts b/src/markup/codemirror/smart-reindent/__tests__/utils.test.ts new file mode 100644 index 00000000..38eb102c --- /dev/null +++ b/src/markup/codemirror/smart-reindent/__tests__/utils.test.ts @@ -0,0 +1,27 @@ +import {parseMarkers} from '../utils'; + +describe('parseMarkers', () => { + it('should parse list markers correctly', () => { + expect(parseMarkers('* list')).toEqual(['* ']); + expect(parseMarkers('- list')).toEqual(['- ']); + expect(parseMarkers('+ list')).toEqual(['+ ']); + expect(parseMarkers(' * list')).toEqual([' ', ' ', '* ']); + expect(parseMarkers(' * list')).toEqual([' ', '* ']); + }); + + it('should parse blockquote markers correctly', () => { + expect(parseMarkers('> quote')).toEqual(['> ']); + expect(parseMarkers(' > quote')).toEqual([' ', ' ', '> ']); + }); + + it('should parse indentation correctly', () => { + expect(parseMarkers(' text')).toEqual([' ', ' ']); + expect(parseMarkers(' text')).toEqual([' ']); + }); + + it('should handle empty or invalid input', () => { + expect(parseMarkers('')).toEqual([]); + expect(parseMarkers('text')).toEqual([]); + expect(parseMarkers(' text')).toEqual([' ']); + }); +}); diff --git a/src/markup/codemirror/smart-reindent/index.ts b/src/markup/codemirror/smart-reindent/index.ts new file mode 100644 index 00000000..af02f4f5 --- /dev/null +++ b/src/markup/codemirror/smart-reindent/index.ts @@ -0,0 +1,46 @@ +import {parseMarkers} from './utils'; + +/** + * Reindents pasted text based on the current line's markers + */ +export function smartReindent(pastedText: string, currentLineText: string): string { + // If current line is empty, return pasted text as is + if (currentLineText.length === 0) { + return pastedText; + } + + // Get markers from current line + const markers = parseMarkers(currentLineText); + + // If no markers found, return pasted text as is + if (markers.length === 0) { + return pastedText; + } + + // Create indentation for subsequent lines by replacing list markers with spaces + const subsequentIndent = markers + .map((marker) => { + if (marker.match(/^\d{1,6}\. |-|\*|\+/)) { + return ' '.repeat(marker.length); + } + return marker; + }) + .join(''); + + // Split and process the pasted text + const lines = pastedText.split('\n'); + + const reindentedText = lines + .map((line, index) => { + // First line doesn't need indentation + if (index === 0) { + return line; + } + + // Add indentation to all subsequent lines, including empty ones + return subsequentIndent + line; + }) + .join('\n'); + + return reindentedText; +} diff --git a/src/markup/codemirror/smart-reindent/utils.ts b/src/markup/codemirror/smart-reindent/utils.ts new file mode 100644 index 00000000..3353e156 --- /dev/null +++ b/src/markup/codemirror/smart-reindent/utils.ts @@ -0,0 +1,63 @@ +/** + * Parses markdown-style markers from the start of a line + * Returns an array of markers found: + * - ' ' for indentation + * - '> ' for blockquotes + * - '* ' or '- ' for list items + * - '1. ' for numbered lists + * + * Example inputs: + * " * list" -> [' ', '* '] + * "> quoted" -> ['> '] + * " nested" -> [' ', ' '] + * "1. list" -> ['1. '] + */ +export function parseMarkers(text: string): string[] { + const markers: string[] = []; + let pos = 0; + + while (pos < text.length) { + // Handle code block (4 spaces) + if ( + pos + 3 < text.length && + text[pos] === ' ' && + text[pos + 1] === ' ' && + text[pos + 2] === ' ' && + text[pos + 3] === ' ' + ) { + markers.push(' '); + pos += 4; + continue; + } + + // Handle numbered lists (1-6 digits followed by dot and space) + if (/^\d{1,6}\. /.test(text.slice(pos))) { + const match = text.slice(pos).match(/^(\d{1,6}\. )/); + if (match) { + markers.push(match[1]); + pos += match[1].length; + continue; + } + } + + // Handle block quotes and list markers + if (text[pos] === '>' || text[pos] === '-' || text[pos] === '*' || text[pos] === '+') { + if (pos + 1 < text.length && text[pos + 1] === ' ') { + markers.push(text[pos] + ' '); + pos += 2; + continue; + } + } + + // Handle single space (last priority) + if (text[pos] === ' ') { + markers.push(' '); + pos += 1; + continue; + } + + break; + } + + return markers; +}