// Pure text transformations for the minimal Markdown editor. They take the // textarea state and return the new content plus the selection to restore, // which keeps them testable without a DOM. export interface EditState { content: string; selectionStart: number; selectionEnd: number; } export interface LinePrefix { add: string; detect: RegExp; } const lineBounds = (content: string, start: number, end: number) => { const lineStart = content.lastIndexOf('\n', start - 1) + 1; let lineEnd = content.indexOf('\n', end); if (lineEnd < 0) lineEnd = content.length; return { start: lineStart, end: lineEnd }; }; export const insertTextAtSelection = (state: EditState, text: string): EditState => { const cursor = state.selectionStart + text.length; return { content: state.content.substring(0, state.selectionStart) + text + state.content.substring(state.selectionEnd), selectionStart: cursor, selectionEnd: cursor, }; }; export const toggleWrap = (state: EditState, marker: string, placeholder: string): EditState => { const { content, selectionStart, selectionEnd } = state; const selected = content.substring(selectionStart, selectionEnd); const before = content.substring(0, selectionStart); const after = content.substring(selectionEnd); if (before.endsWith(marker) && after.startsWith(marker)) { return { content: before.substring(0, before.length - marker.length) + selected + after.substring(marker.length), selectionStart: selectionStart - marker.length, selectionEnd: selectionEnd - marker.length, }; } if (selected.length >= marker.length * 2 && selected.startsWith(marker) && selected.endsWith(marker)) { const inner = selected.substring(marker.length, selected.length - marker.length); return { content: before + inner + after, selectionStart, selectionEnd: selectionStart + inner.length, }; } const text = selected.length > 0 ? selected : placeholder; return { content: `${before}${marker}${text}${marker}${after}`, selectionStart: selectionStart + marker.length, selectionEnd: selectionStart + marker.length + text.length, }; }; export const toggleLinePrefix = (state: EditState, prefix: LinePrefix): EditState => { const { start, end } = lineBounds(state.content, state.selectionStart, state.selectionEnd); const lines = state.content.substring(start, end).split('\n'); const nonEmpty = lines.filter(line => line.trim().length > 0); const remove = nonEmpty.length > 0 && nonEmpty.every(line => prefix.detect.test(line)); const collapsed = state.selectionStart === state.selectionEnd; let selectionStart = state.selectionStart; let selectionEnd = state.selectionEnd; const newLines: string[] = []; let offset = start; for (const line of lines) { const isEmpty = line.trim().length === 0; const newLine = isEmpty ? line : (remove ? line.replace(prefix.detect, '') : `${prefix.add}${line}`); const delta = newLine.length - line.length; // An inserted prefix lands before a cursor sitting at the line start, so // the cursor follows it. A removed prefix leaves the line start unchanged. if (offset < selectionStart || (offset === selectionStart && !remove)) selectionStart += delta; if (offset < selectionEnd || (offset === selectionEnd && collapsed && !remove)) selectionEnd += delta; offset += line.length + 1; newLines.push(newLine); } return { content: state.content.substring(0, start) + newLines.join('\n') + state.content.substring(end), selectionStart, selectionEnd, }; }; export const toggleCodeBlock = (state: EditState): EditState => { const { content, selectionStart, selectionEnd } = state; const selected = content.substring(selectionStart, selectionEnd); const before = content.substring(0, selectionStart); const after = content.substring(selectionEnd); const fence = '```'; const fenced = new RegExp(`^\\s*${fence}\\n([\\s\\S]*)\\n${fence}\\s*$`); const fencedSelection = selected.match(fenced); if (fencedSelection) { const inner = fencedSelection[1]; return { content: before + inner + after, selectionStart, selectionEnd: selectionStart + inner.length, }; } if (selected.length > 0) { return { content: `${before}${fence}\n${selected}\n${fence}${after}`, selectionStart: selectionStart + fence.length + 1, selectionEnd: selectionStart + fence.length + 1 + selected.length, }; } const cursor = selectionStart + fence.length + 1; return { content: `${before}${fence}\n\n${fence}${after}`, selectionStart: cursor, selectionEnd: cursor, }; }; export const wrapWithLink = (state: EditState, url: string): EditState => { const { content, selectionStart, selectionEnd } = state; const selected = content.substring(selectionStart, selectionEnd); const link = `[${selected}](${url})`; return { content: content.substring(0, selectionStart) + link + content.substring(selectionEnd), selectionStart: selectionStart + 1, selectionEnd: selectionStart + 1 + selected.length, }; };