Skip to content

Commit 5f9a785

Browse files
committed
feat: add code formatting and RTL/LTR direction — v1.2.0
- Code button: inline <code> wrapping for selections, <pre> block for cursor-only; clicking again toggles off - Direction buttons (RTL/LTR): sets dir attribute + inline style (direction + text-align) directly on the block element; per-block, toggleable, always one active - defaultDir prop: sets default text direction on the editor container - Fix: typing after clearing editor now always starts inside a <p> - Fix: direction feature rewrote block detection to handle missing selection and avoid unreliable <div> wrapper approach - Docs: README updated with direction section, defaultDir prop table entry, fixed heading typos; CHANGELOG entry for v1.2.0
1 parent a0d95b7 commit 5f9a785

14 files changed

Lines changed: 393 additions & 32 deletions

CHANGELOG.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,27 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [1.2.0] - 2026-06-11
9+
10+
### Added
11+
- **Code formatting** — new `code` feature flag and toolbar button with two modes:
12+
- Text selected → wraps in inline `<code>`
13+
- No selection / cursor in a block → converts to `<pre>` code block
14+
- Clicking again inside `<code>` or `<pre>` removes the formatting (toggle)
15+
- Both `<code>` and `<pre>` share the same visual style (monospace font, subtle background, accent border-left)
16+
- **RTL / LTR direction** — new `direction` feature flag adds two toolbar buttons:
17+
- Sets `dir` attribute and matching inline `style` (`direction` + `text-align`) directly on the current block element
18+
- Per-block direction — each paragraph or heading can have its own direction
19+
- Toggle behavior — clicking the active direction button removes the direction
20+
- One button is always highlighted: current block dir → `defaultDir``'ltr'`
21+
- Direction is written as inline style so output renders correctly without the editor stylesheet
22+
- **`defaultDir` prop** — sets the default text direction (`'rtl'` | `'ltr'`) for the entire editor content area; useful for Persian / Arabic editors
23+
- New SVG icons: `IconDirectionRTL`, `IconDirectionLTR`, `IconCode`
24+
25+
### Fixed
26+
- Typing after clearing the editor produced bare text nodes not wrapped in `<p>``handleFocus` now inserts a `<p><br></p>` and positions the cursor inside when the editor is empty; `handleInput` also catches any remaining bare text nodes and wraps them
27+
- Direction buttons previously used a `<div>` wrapper approach which broke block detection when no saved range was present; rewritten to set `dir` directly on the block element and handle missing selection gracefully
28+
829
## [1.1.0] - 2026-06-08
930

1031
### Added

README.md

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
<p align="center">
1010
<strong>A modern, lightweight WYSIWYG rich text editor for React & Next.js</strong><br/>
11-
Feature-flagged · Fully typed · Zero runtime dependencies · &lt;300KB
11+
Feature-flagged · Fully typed · Zero runtime dependencies · &lt;400KB
1212
</p>
1313

1414
<p align="center">
@@ -80,7 +80,7 @@ export default function MyPage() {
8080

8181
## API Reference
8282

83-
###rops
83+
### Props
8484

8585
| Prop | Type | Default | Description |
8686
| -------------- | ------------------------- | --------------------------- | ---------------------------------------- |
@@ -96,11 +96,12 @@ export default function MyPage() {
9696
| `media` | `MediaConfig` || File upload / library config |
9797
| `theme` | `'classic' \| 'modern'` | `'classic'` | Visual theme |
9898
| `colorScheme` | `'light' \| 'dark'` | `'light'` | Color scheme |
99+
| `defaultDir` | `'rtl' \| 'ltr'` || Default text direction for the editor |
99100
| `height` | `number` | `400` | Min height of editor content area (px) |
100101
| `className` | `string` || Extra CSS class on root element |
101102
| `apiToken` | `string` || Token for future paid pro features |
102103

103-
###mperative Handle (ref)
104+
### Imperative Handle (ref)
104105

105106
```tsx
106107
import { useRef } from "react";
@@ -180,7 +181,55 @@ Disable any toolbar button by setting its flag to `false`:
180181
/>
181182
```
182183

183-
Full list of flags: `bold`, `italic`, `strikethrough`, `lists`, `blockquote`, `hr`, `align`, `link`, `fullscreen`, `kitchenSink`, `underline`, `justify`, `foreColor`, `pasteAsText`, `removeFormat`, `charMap`, `indent`, `undo`, `help`, `media`, `subscript`, `superscript`
184+
Full list of flags: `bold`, `italic`, `strikethrough`, `lists`, `blockquote`, `hr`, `align`, `link`, `code`, `direction`, `fullscreen`, `kitchenSink`, `underline`, `justify`, `foreColor`, `pasteAsText`, `removeFormat`, `charMap`, `indent`, `undo`, `help`, `media`, `subscript`, `superscript`
185+
186+
---
187+
188+
## Code Formatting
189+
190+
The `code` feature adds a **Code** button to the toolbar. It has two modes depending on the selection:
191+
192+
| Context | Result |
193+
|---|---|
194+
| Text selected | Wraps in inline `<code>` |
195+
| No selection / cursor in a block | Converts block to `<pre>` (code block) |
196+
| Click again inside `<code>` or `<pre>` | Removes the formatting |
197+
198+
Both `<code>` and `<pre>` share the same visual style — monospace font, subtle background from `--me-textarea-bg`, and a matching border — so inline and block code look like a family.
199+
200+
```tsx
201+
// Disable the code button
202+
<MandooEditor features={{ code: false }} />
203+
```
204+
205+
---
206+
207+
## RTL / LTR Direction
208+
209+
The `direction` feature adds **RTL** and **LTR** toggle buttons to the toolbar. Direction is applied per block — each paragraph or heading can have its own direction independently.
210+
211+
| Action | Result |
212+
|---|---|
213+
| Click RTL | Sets `dir="rtl" style="direction:rtl; text-align:right"` on the current block |
214+
| Click LTR | Sets `dir="ltr" style="direction:ltr; text-align:left"` on the current block |
215+
| Click the active button again | Removes direction from the block (toggle off) |
216+
217+
One button is always highlighted: the active block's direction, or `defaultDir` if set, or LTR by default.
218+
219+
```tsx
220+
// RTL-first editor (e.g. Persian / Arabic content)
221+
<MandooEditor defaultDir="rtl" />
222+
223+
// Disable the direction buttons entirely
224+
<MandooEditor features={{ direction: false }} />
225+
```
226+
227+
Direction is stored inline in the HTML output so it renders correctly anywhere, without requiring the editor's stylesheet:
228+
229+
```html
230+
<p dir="rtl" style="direction: rtl; text-align: right;">متن فارسی</p>
231+
<p dir="ltr" style="direction: ltr; text-align: left;">English paragraph</p>
232+
```
184233

185234
---
186235

@@ -226,7 +275,7 @@ Wire any storage backend — S3, MinIO, Cloudflare R2, or your own API:
226275
/>
227276
```
228277

229-
###inIO / S3 Server Route (Next.js App Router)
278+
### MinIO / S3 Server Route (Next.js App Router)
230279

231280
```ts
232281
// app/api/upload/route.ts

package-lock.json

Lines changed: 25 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "mandoo-editor",
3-
"version": "1.1.0",
3+
"version": "1.2.0",
44
"description": "A modern, lightweight WYSIWYG rich text editor for React & Next.js — feature-flagged, fully typed, zero runtime dependencies.",
55
"author": "Mohammad Ali Rahimi <hi@markrahimi.com> (https://markrahimi.com)",
66
"license": "MIT",

src/MandooEditor.tsx

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ const MandooEditor = forwardRef<MandooEditorHandle, MandooEditorProps>(function
4646
name,
4747
theme = DEFAULT_THEME,
4848
colorScheme = DEFAULT_COLOR_SCHEME,
49+
defaultDir,
4950
height = DEFAULT_HEIGHT,
5051
className,
5152
},
@@ -159,8 +160,17 @@ const MandooEditor = forwardRef<MandooEditorHandle, MandooEditorProps>(function
159160

160161
// Wrap handleInput to pass onChange
161162
const handleInput = useCallback(() => {
163+
// Fix bare text nodes — wrap in <p> if the first child is a text node
164+
if (editorRef.current) {
165+
const fc = editorRef.current.firstChild;
166+
if (fc && fc.nodeType === Node.TEXT_NODE) {
167+
const p = document.createElement('p');
168+
editorRef.current.insertBefore(p, fc);
169+
p.appendChild(fc);
170+
}
171+
}
162172
_handleInput(handleChange);
163-
}, [_handleInput, handleChange]);
173+
}, [_handleInput, handleChange, editorRef]);
164174

165175
// Wrap handleKeyDown to pass exec
166176
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
@@ -179,8 +189,17 @@ const MandooEditor = forwardRef<MandooEditorHandle, MandooEditorProps>(function
179189
}, [handleSelectionChange]);
180190

181191
const handleFocus = useCallback(() => {
182-
if (editorRef.current && editorRef.current.innerHTML === '') {
183-
document.execCommand('formatBlock', false, 'p');
192+
if (!editorRef.current) return;
193+
// When editor is empty, insert a real <p> so typing starts inside a block
194+
if (!editorRef.current.firstChild) {
195+
const p = document.createElement('p');
196+
p.innerHTML = '<br>';
197+
editorRef.current.appendChild(p);
198+
const range = document.createRange();
199+
range.setStart(p, 0);
200+
range.collapse(true);
201+
window.getSelection()?.removeAllRanges();
202+
window.getSelection()?.addRange(range);
184203
}
185204
}, []);
186205

@@ -353,6 +372,8 @@ const MandooEditor = forwardRef<MandooEditorHandle, MandooEditorProps>(function
353372
<Toolbar
354373
activeFormats={state.activeFormats}
355374
currentBlock={state.currentBlock}
375+
currentDir={state.currentDir}
376+
defaultDir={defaultDir}
356377
isFullscreen={state.isFullscreen}
357378
showKitchenSink={state.showKitchenSink}
358379
pasteAsText={state.pasteAsText}
@@ -379,6 +400,7 @@ const MandooEditor = forwardRef<MandooEditorHandle, MandooEditorProps>(function
379400
initialValue={htmlValue}
380401
placeholder={placeholder}
381402
height={height}
403+
dir={defaultDir}
382404
onInput={handleInput}
383405
onKeyDown={handleKeyDown}
384406
onPaste={handlePaste}

src/constants.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ export const ALL_FEATURES_ON: Required<Features> = {
3131
media: true,
3232
subscript: true,
3333
superscript: true,
34+
code: true,
35+
direction: true,
3436
};
3537

3638
/** Merge partial feature flags with all-enabled defaults */

src/editor/VisualEditor.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ interface VisualEditorProps {
77
initialValue?: string;
88
placeholder?: string;
99
height?: number;
10+
dir?: 'rtl' | 'ltr';
1011
onInput: () => void;
1112
onKeyDown: (e: React.KeyboardEvent) => void;
1213
onPaste: (e: React.ClipboardEvent) => void;
@@ -18,6 +19,7 @@ export default function VisualEditor({
1819
initialValue = '',
1920
placeholder = 'Start writing…',
2021
height = 300,
22+
dir,
2123
onInput,
2224
onKeyDown,
2325
onPaste,
@@ -40,6 +42,7 @@ export default function VisualEditor({
4042
contentEditable
4143
suppressContentEditableWarning
4244
data-placeholder={placeholder}
45+
dir={dir}
4346
style={{ minHeight: height }}
4447
onInput={onInput}
4548
onKeyDown={onKeyDown}

src/hooks/useEditorState.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { EditorState, ExecCommands } from '../types';
66
const INITIAL_STATE: EditorState = {
77
activeFormats: new Set(),
88
currentBlock: 'p',
9+
currentDir: '',
910
wordCount: 0,
1011
charCount: 0,
1112
pathElements: [],
@@ -38,12 +39,39 @@ export function useEditorState(editorRef: React.RefObject<HTMLDivElement | null>
3839
// execCommand state queries can throw in some contexts
3940
}
4041

42+
// Detect inline <code> around cursor
43+
try {
44+
const sel = window.getSelection();
45+
if (sel && sel.anchorNode) {
46+
let n: Node | null = sel.anchorNode;
47+
if (n.nodeType === Node.TEXT_NODE) n = n.parentNode;
48+
while (n && n !== document.body) {
49+
if ((n as Element).tagName === 'CODE') { formats.add('code'); break; }
50+
n = n.parentNode;
51+
}
52+
}
53+
} catch { /* empty */ }
54+
4155
let currentBlock = 'p';
4256
try {
4357
const val = document.queryCommandValue('formatBlock');
4458
currentBlock = val?.replace(/^<|>$/g, '') || 'p';
4559
} catch { /* empty */ }
4660

61+
let currentDir: 'rtl' | 'ltr' | '' = '';
62+
try {
63+
const sel = window.getSelection();
64+
if (sel && sel.anchorNode) {
65+
let n: Node | null = sel.anchorNode;
66+
if (n.nodeType === Node.TEXT_NODE) n = n.parentNode;
67+
while (n && n !== editorRef.current) {
68+
const dir = (n as Element).getAttribute?.('dir');
69+
if (dir === 'rtl' || dir === 'ltr') { currentDir = dir; break; }
70+
n = n.parentNode;
71+
}
72+
}
73+
} catch { /* empty */ }
74+
4775
const text = editorRef.current?.innerText || '';
4876
const words = text.trim() ? text.trim().split(/\s+/).filter(Boolean) : [];
4977
const charCount = text.replace(/\s/g, '').length;
@@ -54,6 +82,7 @@ export function useEditorState(editorRef: React.RefObject<HTMLDivElement | null>
5482
...prev,
5583
activeFormats: formats,
5684
currentBlock,
85+
currentDir,
5786
wordCount: words.length,
5887
charCount,
5988
pathElements: path,

0 commit comments

Comments
 (0)