Skip to content

Commit

Permalink
feat(markup): smart re-indent on paste (#530)
Browse files Browse the repository at this point in the history
  • Loading branch information
obenjiro authored Dec 19, 2024
1 parent bd52bee commit 15767d7
Show file tree
Hide file tree
Showing 5 changed files with 287 additions and 12 deletions.
41 changes: 29 additions & 12 deletions src/markup/codemirror/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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;
}
}
Expand All @@ -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);
}
},
}),
Expand Down
122 changes: 122 additions & 0 deletions src/markup/codemirror/smart-reindent/__tests__/index.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
27 changes: 27 additions & 0 deletions src/markup/codemirror/smart-reindent/__tests__/utils.test.ts
Original file line number Diff line number Diff line change
@@ -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([' ']);
});
});
46 changes: 46 additions & 0 deletions src/markup/codemirror/smart-reindent/index.ts
Original file line number Diff line number Diff line change
@@ -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;
}
63 changes: 63 additions & 0 deletions src/markup/codemirror/smart-reindent/utils.ts
Original file line number Diff line number Diff line change
@@ -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;
}

0 comments on commit 15767d7

Please sign in to comment.