Halka is a framework-agnostic, DOM-centric rich text editor core. It provides a robust headless API to build toolbars, plugins, and UIs while keeping the DOM clean and semantic.
- Core focus: minimal DOM mutations, strong selection stability, strict normalization.
- Headless: you own the UI; Halka exposes commands, queries, transforms, and events.
- No-ZWS: collapsed formatting uses virtual “pending formats”; typing applies them without zero-width spaces.
pnpm add halka
# or
npm i halkaThe package ships ESM with typed exports.
import { HalkaEditor, definePlugin } from 'halka';
import { listPlugin } from 'halka/plugins/list';
import { historyPlugin } from 'halka/plugins/history';See exports in package manifest for available modules. Code references:
- Editor: editor.ts
- Query: query.ts
- Transform: transform.ts
- Selection: selection.ts
- Range helpers: range.ts
- Node helpers: node.ts
// Create an editable root
const root = document.createElement('div');
root.contentEditable = 'true';
document.body.appendChild(root);
// Initialize
const editor = new HalkaEditor(root, {
shortcuts: true,
plugins: [listPlugin, historyPlugin]
});
// Set content
editor.setHTML('<p>Hello world</p>');
// Toggle inline formats
editor.toggleInlineFormat('bold'); // wraps selection in <strong>
editor.toggleInlineFormat('italic'); // <em>
editor.toggleInlineFormat('underline'); // <u>
// Toggle block format
editor.toggleBlockFormat('h1'); // switches current block to <h1> or back to <p>
// Inline styles
editor.setInlineStyle('color', 'red'); // wraps with <span style="color:red">
editor.setInlineStyle('color'); // removes color, unwraps empty span- new HalkaEditor(root, options?)
- root: HTMLElement, must be contentEditable
- options: { shortcuts?: boolean; plugins?: HalkaPlugin[] }
- Default shortcuts include mod+b, mod+i, mod+u
- getHTML(): string
- setHTML(html: string): void
- insertHTML(html: string): void
- insertText(text: string): void
- toggleInlineFormat(format: 'bold' | 'italic' | 'underline' | 'code'): void
- toggleBlockFormat(format: 'paragraph' | 'h1' | 'h2' | 'h3' | 'blockquote'): void
- setInlineStyle(property: string, value?: string): void
- setBlockStyle(property: string, value?: string): void
- getSelection(): Selection | null
- getRange(): Range
- setSelection(range: Range): void
- applySelection(): void
- normalizeSelection(): void
- selection.preserveSelection(cb: (editor) => void): void — runs cb and restores the user selection afterward (selection.ts)
- registerNormalizer(fn: (range: Range) => Range | null): void — enforce caret correctness (e.g., ensure caret is inside an LI)
- addPendingFormat(tagName: string): void
- removePendingFormat(tagName: string): void
- clearPendingFormats(): void
- getPendingFormats(): Set
- Typing applies pending formats via beforeinput (input.ts).
- registerCommand(name: string, handler): void
- unregisterCommand(name: string, handler): void
- execCommand(name: string, payload?): void
- registerState(name: string, handler): void
- unregisterState(name: string, handler): void
- getState(name: string, payload?): unknown
- on(event: string, cb): void
- off(event: string, cb): void
- emit(event: string, data?): void
- onShortcut(desc: string, cb): void — desc like "mod+shift+8"
- offShortcut(desc: string, cb): void
- isActive(tagName: string): boolean — respects pending formats when collapsed
- findClosest(tagName: string): Element | null
- getCurrentBlock(): Element | null References: query.ts
Chainable mutations:
- wrap(tagName: string): this
- unwrap(tagName: string): this
- toggleMark(tagName: string): this
- insertText(text: string, formats?: Set): this
- insertNode(node: Node): this
- collapseToEnd(): this
- collapseToStart(): this
- deleteSelection(): this References: transform.ts
import { definePlugin } from 'halka';
export const myPlugin = definePlugin({
name: 'my-plugin',
commands: {
'myPlugin.action': (editor) => { /* ... */ }
},
shortcuts: {
'enter': (editor, e) => { /* ... */ },
'mod+k': 'myPlugin.action'
},
events: {
keydown: (editor, e) => { /* ... */ }
}
});References: editor.ts:definePlugin
- List: list.toggleUnordered, list.toggleOrdered, list.indent, list.outdent
- Shortcut: mod+shift+8 (unordered), mod+shift+7 (ordered), Tab/Shift+Tab for indent/outdent
- Reference: list.ts
- History: history.undo, history.redo
- Shortcuts: mod+z, mod+shift+z, mod+y
- Reference: history.ts
- Footnote, Link, Image, Paste, Placeholder, Table — see plugins directory
- Halka records text offsets before transactions and restores selection by offsets afterward.
- Reference: editor.applySelection, range.restoreSelectionByOffsets
- normalizeHTML ensures a default block
<p><br></p>when content is empty. - Selection normalizers ensure caret is inside valid containers (e.g., LI inside UL/OL).
- Schema class exposes block/inline/void categorization. References: editor.normalizeHTML, schema.ts, editor.registerDefaultNormalizers
- Built-in:
- mod+b → toggleInlineFormat('bold') →
<strong> - mod+i → toggleInlineFormat('italic') →
<em> - mod+u → toggleInlineFormat('underline') →
<u>
- mod+b → toggleInlineFormat('bold') →
- Custom:
- editor.onShortcut('mod+shift+8', (e) => editor.execCommand('list.toggleUnordered')) Reference: editor.onShortcut
Halka does not render UI; you build toolbars and menus and call the API:
- Button handlers call editor.toggleInlineFormat or execCommand.
- Reactive UI can observe
formatChangeandchangeevents to highlight active states. Reference: editor.emit
This repository includes unit and e2e tests.
pnpm run test:unit -- --run
pnpm run test:e2e- No-ZWS strategy means the DOM stays clean; pending formats apply on typing via beforeinput.
- Selection stability is handled via offsets; collapsed caret behaviors avoid inserting invisible characters.