Skip to content

Commit edaf57c

Browse files
committed
Add markdown format shortcut
1 parent 956c0cb commit edaf57c

5 files changed

Lines changed: 182 additions & 8 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ _Distraction-free web editing with the same files from your CLI_
5454
- **Syntax highlighting** for Markdown (headers, bold, italic, code, links, lists)
5555
- **Text selection** with Shift+Arrow keys
5656
- **Undo/Redo** (Ctrl+Z/Ctrl+Y)
57+
- **Auto-formatting** (Ctrl+Shift+F) for clean Markdown
5758
- **Smart indentation** (Tab/Shift+Tab)
5859
- **Word jumping** (Ctrl+Arrow keys)
5960
- **Line operations** (join lines with backspace at start)
@@ -196,6 +197,7 @@ See [CLOUD.md](CLOUD.md) for detailed cloud documentation.
196197
| ------------- | --------------------------------------- |
197198
| **Ctrl+Z** | Undo |
198199
| **Ctrl+Y** | Redo |
200+
| **Ctrl+Shift+F** | Auto-format Markdown |
199201
| **Tab** | Indent (2 spaces) |
200202
| **Shift+Tab** | Unindent |
201203
| **Enter** | New line |

src/App.jsx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import Editor from './components/Editor.jsx';
66
import Preview from './components/Preview.jsx';
77
import StatusBar from './components/StatusBar.jsx';
88
import { syncFileOnSave } from './cloud/sync/syncOnSave.js';
9+
import { formatMarkdown } from './utils/formatMarkdown.js';
910

1011
export default function App({ filePath: initialFilePath, initialContent, layout: initialLayout, showPreview: initialShowPreview, config, onExit }) {
1112
const [filePath, setFilePath] = useState(initialFilePath);
@@ -118,6 +119,25 @@ export default function App({ filePath: initialFilePath, initialContent, layout:
118119
}
119120
}, [filePath, content, addTimeout]);
120121

122+
const handleFormat = useCallback(() => {
123+
try {
124+
const formatted = formatMarkdown(content);
125+
126+
if (formatted === content) {
127+
setMessage('Already formatted');
128+
addTimeout(() => setMessage(''), 1500);
129+
return;
130+
}
131+
132+
setContent(formatted);
133+
setMessage('Formatted markdown');
134+
addTimeout(() => setMessage(''), 2000);
135+
} catch (error) {
136+
setMessage(`Format error: ${error.message}`);
137+
addTimeout(() => setMessage(''), 3000);
138+
}
139+
}, [content, addTimeout]);
140+
121141
useInput((input, key) => {
122142
// Clear exit warning on any key press except Ctrl+X
123143
if (!(key.ctrl && input === 'x') && exitWarningShown) {
@@ -128,6 +148,10 @@ export default function App({ filePath: initialFilePath, initialContent, layout:
128148
if (key.ctrl && input === 's') {
129149
handleSave();
130150
}
151+
// Ctrl+Shift+F: Format markdown
152+
if (key.ctrl && key.shift && input && input.toLowerCase() === 'f') {
153+
handleFormat();
154+
}
131155
// Ctrl+O: Open config file (or return to original file if editing config)
132156
if (key.ctrl && input === 'o') {
133157
const configPath = getConfigPath();

src/components/TextBuffer.jsx

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -269,16 +269,49 @@ export default function TextBuffer({ content, onChange, isFocused = true, viewpo
269269
onChange(lines.map(l => l.text).join('\n'));
270270
}, [lines, onChange]);
271271

272-
// Reset when content prop changes (initial load only)
272+
// Sync editor when parent content changes (e.g., external formatting)
273273
useEffect(() => {
274-
if (lines.length === 0 || (lines.length === 1 && lines[0].text === '')) {
275-
const newLines = content.split('\n').map((line) => ({
276-
id: `line-${lineIdCounter++}`,
277-
text: line
278-
}));
279-
setLines(newLines);
274+
const currentContent = lines.map(l => l.text).join('\n');
275+
if (content === currentContent) {
276+
return;
280277
}
281-
}, []);
278+
279+
const newLines = content.split('\n').map((line) => ({
280+
id: `line-${lineIdCounter++}`,
281+
text: line
282+
}));
283+
284+
setLines(newLines);
285+
setSelection(null);
286+
287+
const newCursorLine = Math.min(cursorLine, newLines.length - 1);
288+
const newCursorCol = Math.min(newLines[newCursorLine]?.text?.length || 0, cursorCol);
289+
setCursorLine(newCursorLine);
290+
setCursorCol(newCursorCol);
291+
292+
setHistory(prevHistory => {
293+
const truncated = prevHistory
294+
.slice(0, historyIndex + 1)
295+
.map(state => ({
296+
lines: state.lines.map(l => ({ ...l })),
297+
cursorLine: state.cursorLine,
298+
cursorCol: state.cursorCol,
299+
}));
300+
301+
truncated.push({
302+
lines: newLines.map(l => ({ ...l })),
303+
cursorLine: newCursorLine,
304+
cursorCol: newCursorCol,
305+
});
306+
307+
if (truncated.length > 100) {
308+
truncated.shift();
309+
}
310+
311+
setHistoryIndex(truncated.length - 1);
312+
return truncated;
313+
});
314+
}, [content, cursorCol, cursorLine, historyIndex, lines]);
282315

283316
// Keep cursor visible by adjusting scroll offset
284317
useEffect(() => {

src/utils/formatMarkdown.js

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
export function formatMarkdown(content) {
2+
if (typeof content !== 'string' || content.length === 0) {
3+
return '';
4+
}
5+
6+
const normalized = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
7+
const lines = normalized.split('\n');
8+
const formatted = [];
9+
10+
let blankLineCount = 0;
11+
let inCodeFence = false;
12+
13+
for (let i = 0; i < lines.length; i++) {
14+
const originalLine = lines[i];
15+
const trimmedEnd = originalLine.replace(/\s+$/u, '');
16+
const fenceMatch = trimmedEnd.trimStart().match(/^(?:```+|~~~+)(.*)$/);
17+
18+
if (inCodeFence) {
19+
formatted.push(originalLine);
20+
if (fenceMatch) {
21+
inCodeFence = false;
22+
blankLineCount = 0;
23+
}
24+
continue;
25+
}
26+
27+
if (fenceMatch) {
28+
formatted.push(trimmedEnd);
29+
inCodeFence = true;
30+
blankLineCount = 0;
31+
continue;
32+
}
33+
34+
if (trimmedEnd.trim() === '') {
35+
if (formatted.length === 0) {
36+
continue;
37+
}
38+
blankLineCount += 1;
39+
if (blankLineCount > 1) {
40+
continue;
41+
}
42+
formatted.push('');
43+
continue;
44+
}
45+
46+
blankLineCount = 0;
47+
let processedLine = trimmedEnd;
48+
49+
const headingMatch = processedLine.match(/^(#{1,6})(\s*)(.*)$/);
50+
if (headingMatch) {
51+
const [, hashes, , text] = headingMatch;
52+
const headingText = text.trim();
53+
processedLine = headingText ? `${hashes} ${headingText}` : hashes;
54+
formatted.push(processedLine);
55+
continue;
56+
}
57+
58+
const blockquoteMatch = processedLine.match(/^(\s*>+)(\s*)(.*)$/);
59+
if (blockquoteMatch) {
60+
const [, markers, , text] = blockquoteMatch;
61+
const quoteText = text.replace(/^\s+/, '');
62+
processedLine = quoteText ? `${markers} ${quoteText}` : markers;
63+
formatted.push(processedLine);
64+
continue;
65+
}
66+
67+
const listMatch = processedLine.match(/^(\s*)(?:([-*+])|(\d+\.))(\s*)(.*)$/);
68+
if (listMatch) {
69+
const indent = listMatch[1] || '';
70+
const bullet = listMatch[2] || listMatch[3] || '';
71+
const listText = (listMatch[5] || '').replace(/^\s+/, '');
72+
const spacer = listText ? ' ' : '';
73+
processedLine = `${indent}${bullet}${spacer}${listText}`;
74+
formatted.push(processedLine);
75+
continue;
76+
}
77+
78+
formatted.push(processedLine);
79+
}
80+
81+
while (formatted.length > 0 && formatted[formatted.length - 1] === '') {
82+
formatted.pop();
83+
}
84+
85+
if (formatted.length === 0) {
86+
return '';
87+
}
88+
89+
let result = formatted.join('\n');
90+
if (!result.endsWith('\n')) {
91+
result += '\n';
92+
}
93+
return result;
94+
}

tests/unit/formatMarkdown.test.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { formatMarkdown } from '../../src/utils/formatMarkdown.js';
2+
3+
describe('formatMarkdown', () => {
4+
it('normalizes headings and ensures a trailing newline', () => {
5+
const input = '#Heading\n\nSome text';
6+
const output = formatMarkdown(input);
7+
expect(output).toBe('# Heading\n\nSome text\n');
8+
});
9+
10+
it('collapses excessive blank lines and trims list spacing', () => {
11+
const input = '- item one\n\n\n- item two';
12+
const output = formatMarkdown(input);
13+
expect(output).toBe('- item one\n\n- item two\n');
14+
});
15+
16+
it('preserves code fences without altering inner content', () => {
17+
const input = '```js\nconst value = 1; \n```\n';
18+
const output = formatMarkdown(input);
19+
expect(output).toBe('```js\nconst value = 1; \n```\n');
20+
});
21+
});

0 commit comments

Comments
 (0)