Skip to content

hasinoorit/halka

Repository files navigation

Halka — Headless Rich Text Kernel

Show me demo

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.

Install

pnpm add halka
# or
npm i halka

Import

The 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

Quick Start

// 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

Core API

Construction

  • new HalkaEditor(root, options?)
    • root: HTMLElement, must be contentEditable
    • options: { shortcuts?: boolean; plugins?: HalkaPlugin[] }
    • Default shortcuts include mod+b, mod+i, mod+u

Content

  • getHTML(): string
  • setHTML(html: string): void
  • insertHTML(html: string): void
  • insertText(text: string): void

Formats and Styles

  • 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

Selection

  • 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)

Pending Formats (No‑ZWS)

  • addPendingFormat(tagName: string): void
  • removePendingFormat(tagName: string): void
  • clearPendingFormats(): void
  • getPendingFormats(): Set
  • Typing applies pending formats via beforeinput (input.ts).

Commands, State, Events

  • 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

Query API

  • isActive(tagName: string): boolean — respects pending formats when collapsed
  • findClosest(tagName: string): Element | null
  • getCurrentBlock(): Element | null References: query.ts

Transform API

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

Plugins

Declarative Plugin Definition

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

Built-in Plugins

  • 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

Selection & Restoration

  • Halka records text offsets before transactions and restores selection by offsets afterward.
  • Reference: editor.applySelection, range.restoreSelectionByOffsets

Normalization & Schema

  • 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

Shortcuts

  • Built-in:
    • mod+b → toggleInlineFormat('bold') → <strong>
    • mod+i → toggleInlineFormat('italic') → <em>
    • mod+u → toggleInlineFormat('underline') → <u>
  • Custom:
    • editor.onShortcut('mod+shift+8', (e) => editor.execCommand('list.toggleUnordered')) Reference: editor.onShortcut

Headless UI Integration

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 formatChange and change events to highlight active states. Reference: editor.emit

Testing

This repository includes unit and e2e tests.

pnpm run test:unit -- --run
pnpm run test:e2e

Notes

  • 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.

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors