Open Source
Coming Soon & Needs Your Help

RayEditor is MIT-licensed and built in the open. Every feature below has a clear why, what, and how — so you can pick one up and ship it without guessing what's needed.

Architecture at a Glance

Understanding the structure before you contribute.

LayerWhat it doesKey files
Facade / Mediator Public API. Routes every toolbar action to the right feature via dispatchCommand(key). Features never talk to each other — everything goes through here. src/RayEditor.ts
Core modules EventBus (pub/sub), HistoryManager (undo/redo stack), SelectionManager (Range save/restore), ContentManager (getContent pipeline), ToolbarManager (render + active states). src/core/
Feature modules One class per feature. Each receives editorArea: HTMLElement in the constructor. Block-level insertions must use insertBlockAtCursor() from dom-utils.ts. src/features/
Plugin API editor.use({ name, install(editor) {} }) — third-party plugins get the full public API. Heavy optional features (KaTeX, DOCX, collab) belong here, never in core. src/types/plugin.ts
Two rules for contributors ① Every toolbar button needs mousedown.preventDefault() (already in ToolbarManager.createButton). ② Every document.addEventListener must be cleaned up in destroy() — use AbortController.

How to Run Tests

RayEditor has two test layers — unit tests (Vitest + jsdom) and E2E tests (Playwright). Both must pass before a PR is merged.

LayerCommandWhat it covers
Unit tests npm run test:run Feature logic in isolation — find & replace, markdown parser, paste normalizer, word count, history, etc. ~470 tests, runs in <30 s.
E2E tests npm run test:e2e Real browser (Chromium, Firefox, WebKit via Playwright). Covers toolbar clicks, dropdowns, keyboard shortcuts, exact DOM structure, security/XSS, paste from Word/Docs/VS Code. ~213 tests.
E2E — one browser npm run test:e2e:chrome Chromium only — fastest feedback loop during development.
E2E — headed npm run test:e2e:headed Runs tests with a visible browser window — useful when debugging a failing test.
E2E — debug npm run test:e2e:debug Opens Playwright Inspector — step through each action interactively.

Test entry-point: tests/e2e/test-app.html

All E2E tests load tests/e2e/test-app.html — a minimal, self-contained page that initialises RayEditor with the full toolbar and exposes window.editor for API calls. It has no decorative UI or initial content so each test always starts from a clean state. The page is served by http-server (auto-downloaded by npx) — no manual setup required.

Writing a new test

Every spec imports from tests/e2e/fixtures/editor.fixture.ts which provides the editorPage fixture. Use its helpers — type(), clickBtn(key), selectAll(), getHTML(), settle() — rather than raw Playwright locators where possible.

Toolbar buttons use CSS class .ray-btn-{key} (e.g. .ray-btn-bold). Dropdowns (headings, fonts, alignment) are <select> elements with class .ray-dropdown-{key} — use Playwright's selectOption(). Pickers (emoji, font-size, callout, table grid) are custom popups — locate them by their BEM class (e.g. .ray-emoji-picker).

When asserting DOM structure, prefer exact class checks (expect(el).toHaveClass()) and attribute checks (expect(html).toContain('data-lang="python"')) over text-content sniffing. Each test file lives in tests/e2e/features/{feature}.spec.ts. Unit tests live in tests/features/{feature}.test.ts.

Features to Build pick one up on GitHub

⬇️ Export HTML & Plain Text Easy

Why Writers want to download their work without copy-paste. One-click export is a standard expectation.
What Two buttons: Export HTML (saves getContent() as .html) and Export Text (saves innerText as .txt). No server needed — pure Blob + URL.createObjectURL.
How Add 'exportHtml' and 'exportText' to ToolbarItem and DEFAULT_TOOLBAR. Wire to _downloadBlob() which already exists in RayEditor.ts (used by markdown export).
Files: types/options.ts button-configs.ts RayEditor.ts

↔️ RTL / LTR Support Easy

Why Arabic, Hebrew, and Persian are spoken by ~500 million people. Without RTL, the editor is unusable for them.
What A toggle button that sets dir="rtl" on the editor area. Dropdowns and popups should also flip to right-align.
How Add 'rtl' toolbar item. In dispatchCommand, toggle editorArea.dir between 'rtl' and 'ltr'. Update popup positioning in toolbar.ts to read the current dir. getContent() passes dir attributes through unchanged.
Files: types/options.ts button-configs.ts RayEditor.ts main.css

🔍 Emoji Search "No Results" State Easy

Why Currently, typing a search term that matches zero emojis shows all 800+ emojis instead of a "no results" message — breaking the search UX entirely.
What When filterEmojis() returns an empty array, show a "No emojis found for '…'" message in the grid instead of the full emoji set.
How In emoji.ts filterEmojis(): if all category results are empty, return an empty array. In the render function, check for empty and render a message node instead of buttons.
Files: features/emoji.ts

🧹 Fix Event Listener Leaks (AbortController) Medium

Why Callout, emoji, special-chars, and formatting pickers register outside-click and keydown listeners on document but never remove them on close. After 10 minutes of editing, the document accumulates dozens of dead handlers that fire on every click — degrading performance.
What Each picker class gets an AbortController. All its document.addEventListener calls pass { signal: controller.signal }. Calling controller.abort() in the close/destroy path removes all of them automatically.
How
private _ac = new AbortController();
// register:
document.addEventListener('click', handler,
  { signal: this._ac.signal });
// cleanup:
destroy() { this._ac.abort(); }
Apply this pattern to: callout.ts, emoji.ts, special-chars.ts, formatting.ts, link.ts.
Files: features/callout.ts features/emoji.ts features/special-chars.ts features/formatting.ts features/link.ts

🔢 More Unit Tests Medium

Why Test coverage is strong for task lists and formatting but missing for table operations, image insertion, link sanitization, and markdown round-trips. Uncovered code ships broken features undetected.
What Add test files for: table.test.ts (insert, add/remove row/col, resize), link.test.ts (insert, edit, sanitize javascript:), image.test.ts (upload, resize handles), markdown.test.ts (round-trip for all block types).
How Follow the pattern in tests/features/tasklist.test.ts — jsdom + vitest, no real browser needed. Run with npm test.
Files: tests/features/

⬛ Empty <ul> After Task List Delete Easy

Why Pressing Backspace on the last task item converts it to a <p> but leaves an orphan <ul></ul> in the DOM — invalid HTML that can cause layout artefacts.
What After removing the last <li> from a .ray-task-list, check if the <ul> has zero children and remove it too.
How In tasklist.ts handleKeydown() Backspace path, after the li.remove() call: if (ul.children.length === 0) ul.remove();
Files: features/tasklist.ts
Advanced Features significant scope — open an issue first

➗ Math / LaTeX (KaTeX Plugin) Hard

Why Scientific, engineering, and academic writing requires equations. Without math support, RayEditor cannot serve this large segment of users.
What An opt-in plugin package @rohanyeole/ray-editor-katex so KaTeX does not bloat the zero-dep core. Inline $...$ and block $$...$$ render to <span class="ray-math" data-latex="..."> with the KaTeX SVG inside. The data-latex attribute preserves source for round-trip editing.
How Add a slash command /math and toolbar button. On insert: modal with LaTeX input + live preview. On save: insert the span. In content.ts getContent(): strip inner SVG, keep only the span + data attribute. On setContent(): find all .ray-math, call katex.render() to re-render.
New package: packages/ray-editor-katex/

📄 DOCX Import (mammoth.js Plugin) Hard

Why Writers live in Word. Paste-from-Word strips all formatting. A proper DOCX import preserves headings, bold, lists, and tables — a significant productivity gain.
What Opt-in plugin. User supplies mammoth as their own dep (core stays zero-dep). Plugin exposes importDocx(file: File): Promise<void> that converts and calls editor.setContent().
How mammoth.convertToHtml({ arrayBuffer }) → pass result through normalizePastedHTML() (already in core/paste-normalizer.ts) to strip Word-specific tags → call editor.setContent(html).
New package: packages/ray-editor-docx/

⊞ Table: Merge / Split Cells Hard

Why Without merge/split you can only build plain grids — no forms, invoices, or complex document layouts. This is the #1 table feature request and the biggest gap vs TinyMCE / CKEditor.
What Right-click on a selected cell range → "Merge cells" (sets colspan/rowspan, removes covered cells). Right-click a merged cell → "Split" (restores individual cells).
How ① Build a buildCellMap(table) utility — a rows × cols 2D grid mapping logical positions to physical cells (accounting for existing spans). ② Merge: walk the map for the selection, set attributes on anchor cell, remove others. ③ Split: re-insert <td> elements, clear span attributes. Full undo/redo snapshot of the table is required — not just one cell.
Files: features/table.ts + new core/cell-map.ts

🤝 Real-Time Collaboration Hard

Why Notion and Google Docs have set the expectation that rich text is collaborative by default. A collaboration layer would open RayEditor to team-tool use cases.
What External plugin only — never in core. WebSocket + Yjs CRDT for conflict-free merging. Remote cursors shown as coloured caret overlays. The plugin intercepts on('content:change') and applies incoming operations.
How Use Yjs with a Y.XmlFragment bound to the editor DOM. Apply incoming ops via editor.setContent(merged) or targeted DOM patches. Requires a WebSocket server (e.g., y-websocket). This is effectively a separate product — consider building it as a standalone SaaS add-on.
New package: packages/ray-editor-collab/

🏗️ Content Pipeline (Visitor Pattern) Architecture

Why getContent() currently runs 6+ querySelectorAll sweeps over the full DOM on every call. With onChange bound, this runs on every keystroke — O(n²) on large documents.
What Each feature registers a ContentVisitor with ContentManager. getContent() does a single DOM walk, calling all registered visitors per node — O(n) one pass.
How
interface ContentVisitor {
  visitElement(el: Element): Element | null;
}
// Each feature in constructor:
contentManager.registerVisitor(this);
Replace each querySelectorAll cleanup block in content.ts getContent() with visitor dispatch.
Files: core/content.ts + all feature files (register visitors)

🔄 Editor Mode State Machine Architecture

Why The editor has 4 modes (rich-text, markdown, source, fullscreen) and 2 flags (read-only, destroyed). Interactions between modes are guarded by scattered if checks. Entering markdown mode while a link modal is open, or calling setContent() after destroy(), can silently corrupt state.
What An explicit state machine with valid transitions: IDLE → MARKDOWN | SOURCE | FULLSCREEN | READONLY → any → DESTROYED. Invalid transitions log a warning and no-op. Each mode has enter/exit hooks to clean up its own UI.
How Add a EditorStateMachine class to core/. Replace scattered isSourceMode, readOnly, and destroyed guards in RayEditor.ts with this.state.transition('SOURCE') calls. Each transition validates current state before proceeding.
New: core/state-machine.ts · Modified: RayEditor.ts

Known Browser Limitations

These affect all contenteditable-based editors — not specific to RayEditor. Fixes require browser patches, not editor code.

  • Emoji cursor splitting — Unicode emojis span multiple code points. The cursor can land between them, making the first Backspace appear ineffective (it removes the trailing invisible code point).
  • Emoji skin tones — Fitzpatrick modifiers (U+1F3FB–U+1F3FF) only work on Emoji_Modifier_Base characters. Appending one to a non-base emoji (e.g. 😄) renders two separate visible characters. Skin tone support is intentionally excluded from RayEditor.
  • Undo granularitydocument.execCommand undo is browser-managed. Direct DOM insertions (callouts, tables, code blocks) may not participate in the native undo stack. Custom history via HistoryManager is planned to replace this.
  • Table resize in read-only mode — Col/row drag handlers remain active when readOnly: true. Tracked for a fix — good first issue for contributors.

Found a bug or ready to contribute? Open an issue on GitHub →