diff --git a/README.md b/README.md index f8f83e7..ddea596 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,7 @@ _Distraction-free web editing with the same files from your CLI_ - **Syntax highlighting** for Markdown (headers, bold, italic, code, links, lists) - **Text selection** with Shift+Arrow keys - **Undo/Redo** (Ctrl+Z/Ctrl+Y) +- **Auto-formatting** (Ctrl+Shift+F) for clean Markdown - **Smart indentation** (Tab/Shift+Tab) - **Word jumping** (Ctrl+Arrow keys) - **Line operations** (join lines with backspace at start) @@ -196,6 +197,7 @@ See [CLOUD.md](CLOUD.md) for detailed cloud documentation. | ------------- | --------------------------------------- | | **Ctrl+Z** | Undo | | **Ctrl+Y** | Redo | +| **Ctrl+Shift+F** | Auto-format Markdown | | **Tab** | Indent (2 spaces) | | **Shift+Tab** | Unindent | | **Enter** | New line | diff --git a/src/App.jsx b/src/App.jsx index db7af76..c6e6fa3 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -6,6 +6,7 @@ import Editor from './components/Editor.jsx'; import Preview from './components/Preview.jsx'; import StatusBar from './components/StatusBar.jsx'; import { syncFileOnSave } from './cloud/sync/syncOnSave.js'; +import { formatMarkdown } from './utils/formatMarkdown.js'; export default function App({ filePath: initialFilePath, initialContent, layout: initialLayout, showPreview: initialShowPreview, config, onExit }) { const [filePath, setFilePath] = useState(initialFilePath); @@ -118,6 +119,25 @@ export default function App({ filePath: initialFilePath, initialContent, layout: } }, [filePath, content, addTimeout]); + const handleFormat = useCallback(() => { + try { + const formatted = formatMarkdown(content); + + if (formatted === content) { + setMessage('Already formatted'); + addTimeout(() => setMessage(''), 1500); + return; + } + + setContent(formatted); + setMessage('Formatted markdown'); + addTimeout(() => setMessage(''), 2000); + } catch (error) { + setMessage(`Format error: ${error.message}`); + addTimeout(() => setMessage(''), 3000); + } + }, [content, addTimeout]); + useInput((input, key) => { // Clear exit warning on any key press except Ctrl+X if (!(key.ctrl && input === 'x') && exitWarningShown) { @@ -128,6 +148,10 @@ export default function App({ filePath: initialFilePath, initialContent, layout: if (key.ctrl && input === 's') { handleSave(); } + // Ctrl+Shift+F: Format markdown + if (key.ctrl && key.shift && input && input.toLowerCase() === 'f') { + handleFormat(); + } // Ctrl+O: Open config file (or return to original file if editing config) if (key.ctrl && input === 'o') { const configPath = getConfigPath(); diff --git a/src/components/TextBuffer.jsx b/src/components/TextBuffer.jsx index 35541fe..00460ca 100644 --- a/src/components/TextBuffer.jsx +++ b/src/components/TextBuffer.jsx @@ -269,16 +269,49 @@ export default function TextBuffer({ content, onChange, isFocused = true, viewpo onChange(lines.map(l => l.text).join('\n')); }, [lines, onChange]); - // Reset when content prop changes (initial load only) + // Sync editor when parent content changes (e.g., external formatting) useEffect(() => { - if (lines.length === 0 || (lines.length === 1 && lines[0].text === '')) { - const newLines = content.split('\n').map((line) => ({ - id: `line-${lineIdCounter++}`, - text: line - })); - setLines(newLines); + const currentContent = lines.map(l => l.text).join('\n'); + if (content === currentContent) { + return; } - }, []); + + const newLines = content.split('\n').map((line) => ({ + id: `line-${lineIdCounter++}`, + text: line + })); + + setLines(newLines); + setSelection(null); + + const newCursorLine = Math.min(cursorLine, newLines.length - 1); + const newCursorCol = Math.min(newLines[newCursorLine]?.text?.length || 0, cursorCol); + setCursorLine(newCursorLine); + setCursorCol(newCursorCol); + + setHistory(prevHistory => { + const truncated = prevHistory + .slice(0, historyIndex + 1) + .map(state => ({ + lines: state.lines.map(l => ({ ...l })), + cursorLine: state.cursorLine, + cursorCol: state.cursorCol, + })); + + truncated.push({ + lines: newLines.map(l => ({ ...l })), + cursorLine: newCursorLine, + cursorCol: newCursorCol, + }); + + if (truncated.length > 100) { + truncated.shift(); + } + + setHistoryIndex(truncated.length - 1); + return truncated; + }); + }, [content, cursorCol, cursorLine, historyIndex, lines]); // Keep cursor visible by adjusting scroll offset useEffect(() => { diff --git a/src/utils/formatMarkdown.js b/src/utils/formatMarkdown.js new file mode 100644 index 0000000..e7ca33d --- /dev/null +++ b/src/utils/formatMarkdown.js @@ -0,0 +1,94 @@ +export function formatMarkdown(content) { + if (typeof content !== 'string' || content.length === 0) { + return ''; + } + + const normalized = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); + const lines = normalized.split('\n'); + const formatted = []; + + let blankLineCount = 0; + let inCodeFence = false; + + for (let i = 0; i < lines.length; i++) { + const originalLine = lines[i]; + const trimmedEnd = originalLine.replace(/\s+$/u, ''); + const fenceMatch = trimmedEnd.trimStart().match(/^(?:```+|~~~+)(.*)$/); + + if (inCodeFence) { + formatted.push(originalLine); + if (fenceMatch) { + inCodeFence = false; + blankLineCount = 0; + } + continue; + } + + if (fenceMatch) { + formatted.push(trimmedEnd); + inCodeFence = true; + blankLineCount = 0; + continue; + } + + if (trimmedEnd.trim() === '') { + if (formatted.length === 0) { + continue; + } + blankLineCount += 1; + if (blankLineCount > 1) { + continue; + } + formatted.push(''); + continue; + } + + blankLineCount = 0; + let processedLine = trimmedEnd; + + const headingMatch = processedLine.match(/^(#{1,6})(\s*)(.*)$/); + if (headingMatch) { + const [, hashes, , text] = headingMatch; + const headingText = text.trim(); + processedLine = headingText ? `${hashes} ${headingText}` : hashes; + formatted.push(processedLine); + continue; + } + + const blockquoteMatch = processedLine.match(/^(\s*>+)(\s*)(.*)$/); + if (blockquoteMatch) { + const [, markers, , text] = blockquoteMatch; + const quoteText = text.replace(/^\s+/, ''); + processedLine = quoteText ? `${markers} ${quoteText}` : markers; + formatted.push(processedLine); + continue; + } + + const listMatch = processedLine.match(/^(\s*)(?:([-*+])|(\d+\.))(\s*)(.*)$/); + if (listMatch) { + const indent = listMatch[1] || ''; + const bullet = listMatch[2] || listMatch[3] || ''; + const listText = (listMatch[5] || '').replace(/^\s+/, ''); + const spacer = listText ? ' ' : ''; + processedLine = `${indent}${bullet}${spacer}${listText}`; + formatted.push(processedLine); + continue; + } + + formatted.push(processedLine); + } + + while (formatted.length > 0 && formatted[formatted.length - 1] === '') { + formatted.pop(); + } + + if (formatted.length === 0) { + return ''; + } + + let result = formatted.join('\n'); + if (!result.endsWith('\n')) { + result += '\n'; + } + return result; +} diff --git a/tests/unit/formatMarkdown.test.js b/tests/unit/formatMarkdown.test.js new file mode 100644 index 0000000..4d76d16 --- /dev/null +++ b/tests/unit/formatMarkdown.test.js @@ -0,0 +1,21 @@ +import { formatMarkdown } from '../../src/utils/formatMarkdown.js'; + +describe('formatMarkdown', () => { + it('normalizes headings and ensures a trailing newline', () => { + const input = '#Heading\n\nSome text'; + const output = formatMarkdown(input); + expect(output).toBe('# Heading\n\nSome text\n'); + }); + + it('collapses excessive blank lines and trims list spacing', () => { + const input = '- item one\n\n\n- item two'; + const output = formatMarkdown(input); + expect(output).toBe('- item one\n\n- item two\n'); + }); + + it('preserves code fences without altering inner content', () => { + const input = '```js\nconst value = 1; \n```\n'; + const output = formatMarkdown(input); + expect(output).toBe('```js\nconst value = 1; \n```\n'); + }); +});