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.
Understanding the structure before you contribute.
| Layer | What it does | Key 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. |
|
RayEditor has two test layers — unit tests (Vitest + jsdom) and E2E tests (Playwright). Both must pass before a PR is merged.
| Layer | Command | What 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.
getContent() as .html) and Export Text (saves innerText as .txt). No server needed — pure Blob + URL.createObjectURL.'exportHtml' and 'exportText' to ToolbarItem and DEFAULT_TOOLBAR. Wire to _downloadBlob() which already exists in RayEditor.ts (used by markdown export).types/options.ts button-configs.ts RayEditor.tsdir="rtl" on the editor area. Dropdowns and popups should also flip to right-align.'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.types/options.ts button-configs.ts RayEditor.ts main.cssfilterEmojis() returns an empty array, show a "No emojis found for '…'" message in the grid instead of the full emoji set.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.features/emoji.tsdocument 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.AbortController. All its document.addEventListener calls pass { signal: controller.signal }. Calling controller.abort() in the close/destroy path removes all of them automatically.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.
features/callout.ts features/emoji.ts features/special-chars.ts features/formatting.ts features/link.tstable.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).tests/features/tasklist.test.ts — jsdom + vitest, no real browser needed. Run with npm test.tests/features/<p> but leaves an orphan <ul></ul> in the DOM — invalid HTML that can cause layout artefacts.<li> from a .ray-task-list, check if the <ul> has zero children and remove it too.tasklist.ts handleKeydown() Backspace path, after the li.remove() call: if (ul.children.length === 0) ul.remove();features/tasklist.ts@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./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.packages/ray-editor-katex/mammoth as their own dep (core stays zero-dep). Plugin exposes importDocx(file: File): Promise<void> that converts and calls editor.setContent().mammoth.convertToHtml({ arrayBuffer }) → pass result through normalizePastedHTML() (already in core/paste-normalizer.ts) to strip Word-specific tags → call editor.setContent(html).packages/ray-editor-docx/colspan/rowspan, removes covered cells). Right-click a merged cell → "Split" (restores individual cells).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.features/table.ts + new core/cell-map.tson('content:change') and applies incoming operations.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.packages/ray-editor-collab/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.ContentVisitor with ContentManager. getContent() does a single DOM walk, calling all registered visitors per node — O(n) one pass.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.
core/content.ts + all feature files (register visitors)if checks. Entering markdown mode while a link modal is open, or calling setContent() after destroy(), can silently corrupt state.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.
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.core/state-machine.ts · Modified: RayEditor.tsThese affect all contenteditable-based editors — not specific to RayEditor. Fixes require browser patches, not editor code.
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.document.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.readOnly: true. Tracked for a fix — good first issue for contributors.Found a bug or ready to contribute? Open an issue on GitHub →