Skip to content

Commit 15767d7

Browse files
authored
feat(markup): smart re-indent on paste (#530)
1 parent bd52bee commit 15767d7

File tree

5 files changed

+287
-12
lines changed

5 files changed

+287
-12
lines changed

src/markup/codemirror/create.ts

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import {MarkdownConverter} from './html-to-markdown/converters';
4646
import {PairingCharactersExtension} from './pairing-chars';
4747
import {ReactRendererFacet} from './react-facet';
4848
import {SearchPanelPlugin} from './search-plugin/plugin';
49+
import {smartReindent} from './smart-reindent';
4950
import {type YfmLangOptions, yfmLang} from './yfm';
5051

5152
export type {YfmLangOptions};
@@ -162,12 +163,17 @@ export function createCodemirror(params: CreateCodemirrorParams) {
162163
paste(event, editor) {
163164
if (!event.clipboardData) return;
164165

166+
const {from} = editor.state.selection.main;
167+
const line = editor.state.doc.lineAt(from);
168+
const currentLine = line.text;
169+
165170
// if clipboard contains YFM content - avoid any meddling with pasted content
166171
// since text/yfm will contain valid markdown
167172
const yfmContent = event.clipboardData.getData(DataTransferType.Yfm);
168173
if (yfmContent) {
169174
event.preventDefault();
170-
editor.dispatch(editor.state.replaceSelection(yfmContent));
175+
const reindentedYfmContent = smartReindent(yfmContent, currentLine);
176+
editor.dispatch(editor.state.replaceSelection(reindentedYfmContent));
171177
return;
172178
}
173179

@@ -195,7 +201,11 @@ export function createCodemirror(params: CreateCodemirrorParams) {
195201

196202
if (parsedMarkdownMarkup !== undefined) {
197203
event.preventDefault();
198-
editor.dispatch(editor.state.replaceSelection(parsedMarkdownMarkup));
204+
const reindentedHtmlContent = smartReindent(
205+
parsedMarkdownMarkup,
206+
currentLine,
207+
);
208+
editor.dispatch(editor.state.replaceSelection(reindentedHtmlContent));
199209
return;
200210
}
201211
}
@@ -206,19 +216,26 @@ export function createCodemirror(params: CreateCodemirrorParams) {
206216
event.clipboardData.getData(DataTransferType.Text) ?? '',
207217
) || {};
208218

209-
if (!imageUrl) {
210-
return;
219+
if (imageUrl) {
220+
event.preventDefault();
221+
222+
insertImages([
223+
{
224+
url: imageUrl,
225+
alt: title,
226+
title,
227+
},
228+
])(editor);
211229
}
230+
}
212231

232+
// Reindenting pasted plain text
233+
const pastedText = event.clipboardData.getData(DataTransferType.Text);
234+
const reindentedText = smartReindent(pastedText, currentLine);
235+
// but only if there is a need for reindentation
236+
if (pastedText !== reindentedText) {
237+
editor.dispatch(editor.state.replaceSelection(reindentedText));
213238
event.preventDefault();
214-
215-
insertImages([
216-
{
217-
url: imageUrl,
218-
alt: title,
219-
title,
220-
},
221-
])(editor);
222239
}
223240
},
224241
}),
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import {smartReindent} from '../index';
2+
3+
describe('smartReindent', () => {
4+
// Basic functionality
5+
it('should preserve pasted text when current line is empty', () => {
6+
const pastedText = 'First line\nSecond line';
7+
const currentLine = '';
8+
9+
expect(smartReindent(pastedText, currentLine)).toBe(pastedText);
10+
});
11+
12+
it('should preserve pasted text when current line has no markers', () => {
13+
const pastedText = 'First line\nSecond line';
14+
const currentLine = 'Just plain text';
15+
16+
expect(smartReindent(pastedText, currentLine)).toBe(pastedText);
17+
});
18+
19+
// List markers
20+
it('should reindent with numeric list markers', () => {
21+
const pastedText = 'First item\nSecond item\nThird item';
22+
const currentLine = '1. List item';
23+
24+
expect(smartReindent(pastedText, currentLine)).toBe(
25+
'First item\n Second item\n Third item',
26+
);
27+
});
28+
29+
it('should reindent with dash list markers', () => {
30+
const pastedText = 'First item\nSecond item';
31+
const currentLine = '- List item';
32+
33+
expect(smartReindent(pastedText, currentLine)).toBe('First item\n Second item');
34+
});
35+
36+
it('should reindent with asterisk list markers', () => {
37+
const pastedText = 'First item\nSecond item';
38+
const currentLine = '* List item';
39+
40+
expect(smartReindent(pastedText, currentLine)).toBe('First item\n Second item');
41+
});
42+
43+
it('should reindent with plus list markers', () => {
44+
const pastedText = 'First item\nSecond item';
45+
const currentLine = '+ List item';
46+
47+
expect(smartReindent(pastedText, currentLine)).toBe('First item\n Second item');
48+
});
49+
50+
// Edge cases
51+
it('should handle multi-digit numeric markers correctly', () => {
52+
const pastedText = 'First item\nSecond item';
53+
const currentLine = '123. List item';
54+
55+
expect(smartReindent(pastedText, currentLine)).toBe('First item\n Second item');
56+
});
57+
58+
it('should preserve empty lines with indentation', () => {
59+
const pastedText = 'First item\n\nThird item';
60+
const currentLine = '- List item';
61+
62+
expect(smartReindent(pastedText, currentLine)).toBe('First item\n \n Third item');
63+
});
64+
65+
it('should handle multiple markers correctly', () => {
66+
const pastedText = 'First item\nSecond item';
67+
const currentLine = ' - Nested list item';
68+
69+
expect(smartReindent(pastedText, currentLine)).toBe('First item\n Second item');
70+
});
71+
72+
it('should handle single-line paste correctly', () => {
73+
const pastedText = 'Single line';
74+
const currentLine = '- List item';
75+
76+
expect(smartReindent(pastedText, currentLine)).toBe('Single line');
77+
});
78+
79+
it('should handle windows-style line endings', () => {
80+
const pastedText = 'First item\r\nSecond item';
81+
const currentLine = '- List item';
82+
83+
expect(smartReindent(pastedText, currentLine)).toBe('First item\r\n Second item');
84+
});
85+
86+
// Block quotes
87+
it('should reindent with blockquote markers', () => {
88+
const pastedText = 'First quote\nSecond quote';
89+
const currentLine = '> Quoted text';
90+
91+
expect(smartReindent(pastedText, currentLine)).toBe('First quote\n> Second quote');
92+
});
93+
94+
it('should handle nested blockquotes', () => {
95+
const pastedText = 'First quote\nSecond quote';
96+
const currentLine = '> > Nested quote';
97+
98+
expect(smartReindent(pastedText, currentLine)).toBe('First quote\n> > Second quote');
99+
});
100+
101+
// Spaces and indentation
102+
it('should handle double space indentation', () => {
103+
const pastedText = 'First line\nSecond line';
104+
const currentLine = ' Indented text';
105+
106+
expect(smartReindent(pastedText, currentLine)).toBe('First line\n Second line');
107+
});
108+
109+
it('should handle code block indentation (4 spaces)', () => {
110+
const pastedText = 'var x = 1;\nvar y = 2;';
111+
const currentLine = ' Code block';
112+
113+
expect(smartReindent(pastedText, currentLine)).toBe('var x = 1;\n var y = 2;');
114+
});
115+
116+
it('should handle mixed markers correctly', () => {
117+
const pastedText = 'First line\nSecond line';
118+
const currentLine = ' > - Nested quote with list';
119+
120+
expect(smartReindent(pastedText, currentLine)).toBe('First line\n > Second line');
121+
});
122+
});
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import {parseMarkers} from '../utils';
2+
3+
describe('parseMarkers', () => {
4+
it('should parse list markers correctly', () => {
5+
expect(parseMarkers('* list')).toEqual(['* ']);
6+
expect(parseMarkers('- list')).toEqual(['- ']);
7+
expect(parseMarkers('+ list')).toEqual(['+ ']);
8+
expect(parseMarkers(' * list')).toEqual([' ', ' ', '* ']);
9+
expect(parseMarkers(' * list')).toEqual([' ', '* ']);
10+
});
11+
12+
it('should parse blockquote markers correctly', () => {
13+
expect(parseMarkers('> quote')).toEqual(['> ']);
14+
expect(parseMarkers(' > quote')).toEqual([' ', ' ', '> ']);
15+
});
16+
17+
it('should parse indentation correctly', () => {
18+
expect(parseMarkers(' text')).toEqual([' ', ' ']);
19+
expect(parseMarkers(' text')).toEqual([' ']);
20+
});
21+
22+
it('should handle empty or invalid input', () => {
23+
expect(parseMarkers('')).toEqual([]);
24+
expect(parseMarkers('text')).toEqual([]);
25+
expect(parseMarkers(' text')).toEqual([' ']);
26+
});
27+
});
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import {parseMarkers} from './utils';
2+
3+
/**
4+
* Reindents pasted text based on the current line's markers
5+
*/
6+
export function smartReindent(pastedText: string, currentLineText: string): string {
7+
// If current line is empty, return pasted text as is
8+
if (currentLineText.length === 0) {
9+
return pastedText;
10+
}
11+
12+
// Get markers from current line
13+
const markers = parseMarkers(currentLineText);
14+
15+
// If no markers found, return pasted text as is
16+
if (markers.length === 0) {
17+
return pastedText;
18+
}
19+
20+
// Create indentation for subsequent lines by replacing list markers with spaces
21+
const subsequentIndent = markers
22+
.map((marker) => {
23+
if (marker.match(/^\d{1,6}\. |-|\*|\+/)) {
24+
return ' '.repeat(marker.length);
25+
}
26+
return marker;
27+
})
28+
.join('');
29+
30+
// Split and process the pasted text
31+
const lines = pastedText.split('\n');
32+
33+
const reindentedText = lines
34+
.map((line, index) => {
35+
// First line doesn't need indentation
36+
if (index === 0) {
37+
return line;
38+
}
39+
40+
// Add indentation to all subsequent lines, including empty ones
41+
return subsequentIndent + line;
42+
})
43+
.join('\n');
44+
45+
return reindentedText;
46+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/**
2+
* Parses markdown-style markers from the start of a line
3+
* Returns an array of markers found:
4+
* - ' ' for indentation
5+
* - '> ' for blockquotes
6+
* - '* ' or '- ' for list items
7+
* - '1. ' for numbered lists
8+
*
9+
* Example inputs:
10+
* " * list" -> [' ', '* ']
11+
* "> quoted" -> ['> ']
12+
* " nested" -> [' ', ' ']
13+
* "1. list" -> ['1. ']
14+
*/
15+
export function parseMarkers(text: string): string[] {
16+
const markers: string[] = [];
17+
let pos = 0;
18+
19+
while (pos < text.length) {
20+
// Handle code block (4 spaces)
21+
if (
22+
pos + 3 < text.length &&
23+
text[pos] === ' ' &&
24+
text[pos + 1] === ' ' &&
25+
text[pos + 2] === ' ' &&
26+
text[pos + 3] === ' '
27+
) {
28+
markers.push(' ');
29+
pos += 4;
30+
continue;
31+
}
32+
33+
// Handle numbered lists (1-6 digits followed by dot and space)
34+
if (/^\d{1,6}\. /.test(text.slice(pos))) {
35+
const match = text.slice(pos).match(/^(\d{1,6}\. )/);
36+
if (match) {
37+
markers.push(match[1]);
38+
pos += match[1].length;
39+
continue;
40+
}
41+
}
42+
43+
// Handle block quotes and list markers
44+
if (text[pos] === '>' || text[pos] === '-' || text[pos] === '*' || text[pos] === '+') {
45+
if (pos + 1 < text.length && text[pos + 1] === ' ') {
46+
markers.push(text[pos] + ' ');
47+
pos += 2;
48+
continue;
49+
}
50+
}
51+
52+
// Handle single space (last priority)
53+
if (text[pos] === ' ') {
54+
markers.push(' ');
55+
pos += 1;
56+
continue;
57+
}
58+
59+
break;
60+
}
61+
62+
return markers;
63+
}

0 commit comments

Comments
 (0)