diff --git a/CONFIG.md b/CONFIG.md index 3ce7848..e03850b 100644 --- a/CONFIG.md +++ b/CONFIG.md @@ -29,6 +29,9 @@ Splitmark automatically detects the correct Documents folder for your platform: "layout": "side", "showPreview": true, "columnWidthRatio": 75, + "format": { + "wrapColumn": 80 + }, "theme": { "editor": { "background": "#1e1e1e", @@ -85,6 +88,11 @@ Splitmark automatically detects the correct Documents folder for your platform: - **Default**: `75` - **Description**: Editor width percentage in side-by-side layout (75%, 50%, or 25%) +### `format.wrapColumn` +- **Type**: Number +- **Default**: `80` +- **Description**: Target column width used by the in-editor Markdown formatter (Ctrl+Shift+F) + ### `theme` - **Type**: Object - **Description**: Color theme settings for editor, syntax highlighting, and preview @@ -170,7 +178,10 @@ Edit `~/.splitmarkrc` with your preferred settings: "defaultLocation": "/path/to/your/notes", "layout": "bottom", "showPreview": true, - "columnWidthRatio": 50 + "columnWidthRatio": 50, + "format": { + "wrapColumn": 72 + } } ``` diff --git a/README.md b/README.md index f8f83e7..38026ec 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 structure - **Smart indentation** (Tab/Shift+Tab) - **Word jumping** (Ctrl+Arrow keys) - **Line operations** (join lines with backspace at start) @@ -192,14 +193,15 @@ See [CLOUD.md](CLOUD.md) for detailed cloud documentation. ### Editing -| Shortcut | Action | -| ------------- | --------------------------------------- | -| **Ctrl+Z** | Undo | -| **Ctrl+Y** | Redo | -| **Tab** | Indent (2 spaces) | -| **Shift+Tab** | Unindent | -| **Enter** | New line | -| **Backspace** | Delete character (joins lines at start) | +| Shortcut | Action | +| ---------------- | --------------------------------------- | +| **Ctrl+Z** | Undo | +| **Ctrl+Y** | Redo | +| **Ctrl+Shift+F** | Format Markdown document | +| **Tab** | Indent (2 spaces) | +| **Shift+Tab** | Unindent | +| **Enter** | New line | +| **Backspace** | Delete character (joins lines at start) | ### Navigation diff --git a/src/App.jsx b/src/App.jsx index db7af76..358e640 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,11 +119,36 @@ export default function App({ filePath: initialFilePath, initialContent, layout: } }, [filePath, content, addTimeout]); + const handleFormatDocument = useCallback(() => { + try { + const formatted = formatMarkdown(content, { + wrapColumn: config?.format?.wrapColumn, + }); + + if (formatted === content) { + setMessage('Document already formatted'); + addTimeout(() => setMessage(''), 1500); + return; + } + + setContent(formatted); + setMessage('Markdown formatted'); + addTimeout(() => setMessage(''), 2000); + } catch (error) { + setMessage(`Format failed: ${error.message}`); + addTimeout(() => setMessage(''), 3000); + } + }, [content, config, addTimeout]); + useInput((input, key) => { // Clear exit warning on any key press except Ctrl+X if (!(key.ctrl && input === 'x') && exitWarningShown) { setExitWarningShown(false); } + // Ctrl+Shift+F: Format Markdown + if (key.ctrl && key.shift && input === 'f') { + handleFormatDocument(); + } // Ctrl+S: Save if (key.ctrl && input === 's') { diff --git a/src/components/TextBuffer.jsx b/src/components/TextBuffer.jsx index 35541fe..bf3ac31 100644 --- a/src/components/TextBuffer.jsx +++ b/src/components/TextBuffer.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useMemo } from 'react'; +import React, { useState, useEffect } from 'react'; import { Box, Text, useInput, measureElement } from 'ink'; import { highlightMarkdownLine } from '../utils/syntaxHighlight.js'; @@ -269,16 +269,60 @@ 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) + // React to external content changes (e.g. formatting commands) 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((line) => line.text).join('\n'); + if (content === currentContent) { + return; } - }, []); + + const updatedLines = content.split('\n').map((line) => ({ + id: `line-${lineIdCounter++}`, + text: line, + })); + + const newCursorLine = Math.min(cursorLine, Math.max(updatedLines.length - 1, 0)); + const newCursorCol = Math.min(cursorCol, updatedLines[newCursorLine]?.text.length || 0); + + const previousState = { + lines: lines.map((line) => ({ ...line })), + cursorLine, + cursorCol, + }; + + const newState = { + lines: updatedLines.map((line) => ({ ...line })), + cursorLine: newCursorLine, + cursorCol: newCursorCol, + }; + + setHistory((prevHistory) => { + const truncated = prevHistory.slice(0, historyIndex + 1).map((state) => ({ + lines: state.lines.map((line) => ({ ...line })), + cursorLine: state.cursorLine, + cursorCol: state.cursorCol, + })); + + let nextHistory = [...truncated, previousState, newState]; + if (nextHistory.length > 100) { + nextHistory = nextHistory.slice(nextHistory.length - 100); + } + + setHistoryIndex(nextHistory.length - 1); + return nextHistory; + }); + + setLines(updatedLines); + setCursorLine(newCursorLine); + setCursorCol(newCursorCol); + setSelection(null); + + const maxScroll = Math.max(updatedLines.length - viewportHeight, 0); + const adjustedScroll = Math.min(newCursorLine, maxScroll); + setScrollOffset(adjustedScroll); + // We intentionally depend only on `content` so external updates run once per change. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [content]); // Keep cursor visible by adjusting scroll offset useEffect(() => { diff --git a/src/utils/config.js b/src/utils/config.js index 680ba14..e3d980a 100644 --- a/src/utils/config.js +++ b/src/utils/config.js @@ -43,6 +43,9 @@ const DEFAULT_CONFIG = { layout: 'side', // 'side' or 'bottom' showPreview: true, columnWidthRatio: 75, // Editor width percentage for side-by-side + format: { + wrapColumn: 80, + }, theme: { // VSCode Dark+ inspired colors editor: { @@ -94,6 +97,10 @@ export function loadConfig() { return { ...DEFAULT_CONFIG, ...userConfig, + format: { + ...DEFAULT_CONFIG.format, + ...(userConfig.format || {}), + }, theme: { ...DEFAULT_CONFIG.theme, ...(userConfig.theme || {}), diff --git a/src/utils/formatMarkdown.js b/src/utils/formatMarkdown.js new file mode 100644 index 0000000..85f94d1 --- /dev/null +++ b/src/utils/formatMarkdown.js @@ -0,0 +1,225 @@ +/** + * Wrap text to the requested width while keeping whole words together. + * Words longer than the width are left intact to avoid losing information. + * + * @param {string} text + * @param {number} width + * @returns {string[]} + */ +function wrapText(text, width) { + if (!text || text.length <= width) { + return text ? [text] : []; + } + + const words = text.split(/\s+/).filter(Boolean); + if (words.length === 0) { + return []; + } + + const lines = []; + let current = words[0]; + + for (let i = 1; i < words.length; i++) { + const word = words[i]; + if (current.length + 1 + word.length <= width) { + current += ` ${word}`; + } else { + lines.push(current); + current = word; + } + } + + if (current) { + lines.push(current); + } + + return lines; +} + +const DEFAULT_WIDTH = 80; + +/** + * Light-weight Markdown formatter inspired by Prettier's defaults. + * + * The formatter normalises whitespace, collapses consecutive blank lines, + * enforces spaces after Markdown markers (e.g. headings, lists, block quotes) + * and wraps free-form paragraphs to a configurable width. + * + * @param {string} source Markdown source to format. + * @param {{ wrapColumn?: number }} [options] + * @returns {string} + */ +export function formatMarkdown(source, options = {}) { + if (typeof source !== 'string') { + return ''; + } + + const wrapColumn = Math.max(20, options.wrapColumn || DEFAULT_WIDTH); + const lines = source.split('\n'); + const formattedLines = []; + + let paragraphBuffer = []; + let insideFence = false; + let fenceMarker = ''; + let previousBlank = false; + + const flushParagraph = () => { + if (paragraphBuffer.length === 0) { + return; + } + + const paragraphText = paragraphBuffer + .join(' ') + .replace(/\s+/g, ' ') + .trim(); + + if (paragraphText.length === 0) { + if (!previousBlank) { + formattedLines.push(''); + previousBlank = true; + } + paragraphBuffer = []; + return; + } + + const wrapped = wrapText(paragraphText, wrapColumn); + wrapped.forEach((line) => { + formattedLines.push(line); + }); + + paragraphBuffer = []; + previousBlank = false; + }; + + for (let i = 0; i < lines.length; i++) { + const rawLine = lines[i]; + const trimmedRight = rawLine.replace(/\s+$/, ''); + const trimmed = trimmedRight.trim(); + + if (!insideFence) { + if (/^(```|~~~)/.test(trimmed)) { + flushParagraph(); + formattedLines.push(trimmedRight); + insideFence = true; + fenceMarker = trimmed.slice(0, 3); + previousBlank = false; + continue; + } + + if (trimmed.length === 0) { + flushParagraph(); + if (!previousBlank && (formattedLines.length > 0 || i < lines.length - 1)) { + formattedLines.push(''); + previousBlank = true; + } + continue; + } + + if (/^<.*>$/.test(trimmed)) { + // Treat standalone HTML blocks as paragraph breakers. + flushParagraph(); + formattedLines.push(trimmedRight); + previousBlank = false; + continue; + } + + const headingMatch = trimmed.match(/^(#{1,6})\s*(.*)$/); + if (headingMatch) { + flushParagraph(); + const [, hashes, text] = headingMatch; + const headingText = text.trim(); + formattedLines.push(`${hashes} ${headingText}`.trimEnd()); + previousBlank = false; + continue; + } + + const hrMatch = trimmed.match(/^([*_\-])\1{2,}$/); + if (hrMatch) { + flushParagraph(); + const char = hrMatch[1]; + formattedLines.push(char.repeat(3)); + previousBlank = false; + continue; + } + + const listMatch = trimmedRight.match(/^(\s*)([-*+])\s*(.*)$/); + if (listMatch) { + flushParagraph(); + const [, indent, marker, text] = listMatch; + const cleaned = text.replace(/\s+/g, ' ').trim(); + const lineText = cleaned.length > 0 ? `${indent}${marker} ${cleaned}` : `${indent}${marker}`; + formattedLines.push(lineText); + previousBlank = false; + continue; + } + + const orderedMatch = trimmedRight.match(/^(\s*)(\d+)\.\s*(.*)$/); + if (orderedMatch) { + flushParagraph(); + const [, indent, index, text] = orderedMatch; + const cleaned = text.replace(/\s+/g, ' ').trim(); + const lineText = cleaned.length > 0 ? `${indent}${index}. ${cleaned}` : `${indent}${index}.`; + formattedLines.push(lineText); + previousBlank = false; + continue; + } + + const blockQuoteMatch = trimmedRight.match(/^(\s*)>\s*(.*)$/); + if (blockQuoteMatch) { + flushParagraph(); + const [, indent, text] = blockQuoteMatch; + const cleaned = text.replace(/\s+/g, ' ').trim(); + const prefix = `${indent}>`; + const quoteLines = cleaned.length > 0 ? wrapText(cleaned, Math.max(wrapColumn - prefix.length - 1, 20)) : []; + + if (quoteLines.length === 0) { + formattedLines.push(`${prefix}`); + } else { + formattedLines.push(`${prefix} ${quoteLines[0]}`); + for (let qi = 1; qi < quoteLines.length; qi++) { + formattedLines.push(`${indent}> ${quoteLines[qi]}`); + } + } + previousBlank = false; + continue; + } + + if (/^\s*\|.*\|\s*$/.test(trimmedRight)) { + flushParagraph(); + formattedLines.push(trimmedRight.replace(/\s+\|/g, ' |').replace(/\|\s+/g, '| ')); + previousBlank = false; + continue; + } + + if (/^\s{4,}/.test(trimmedRight)) { + // Indented code block - keep as-is. + flushParagraph(); + formattedLines.push(trimmedRight); + previousBlank = false; + continue; + } + + paragraphBuffer.push(trimmedRight); + previousBlank = false; + } else { + formattedLines.push(trimmedRight); + const fenceClose = trimmed.startsWith(fenceMarker); + if (fenceClose) { + insideFence = false; + fenceMarker = ''; + } + previousBlank = false; + } + } + + flushParagraph(); + + // Collapse trailing blank lines to a single newline if the original ended with one + while (formattedLines.length > 1 && formattedLines[formattedLines.length - 1] === '' && formattedLines[formattedLines.length - 2] === '') { + formattedLines.pop(); + } + + return formattedLines.join('\n'); +} + +export default formatMarkdown; diff --git a/tests/unit/config.test.js b/tests/unit/config.test.js index 901675b..ac3820a 100644 --- a/tests/unit/config.test.js +++ b/tests/unit/config.test.js @@ -32,9 +32,11 @@ describe('Config utilities', () => { expect(config).toHaveProperty('layout'); expect(config).toHaveProperty('showPreview'); expect(config).toHaveProperty('columnWidthRatio'); + expect(config).toHaveProperty('format'); expect(config).toHaveProperty('theme'); expect(config.layout).toBe('side'); expect(config.showPreview).toBe(true); + expect(config.format.wrapColumn).toBe(80); }); it('should load and merge custom config', () => { @@ -52,6 +54,7 @@ describe('Config utilities', () => { expect(config.defaultLocation).toBe('/custom/path'); expect(config.layout).toBe('stacked'); expect(config.showPreview).toBe(true); // Default value preserved + expect(config.format.wrapColumn).toBe(80); // Restore original config if (existingConfig) { diff --git a/tests/unit/formatMarkdown.test.js b/tests/unit/formatMarkdown.test.js new file mode 100644 index 0000000..d8b08c9 --- /dev/null +++ b/tests/unit/formatMarkdown.test.js @@ -0,0 +1,28 @@ +import { describe, it, expect } from '@jest/globals'; +import { formatMarkdown } from '../../src/utils/formatMarkdown.js'; + +describe('formatMarkdown', () => { + it('normalises spacing and wraps simple paragraphs', () => { + const input = 'This is a paragraph with\nirregular spacing that should be wrapped neatly.'; + const output = formatMarkdown(input, { wrapColumn: 40 }); + + expect(output).toBe([ + 'This is a paragraph with irregular', + 'spacing that should be wrapped neatly.' + ].join('\n')); + }); + + it('enforces consistent bullet spacing', () => { + const input = '- first item\n -second item'; + const output = formatMarkdown(input); + + expect(output).toBe(['- first item', ' - second item'].join('\n')); + }); + + it('preserves fenced code blocks', () => { + const input = ['```js', 'const x = 1; ', 'console.log(x);', '```'].join('\n'); + const output = formatMarkdown(input); + + expect(output).toBe(['```js', 'const x = 1;', 'console.log(x);', '```'].join('\n')); + }); +});