Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 |
Expand Down
24 changes: 24 additions & 0 deletions src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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) {
Expand All @@ -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();
Expand Down
49 changes: 41 additions & 8 deletions src/components/TextBuffer.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down
94 changes: 94 additions & 0 deletions src/utils/formatMarkdown.js
Original file line number Diff line number Diff line change
@@ -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) {
Comment on lines +13 to +18

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve Markdown hard line break spaces

The formatter unconditionally trims all trailing whitespace from every non‑code line (const trimmedEnd = originalLine.replace(/\s+$/u, '')). In Markdown, two trailing spaces before a newline are the canonical way to force a hard line break. Invoking the new Ctrl+Shift+F shortcut on text that relies on this syntax will remove those spaces and the renderer will collapse the break back into the same paragraph, altering the document’s meaning. The formatter should skip trimming when a line intentionally ends with two spaces.

Useful? React with 👍 / 👎.

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;
}
21 changes: 21 additions & 0 deletions tests/unit/formatMarkdown.test.js
Original file line number Diff line number Diff line change
@@ -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');
});
});