npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@spezutil/richtext-editor

v0.4.0

Published

Rich-text editor Web Component for Arabic / Lisan-ud-Dawat content, built on Lexical.

Downloads

361

Readme

@spezutil/richtext-editor

<spez-richtext> — a rich-text editor Web Component for Arabic / Lisan-ud-Dawat content, built on Lexical. Framework-agnostic: works in plain HTML, React, Angular, Vue, or anything that renders DOM.

  • Amiri Arabic font embedded (SIL OFL-1.1) — applied automatically to Arabic codepoints via unicode-range, no font files to host, no configuration
  • Font selector in the toolbar — end users can switch fonts per selection; the list is fully configurable via the fonts property
  • RTL-first — per-paragraph direction auto-detected from the first strong character, logical (start/end) alignment, mirrored toolbar under dir="rtl"
  • Dawat content blocks
    • Ayat block — distinctly styled container for Quranic ayat / kalemat nooraniyah
    • Transliteration pair — an Arabic line + its transliteration that move, edit, and export as one unit
    • Hijri date token — atomic inline date backed by @spezutil/hijri-core (Bohra/Misri tabular calendar); stores the actual {year, month, day}, not just text
  • Core formatting: bold / italic / underline / strikethrough / subscript / superscript / inline code, font size, headings, quote, lists, indent/outdent, alignment, undo/redo, clear formatting
  • Links, images (by URL), tables
  • Optional word/character count status line
  • Output: Lexical JSON (canonical, lossless) + HTML export/import
  • Localized toolbar (en, ar)

Wrappers: @spezutil/richtext-editor-react · @spezutil/richtext-editor-angular

Install

npm install @spezutil/richtext-editor

Quick start

<script type="module">
  import "@spezutil/richtext-editor";
</script>

<spez-richtext placeholder="Start writing…"></spez-richtext>

<script>
  const editor = document.querySelector("spez-richtext");

  // Persist the canonical Lexical JSON (debounced ~150 ms while typing):
  editor.addEventListener("change", (e) => {
    save(e.detail.json);
  });

  // Restore it later:
  editor.value = savedJson;

  // Render outside the editor (email, static page, …):
  const html = editor.getHTML();
</script>

Persist the JSON, render the HTML. value / e.detail.json round-trips every feature losslessly (ayat blocks, translit pairs, hijri tokens keep their data). getHTML() produces tagged, self-describing markup (data-spez-type="ayat", data-spez-hijri="1446-9-17", …) that setHTML() / initialHtml can re-import.

Dawat content blocks

Ayat block

Toolbar ۞ button or the block dropdown. Renders centered, enlarged, RTL, in the Arabic font. Exports as:

<blockquote data-spez-type="ayat" dir="rtl">…</blockquote>

Transliteration pair

Toolbar ت/t button inserts a two-line unit — an Arabic line and a Latin (transliteration) line:

<div data-spez-type="translit-pair">
  <p data-role="arabic" dir="rtl">العلم نور</p>
  <p data-role="latin" dir="ltr">al-ilmu noor</p>
</div>

Editing behavior (the pair is self-normalizing — it always has exactly one Arabic + one Latin line):

  • Enter in the Arabic line → jumps to the Latin line; Enter in the Latin line → exits below the pair
  • Backspace at the start of the Latin line → moves the caret to the Arabic line (never merges the two lines)
  • Backspace at the start of the Arabic line → unwraps the pair into plain paragraphs
  • Backspace / Delete in an all-empty pair → removes the whole pair
  • Switching block type from the dropdown while inside a pair converts the pair into the chosen block

Hijri date token

Toolbar 📅 button. If @spezutil/hijri-datepicker is loaded on the page (optional peer dependency, detected at runtime — never imported), the button opens a date-picker popover; otherwise it inserts today's date:

import "@spezutil/richtext-editor";
import "@spezutil/hijri-datepicker"; // optional — enables the picker popover

The token is atomic (deletes/moves as one unit) and exports as:

<time data-spez-hijri="1446-9-17" data-spez-format="D MMMM YYYY">17 Ramadan al-Moazzam 1446</time>

Programmatic insertion: editor.insertHijriDate({ year: 1446, month: 9, day: 17 }, "D MMMM YYYY").

API

Attributes

| Attribute | Values | Description | | --- | --- | --- | | readonly | boolean | Disables editing and hides the toolbar | | placeholder | string | Shown while empty | | dir | rtl | ltr | auto | Base direction (default auto; paragraphs still auto-detect) | | locale | en | ar | Toolbar language (default en) | | toolbar | comma-separated groups or none | Groups: history,block,font,inline,color,list,indent,align,direction,insert | | fonts | comma-separated font families | Simple form of the font list, e.g. fonts="Amiri, Tahoma, Arial" (use the fonts property for labels and full font stacks) | | font-sizes | comma-separated font sizes | Simple form of the font-size list, e.g. font-sizes="12px, 16px, 24px" (use the fontSizes property for labels that differ from the CSS value) | | word-count | boolean | Shows a word/character count status line below the editor |

Properties

| Property | Type | Description | | --- | --- | --- | | value | string \| null | Serialized Lexical editor state JSON (get/set; canonical persistence format) | | initialHtml | string \| null | HTML applied on first init when no value was set | | fonts | FontOption[] \| null | Toolbar font list — see Font selector | | fontSizes | FontSizeOption[] \| null | Toolbar font-size list — see Font size | | editor | LexicalEditor | Escape hatch for advanced use (custom commands, transforms, …) |

Methods

| Method | Description | | --- | --- | | getJSON() | Current state as serialized Lexical JSON | | getHTML() | Current content as HTML | | setValue(json) | Replace content from serialized JSON | | setHTML(html) | Replace content from HTML | | clear() | Empty the editor | | focus() | Focus the editable area | | insertHijriDate(date?, format?) | Insert a Hijri date token at the caret (defaults to today) |

Events

| Event | Detail | Notes | | --- | --- | --- | | change | { json: string; isEmpty: boolean } | Debounced ~150 ms. HTML is not included (exporting walks the whole document) — call getHTML() on save/blur instead | | rte-ready | — | Fired once after the editor initializes |

Font selector

The toolbar's font group lets end users apply a font to the selected text (stored as an inline font-family style; survives HTML export/import). The default list is the embedded Amiri plus safe cross-platform stacks. Replace it via the fonts property, or spread DEFAULT_FONTS to extend it:

import { DEFAULT_FONTS } from "@spezutil/richtext-editor";

const editor = document.querySelector("spez-richtext");
editor.fonts = [
  ...DEFAULT_FONTS,
  { label: "Scheherazade", family: '"Scheherazade New", serif' },
];

Custom fonts must be loaded on the page (your own @font-face or a font service) — the editor only applies the font-family value. Setting fonts = null restores the defaults. The simple attribute form fonts="Amiri, Tahoma" is also supported for plain-HTML usage.

Font size

The toolbar's font group also has a font-size selector, next to the font family selector, applying an inline font-size style to the selection (survives HTML export/import). The default list is 12–48px. Replace it via the fontSizes property, or spread DEFAULT_FONT_SIZES to extend it:

import { DEFAULT_FONT_SIZES } from "@spezutil/richtext-editor";

const editor = document.querySelector("spez-richtext");
editor.fontSizes = [...DEFAULT_FONT_SIZES, { label: "64px", size: "64px" }];

Setting fontSizes = null restores the defaults. The simple attribute form font-sizes="12px, 16px, 24px" is also supported for plain-HTML usage.

Text and highlight color

The toolbar's color group has two controls — text color and highlight color — each opening a palette popover with 12 preset swatches, a native custom color picker, and a Reset action that removes the color again. Colors are stored as inline color / background-color styles on the text (preserved across HTML export/import, where browsers normalize them to rgb(…)). The toolbar swatch bar reflects the color under the caret. Omit the group via toolbar="history,block,font,inline,list,…" to hide it.

Word / character count

Set the boolean word-count attribute to show a status line below the editor content with a live word and character count (spez-richtext word-count). It updates on every edit via editor.registerUpdateListener.

Indent, subscript/superscript/code, clear formatting

  • The indent toolbar group adds indent/outdent buttons (INDENT_CONTENT_COMMAND / OUTDENT_CONTENT_COMMAND); Tab / Shift+Tab inside the editor do the same.
  • The inline group also has subscript, superscript, and inline-code toggle buttons, alongside a Clear formatting button that removes all active text formats and inline styles (font, size, color, highlight) from the selection.

Theming

All styling hangs off CSS custom properties on the host element:

spez-richtext {
  --rte-accent: #7c3aed;
  --rte-font-family-arabic: "My Arabic Font", serif;
  --rte-ayat-font-size: 1.75em;
}

| Property | Default | Applies to | | --- | --- | --- | | --rte-font-family | "Amiri", system-ui, sans-serif | Base text (Amiri only binds to Arabic codepoints) | | --rte-font-family-arabic | "Amiri", "Traditional Arabic", serif | RTL blocks, ayat | | --rte-accent | #0b7d3e | Buttons, links, focus states | | --rte-bg / --rte-fg | #ffffff / #1f2933 | Editor surface | | --rte-muted | #6b7280 | Placeholder, secondary text | | --rte-border | #d9dee4 | Borders | | --rte-radius | 8px | Corner radius | | --rte-toolbar-bg | #f7f8f9 | Toolbar background | | --rte-ayat-font-size | 1.5em | Ayat block text | | --rte-translit-color | var(--rte-muted) | Latin transliteration line |

Notes

  • Light DOM. Unlike typical Web Components, <spez-richtext> renders in light DOM: Lexical's selection handling relies on window.getSelection(), which does not work inside shadow roots (facebook/lexical#8125). Styles are scoped under the spez-rte- class prefix and injected once per document, so they won't collide with your CSS.
  • Bundle size. The embedded Amiri font adds ~500 KB (base64) to the bundle. In exchange the component is fully self-contained — no font hosting, no asset-path configuration, no FOUT on Arabic text.
  • Font license. The embedded Amiri typeface is © its authors and redistributed under the SIL Open Font License 1.1; the license text ships in this repository at assets/fonts/OFL-Amiri.txt.
  • Lexical versions. lexical and all @lexical/* packages are regular dependencies, version-matched. If your app also uses Lexical directly, keep it deduped to a single copy — two copies break Lexical's node identity checks.

License

Apache-2.0