Skip to content

Latest commit

 

History

19 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

DV Rich Editor

A Dhivehi rich text editor featuring built-in Thaana keyboard support, dynamic theming, and clean markdown output. This is an effort to better handle markdown images and custom tags correctly in RTL inputs. It also gracefully manages image spacing and backspace behavior during Dhivehi typing, preventing unintended issues such as breaking URLs and other markdown tags.

Thaana keyboard IME (experimental): combines direct character mapping with a short-lived akuru–fili buffer for natural composition. Design may evolve; feedback welcome.

Quick Start

Installation

npm install dv-rich-editor
# or
yarn add dv-rich-editor
# or
pnpm add dv-rich-editor

React

import { useState } from 'react';
import { DVRichEditor } from 'dv-rich-editor/react';

export default function Basic() {
  const [md, setMd] = useState('');
  return <DVRichEditor onChange={setMd} placeholder="test" theme={{ name: 'default' }} />;
}

Vanilla (bundler)

import { DhivehiRichEditor } from 'dv-rich-editor';

const editor = new DhivehiRichEditor({
  container: '#editor-container',
  placeholder: 'test',
  onChange: (markdown) => console.log(markdown)
});

UMD (script tag)

<!DOCTYPE html>
<html>
<head>
  <title>DV Rich Editor</title>
  <script src="dist/dv-rich-editor.umd.js"></script>
</head>
<body>
  <div id="editor-container"></div>
    
  <script>
    const { DhivehiRichEditor } = window.DVRichEditor;
        
    const editor = new DhivehiRichEditor({
      container: '#editor-container',
      placeholder: 'test',
      onChange: (markdown) => console.log(markdown)
    });
  </script>
</body>
</html>

Minimal Formatting (React toolbar sketch)

// ref.toggleBold(), ref.setHeading(1) etc.

Features (summary)

  • Built-in Thaana IME.
  • RTL-aware selection helpers
  • Markdown-first: incremental serializer with fallback, tables, code blocks, lists
  • Granular formatting API (toggleBold, setHeading, insertBulletList, etc.)
  • Theming via predefined themes + CSS variables (--dv-*)
  • Image insertion hook (onImageUrlRequest) + plugin path
  • Controlled or uncontrolled React usage (debounced controlled updates)
  • Event bus: content-change, selection-change, format-change, IME buffer events, perf events
  • Lightweight core, no heavy framework dependency outside React wrapper
  • Programmatic content insertion helper insertMarkdown (parse or literal modes)
  • Selection format introspection (none | partial | all | mixed for headings)
  • Heading level resolver utility (getActiveHeadingLevel) for accurate toolbar display
  • True inline toggle semantics (full selection unwraps instead of nesting)
  • React hook useFormatState for real‑time selection formatting state

React Selection Format State (Toolbar Example)

In React you can track the current selection formatting (bold / italic / underline / strikethrough / code + block formats) via the useFormatState hook. It listens to the editor's internal format-change event and returns a tri‑state object:

interface SelectionFormatState {
  inline: {
    bold: 'none' | 'partial' | 'all';
    italic: 'none' | 'partial' | 'all';
    underline: 'none' | 'partial' | 'all';
    strikethrough: 'none' | 'partial' | 'all';
    code: 'none' | 'partial' | 'all';
  };
  block: {
    heading: 'none' | 'partial' | 'all' | 'mixed';
    blockquote: 'none' | 'partial' | 'all';
    codeBlock: 'none' | 'partial' | 'all';
    bulletList: 'none' | 'partial' | 'all';
    numberedList: 'none' | 'partial' | 'all';
  };
  allActiveFormats: FormatType[];    // formats fully applied across selection
  partialFormats: FormatType[];      // formats partially applied (or heading mixed)
}

External Toolbar (Current Pattern)

useFormatState() only works inside the editor's internal context provider. The current <DVRichEditor /> implementation does not render children, so a sibling toolbar cannot consume that hook directly. Until children support is added, subscribe to the format-change event yourself:

import { useEffect, useRef, useState } from 'react';
import { DVRichEditor } from 'dv-rich-editor/react';
import type { DhivehiRichEditorRef } from 'dv-rich-editor/react';
import type { SelectionFormatState } from 'dv-rich-editor/types';

function useExternalFormatState(ref: React.RefObject<DhivehiRichEditorRef | null>) {
  const [fmt, setFmt] = useState<SelectionFormatState | null>(null);
  useEffect(() => {
    const inst = ref.current as unknown as { on?: Function; off?: Function } | null;
    if (!inst || !inst.on) return;
    const handler = (p: { state?: SelectionFormatState }) => setFmt(p.state || null);
    // @ts-ignore dynamic event API
    inst.on('format-change', handler);
    return () => { inst.off && inst.off('format-change', handler); };
  }, [ref.current]);
  return fmt;
}

function Toolbar({ editorRef }: { editorRef: React.RefObject<DhivehiRichEditorRef | null> }) {
  const fmt = useExternalFormatState(editorRef);
  const boldState = fmt?.inline.bold;
  return (
    <div className="toolbar">
      <button
        onMouseDown={e => { e.preventDefault(); editorRef.current?.toggleBold?.(); }}
        aria-pressed={boldState === 'all'}
        data-partial={boldState === 'partial'}
      >B</button>
    </div>
  );
}

export default function EditorWithToolbar() {
  const ref = useRef<DhivehiRichEditorRef | null>(null);
  return (
    <div>
      <Toolbar editorRef={ref} />
      <DVRichEditor ref={ref} placeholder="Type here..." theme={{ name: 'default' }} />
    </div>
  );
}

Important:

  • Use onMouseDown + preventDefault to preserve selection.
  • Tri-state values: none | partial | all (and mixed for heading) — style partial/mixed distinctly.
  • For now, do not rely on useFormatState() in a sibling component; it will be enabled once children/provider support is introduced.
  • A temporary test-only helper exists: ref.current?.__forceFormatStateEmit?.().

For a deeper explanation and advanced patterns see: docs/TOOLBAR_INTEGRATION.md.

Distinguishing Heading Levels

If fmt.block.heading === 'all' you can query the active level via the ref method getActiveHeadingLevel() (available on the underlying editor instance via ref.current?.getActiveHeadingLevel?.()). Use that to highlight the active heading button.

Testing / Internal Helpers

For unit tests where jsdom selection events can be flaky, the core exposes internal, non-public helpers (only when you hold the instance ref):

ref.current?.__forceFormatStateEmit?.();                // forces a format-change event
const raw = ref.current?.__getSelectionFormatStateRaw?.(); // direct computation (no event)

These are intentionally undocumented in the public API surface and may change—avoid using them in production code.

Pass theme={{ name: 'dark' }} or a custom object (colors, typography, spacing). Override with your own CSS targeting the exposed variables.

React Controlled vs Uncontrolled

Uncontrolled: defaultValue (or initialContent) and onChange. Controlled: supply value + onChange and optionally performance.debounceControlledMs.

Events

editor.on('content-change', cb) etc. Also: selection-change, format-change, ime-buffer-start|commit|flush, perf, focus, blur.

Migration

See CHANGELOG.md

Images

Basic Image Support

<DVRichEditor
  image={{
    preview: true,           // Show actual images in editor
    defaultAltText: 'Image'  // Default alt text
  }}
/>

Custom upload (outline): implement onImageUrlRequest returning a URL string.

const handleImageUpload = async (): Promise<string> => {
  // Open file picker
  const file = await new Promise<File>((resolve) => {
    const input = document.createElement('input');
    input.type = 'file';
    input.accept = 'image/*';
    input.onchange = (e) => resolve((e.target as HTMLInputElement).files![0]);
    input.click();
  });

  // Upload to your service
  const formData = new FormData();
  formData.append('image', file);
  
  const response = await fetch('/api/upload', {
    method: 'POST',
    body: formData
  });
  
  const { url } = await response.json();
  return url;
};

<DVRichEditor
  onImageUrlRequest={handleImageUpload}
  image={{ preview: true }}
/>

Thaana Key Mapping

const customKeyMap = {
  'q': 'ް',  // sukun
  // ... your custom mappings
};

<DVRichEditor
  thaana={{
    enabled: true,
    keyMap: customKeyMap
  }}
/>

Toggle at runtime: call ref.setThaanaEnabled(bool).

const editorRef = useRef<DhivehiRichEditorRef>(null);

// Toggle Thaana input dynamically
const toggleThaana = () => {
  const isEnabled = editorRef.current?.getThaanaConfig()?.enabled;
  editorRef.current?.setThaanaEnabled(!isEnabled);
};

Reference (selected)

Component Props (React)

interface DhivehiRichEditorProps {
  // Content
  placeholder?: string;
  initialContent?: string;
  onChange?: (markdown: string) => void;
  
  // Styling
  className?: string;
  style?: React.CSSProperties;
  theme?: EditorTheme;
  styling?: EditorStyling;
  
  // Features
  image?: ImageConfig;
  thaana?: ThaanaKeyboardLayout;
  
  // Events
  onFocus?: () => void;
  onBlur?: () => void;
  onImageUrlRequest?: () => Promise<string>;
}

### Markdown Options

You can control certain markdown output aspects via the `markdown` option:

```ts
new DhivehiRichEditor({
  container: '#editor',
  markdown: {
    listStyle: 'asterisk', // 'dash' (default) | 'asterisk' | 'plus'
  }
});```


### Markdown Output Features (summary)

Current HTML  Markdown serialization supports:

| Feature | Supported | Notes |
|---------|-----------|-------|
| Headings h1–h6 |  | ATX style (`#`, `##`, ) |
| Bold / Italic / Strikethrough / Code |  | Inline formatting tokens preserved |
| Blockquotes |  | `>` prefix |
| Bullet & Numbered Lists |  | Bullet marker configurable (`listStyle`) |
| Images |  | `![alt](src)` (title optional via insertion API) |
| Links |  | `[text](href)` |
| Code Blocks |  | Preserves fence language from `<pre><code class="language-js">`  ```js |
| Tables | ✅ | Serializes `<table>` into GitHub‑style pipe tables |
| Escaping | ✅ | Escapes markdown control chars in text nodes (leaves existing raw `[]` for previously inserted image/link markdown) |

Example table serialization:

```html
<table>
  <tr><th>Lang</th><th>Word</th></tr>
  <tr><td>EN</td><td>Hello</td></tr>
  <tr><td>DV</td><td>ހަދިޔަ</td></tr>
</table>
| Lang | Word |
| --- | --- |
| EN | Hello |
| DV | ލޯލް |

Note: alignment / colspan not yet supported.

Editor Methods (abridged)

interface DhivehiRichEditorRef {
  // Content Management
  getMarkdown(): string;
  setMarkdown(content: string, preserveFocus?: boolean): void;
  clear(): void;
  insertText(text: string): void;
  insertMarkdown(markdown: string, options?: {
    parse?: boolean;
    sanitize?: boolean;
    schedule?: 'immediate' | 'debounced';
    collapseSelection?: 'after' | 'start';
    literal?: boolean;
  }): void;
  appendContent(content: string): void;

  // Formatting (granular API)
  toggleBold(): void;
  toggleItalic(): void;
  toggleUnderline(): void;
  toggleStrikethrough(): void;
  toggleCode(): void;
  toggleCodeBlock(): void;
  toggleBlockquote(): void;
  setHeading(level: 1|2|3|4|5|6): void;
  insertBulletList(): void;
  insertNumberedList(): void;
  insertImageFormat(): void; // opens image dialog / inserts via ImagePlugin
  isFormatActive(format: FormatType): boolean;
  // Tri-state selection formatting (inline + block)
  getSelectionFormatState?(): SelectionFormatState;
  // Uniform heading level across selection (1-6) or null if none / mixed
  getActiveHeadingLevel?(): number | null;

  // Focus & Selection
  focus(): void;
  blur(): void;
  getSelection(): TextSelection;


  // Clipboard
  copyToClipboard(): Promise<boolean>;
  pasteFromClipboard(): Promise<boolean>;

  // Thaana
  setThaanaEnabled(enabled: boolean): void;
  getThaanaConfig(): ThaanaKeyboardLayout;
  updateThaanaKeyMap(keyMap: Record<string, string>): void;

  // Theming
  updateTheme(theme: EditorTheme): void;

  // Lifecycle
  destroy(): void;
}

Format Types

type FormatType = 
  // Text Formatting
  | 'bold'           // **text** or <strong>
  | 'italic'         // *text* or <em>
  | 'underline'      // <u>text</u>
  | 'strikethrough'  // ~~text~~
  
  // Headings
  | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
  
  // Lists
  | 'bullet-list'    // - item
  | 'numbered-list'  // 1. item
  
  // Other
  | 'blockquote'     // > text
  | 'code'           // `code`
  | 'code-block'     // ```code```
  | 'link'           // [text](url)
  | 'image';         // ![alt](url)

Programmatic Markdown Insertion (insertMarkdown)

When you need to inject content at the current caret position (slash commands, AI assist, paste enrichment, image markdown, etc.) use insertMarkdown instead of the low-level insertText.

Why:

  • Deterministic change scheduling (no race with MutationObserver)
  • Optional parsing of the markdown into HTML immediately (parse: true)
  • Optional literal insertion (escaped as needed) with parse: false or literal: true
  • Respects existing sanitizeMarkdown / sanitizeHtml hooks
  • Marks inserted nodes dirty for the incremental serializer (efficient partial updates)

Signature:

editor.insertMarkdown(markdown: string, options?: {
  parse?: boolean;          // default true (markdown -> HTML -> serialize later)
  sanitize?: boolean;       // default true (passes through provided sanitizers)
  schedule?: 'immediate' | 'debounced'; // default 'immediate'
  collapseSelection?: 'after' | 'start'; // where caret lands post insert (default 'after')
  literal?: boolean;        // alias for parse:false
});

Examples:

// Insert markdown, converting **bold** to <strong> immediately
editor.insertMarkdown('**bold** and *italic*');

// Insert literal characters (will appear with escaping so they are NOT interpreted as formatting)
// NOTE: parse:false (or literal:true) causes control chars to be escaped: '**raw**' -> '\\*\\*raw\\*\\*'
editor.insertMarkdown('**raw markers**', { parse: false });

// Insert code block snippet at caret
editor.insertMarkdown('\n```js\nconsole.log(1)\n```\n');

React ref usage:

const ref = useRef<DhivehiRichEditorRef>(null);
ref.current?.insertMarkdown('![alt](https://example.com/img.png)');

Use insertText only for simulating keystrokes / IME style character-level insertion. Prefer insertMarkdown for semantic content.

Image Plugin Note: The internal ImagePlugin now requires insertMarkdown (provided automatically by the core editor). If you ever embed the plugin context manually, ensure you pass an insertMarkdown helper; otherwise image insertion will throw.

Inline Toggle Semantics

Inline format helpers (toggleBold, toggleItalic, toggleUnderline, toggleStrikethrough, toggleCode) perform a true toggle:

  1. Selection not fully covered → format is applied (wrapping selected non-whitespace text nodes).
  2. 100% of non-whitespace text nodes already inside the format → all matching wrappers wholly contained in the selection are unwrapped (children lifted out, avoiding nested <strong> chains).
  3. Inline code ignores code inside <pre><code> (no accidental inline code toggling within code blocks).

Additional niceties:

  • Caret normalization automatically exits an inline element when you reach its end so typing continues plain.
  • Whitespace-only text nodes are ignored for coverage calculation.
  • Multiple adjacent formatted spans are treated as a single logical coverage region.

Result: Toolbar buttons reflect predictable toggling (second press removes formatting instead of nesting new tags).

Heading Level Introspection

Use getActiveHeadingLevel() to obtain a uniform heading level (1–6) for the current selection, or null if none or mixed:

const level = editorRef.current?.getActiveHeadingLevel();
// 1..6 when single consistent heading, otherwise null

Combine with getSelectionFormatState() where block.heading is 'none' | 'all' | 'mixed' for tri-state UI. A mixed state occurs when multiple heading levels or partial coverage is selected.

Formatting State

When a selection spans content that is partially formatted (e.g. half bold, half plain) you often need an "indeterminate" UI state (checkbox style) for toolbar buttons. The editor now emits an enhanced format-change event payload { formats: FormatType[]; state?: SelectionFormatState } where state includes granular inline + block presence with values:

'none' | 'partial' | 'all' (and 'mixed' specifically for heading when multiple heading levels are selected).

Shape:

interface SelectionFormatState {
  inline: { bold: FormatPresence; italic: FormatPresence; underline: FormatPresence; strikethrough: FormatPresence; code: FormatPresence };
  block: { heading: FormatPresence | 'mixed'; blockquote: FormatPresence; codeBlock: FormatPresence; bulletList: FormatPresence; numberedList: FormatPresence };
  allActiveFormats: FormatType[];   // formats fully applied across selection
  partialFormats: FormatType[];     // formats partially applied (or heading mixed)
}

In React you can consume this using the provided hook:

import { useFormatState } from 'dv-rich-editor/react';

function Toolbar() {
  const state = useFormatState();
  const boldPresence = state?.inline.bold; // 'none' | 'partial' | 'all'
  // Render a tri-state toggle accordingly
}

Toolbar example logic for a button:

<button
  data-active={state?.inline.bold === 'all'}
  data-partial={state?.inline.bold === 'partial'}
  aria-pressed={state?.inline.bold === 'all'}
>
  B
</button>

Security

No built-in sanitization. Supply sanitizeHtml / sanitizeMarkdown if needed before storing or rendering untrusted content.

Troubleshooting

Common Issues

TypeScript errors with imports:

// ✅ Correct way
import { DVRichEditor } from 'dv-rich-editor/react';
import type { DhivehiRichEditorRef } from 'dv-rich-editor/react';

// ❌ Incorrect way  
import { DVRichEditor, DhivehiRichEditorRef } from 'dv-rich-editor/react';

Thaana input not working:

// Ensure Thaana is enabled
<DVRichEditor
  thaana={{ enabled: true }}
  placeholder="test"
/>

End.

About

A Dhivehi rich text editor plugin with Thaana keyboard support and markdown output

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages