Theme
Design your own
On this page (9)
Component Skill — components/editorjs/component-skill.md

Native basis

<div class="editorjs"> with the document in a <script class="editorjs-source"> (type="text/markdown" or application/json); Editor.js mounts into .editorjs-holder.

Web Platform APIs

import()contenteditableexecCommand()Rangeselectionchange

Classes

.editorjs.editorjs-source.editorjs-holder

Data attributes

• data-tools - the tools to load (header list quote code delimiter marker inlineCode table, all by default)

• data-readonly - read only (the readonly state); data-placeholder - the empty document's hint

• data-toolbar="id" - the Toolbar whose data-editor-command buttons drive this editor: bold italic underline marker inline-code paragraph header:1 ... header:6 list:unordered|ordered|checklist quote code delimiter table

• data-ready / data-error - written by the runtime once the editor mounted, or when the builds could not be loaded

Notes

• The editor and its tools are the official packages, pinned (@editorjs/editorjs@2.31.7, marked@18.1.0) and requested from jsDelivr by the first editor on a page - a page without one never loads them. Until they arrive the element shows a shimmer; offline it reports the failure. df$.shadcn.editorjs.load(url) points it at a self-hosted copy.

§Markdown in, Markdown out

The document is Markdown inside the element. Edit it - the + menu adds blocks, the inline toolbar formats a selection - and read it back as Markdown: headings, lists (nested, checklists), quotes, code, rules, tables and the inline formatting survive the round trip.

§Formatting from a Toolbar

data-toolbar names a Toolbar; each of its buttons carries a data-editor-command. Select a word and press Bold, put the caret in a line and press Heading 2 - the toggles report the selection's formatting and the current block's type. A toolbar button never takes the selection away from the editor.

§Editor.js JSON, read only

A saved document - Editor.js output data with its block ids - authored as application/json. data-readonly shows it without editing; the readonly state toggles both the attribute and the editor's mode.

§A subset of tools, an empty document

data-tools limits what the + menu offers - here headings and lists only - and a document without a source starts empty with its placeholder. Only the listed tools are requested.

§Save and restore

The blocks API: Save keeps the Editor.js data, Restore renders it again, Load Markdown replaces the document from a textarea - the same calls a page would make against its own storage.

§States

Named states via the shared State API, driven per instance through the bound api:

  • default - editable: the editor accepts input, the toolbar commands apply
  • readonly - data-readonly on the element and the editor's read-only mode: the document shows, its toolbars are hidden, nothing edits it

The first example carries data-state-demo; bun run screenshots drives it via el.api.setState(name) and captures screenshots/{mode}/editorjs-{state}.png.

Machine contract - verified against editorjs.schema.json by bun run verify:

StateTypeValuesDefaultDescription
readonlybooleantrue, falsefalseRead only: data-readonly on the element, the editor in read-only mode.

§API

Generated from editorjs.ts and the shared State API - the descriptions are their JSDoc, the types are checked by the compiler.

States

type EditorjsState = 'default' | 'readonly' - setState(name, config) takes the config of the state it names.

StateDescription
default
Editable: the editor accepts input, the toolbar commands apply.

No config.

readonly
Read only: the document shows, nothing edits it (data-readonly on the element).

No config.

Every element

MemberDescription
el.api.setState<S extends EditorjsState>(name: S, config?: EditorjsStateConfigs[S]): unknown
Enter a state: the DOM work runs (also when it is the current state), the store records it.
ArgumentTypeDescription
nameSa declared state (an unknown name throws)
config?EditorjsStateConfigs[S]that state's config (merged into the stored one when the component merges)

Returns unknown - what the state's DOM work returned - a Promise for an async state (or await settled())

el.api.getState(): { name: EditorjsState; config: EditorjsStateConfigs[EditorjsState]; model?: ElementModel }
The state the element shows now - read back from the DOM, so it includes what the user changed.

Returns { name: EditorjsState; config: EditorjsStateConfigs[EditorjsState]; model?: ElementModel } - the state's name, its config and the authored markup model render() starts from

el.api.render(state?: { name: EditorjsState; config: EditorjsStateConfigs[EditorjsState]; model?: ElementModel }): string
The element's markup in a state - the authored markup with that state applied; a pure function of the state.
ArgumentTypeDescription
state?{ name: EditorjsState; config: EditorjsStateConfigs[EditorjsState]; model?: ElementModel }a state as getState() returns it (default: the current one)

Returns string - the element's outer HTML in that state

el.api.settled(): Promise<void>
Wait for the last state's DOM work (async states: a diagram rendering, a chart mounting).

Returns Promise<void> - resolves when nothing is pending

el.store: Store<{ name: EditorjsState; config: EditorjsStateConfigs[EditorjsState] }>A defuss-store store of the element's state - subscribe to follow every change (also the user's), set it to drive the component.

Registry

MemberDescription
df$.shadcn.editorjsApi.setState<S extends EditorjsState>(el: HTMLElement, name: S, config?: EditorjsStateConfigs[S]): unknown
Enter a state: the DOM work runs (also when it is the current state), the store records it.
ArgumentTypeDescription
elHTMLElementthe component's element
nameSa declared state (an unknown name throws)
config?EditorjsStateConfigs[S]that state's config (merged into the stored one when the component merges)

Returns unknown - what the state's DOM work returned - a Promise for an async state (await it, or el.api.settled())

df$.shadcn.editorjsApi.getState(el: HTMLElement): { name: EditorjsState; config: EditorjsStateConfigs[EditorjsState]; model?: ElementModel }
The state the element shows now - read back from the DOM, so it includes what the user changed.
ArgumentTypeDescription
elHTMLElementthe component's element

Returns { name: EditorjsState; config: EditorjsStateConfigs[EditorjsState]; model?: ElementModel } - the state's name, its config and the authored markup model render() starts from

df$.shadcn.editorjsApi.render(state: { name: EditorjsState; config: EditorjsStateConfigs[EditorjsState]; model?: ElementModel }): string
The element's markup in a state - the authored markup with that state applied; a pure function of the state.
ArgumentTypeDescription
state{ name: EditorjsState; config: EditorjsStateConfigs[EditorjsState]; model?: ElementModel }a state as getState() returns it (with its model)

Returns string - the element's outer HTML in that state

df$.shadcn.editorjsApi.store(el: HTMLElement): Store<{ name: EditorjsState; config: EditorjsStateConfigs[EditorjsState] }>
The element's store (bindComponent made it).
ArgumentTypeDescription
elHTMLElementthe component's element

Returns Store<{ name: EditorjsState; config: EditorjsStateConfigs[EditorjsState] }> - a defuss-store store of { name, config } - subscribe to follow every change, set it to drive the component

df$.shadcn.editorjsApi.commit<S extends EditorjsState>(el: HTMLElement, name: S, config?: EditorjsStateConfigs[S]): void
Record a state the element reached on its own (no DOM work) - for a component's own handlers.
ArgumentTypeDescription
elHTMLElementthe component's element
nameSthe state it is in
config?EditorjsStateConfigs[S]its config
df$.shadcn.editorjsStates: EditorjsState[]The declared states, 'default' first: default, readonly.

df$.shadcn.editorjs

MemberDescription
load(url?: string): Promise<unknown>
Load the pinned editor build ahead of the first element (or a self-hosted copy).
ArgumentTypeDescription
url?stringthe Editor.js ESM module to load instead of the pinned one

Returns Promise<unknown> - resolves when the module is loaded

url: string
The pinned Editor.js build the component loads.
markdown(target: string | HTMLElement): Promise<string>
The document as Markdown - headings, paragraphs, lists (nested, checklists), quotes, code, rules, tables; inline bold, italic, code and links.
ArgumentTypeDescription
targetstring | HTMLElementthe .editorjs element or its selector

Returns Promise<string> - the Markdown, '' before the editor is ready

setMarkdown(target: string | HTMLElement, markdown: string): Promise<void>
Replace the document with Markdown.
ArgumentTypeDescription
targetstring | HTMLElementthe .editorjs element or its selector
markdownstringthe new document

Returns Promise<void> - resolves once the blocks are rendered

blocks(target: string | HTMLElement): Promise<EditorjsDocument | null>
The document as Editor.js data (blocks with their ids).
ArgumentTypeDescription
targetstring | HTMLElementthe .editorjs element or its selector

Returns Promise<EditorjsDocument | null> - the saved document, or null before the editor is ready

setBlocks(target: string | HTMLElement, data: EditorjsDocument): Promise<void>
Replace the document with Editor.js data.
ArgumentTypeDescription
targetstring | HTMLElementthe .editorjs element or its selector
dataEditorjsDocumentthe blocks to render

Returns Promise<void> - resolves once the blocks are rendered

command(target: string | HTMLElement, name: string): Promise<boolean>
Run a formatting command on the current selection or block - what a toolbar button with data-editor-command sends.
ArgumentTypeDescription
targetstring | HTMLElementthe .editorjs element or its selector
namestringbold, italic, underline, marker, inline-code, paragraph, header:1-6, list:unordered|ordered|checklist, quote, code, delimiter, table

Returns Promise<boolean> - true when the command applied

editor(target: string | HTMLElement): unknown
The Editor.js instance behind an element, for anything this API does not cover.
ArgumentTypeDescription
targetstring | HTMLElementthe .editorjs element or its selector

Returns unknown - the editor, or undefined before it mounted

toBlocks(target: string | HTMLElement, markdown: string): EditorjsBlock[]
Markdown → Editor.js blocks, with the parser the component loaded (after the first editor is ready).
ArgumentTypeDescription
targetstring | HTMLElementany mounted .editorjs element or its selector (its parser is used)
markdownstringthe Markdown to convert

Returns EditorjsBlock[] - the blocks, [] before a parser is loaded

toMarkdown(blocks: EditorjsBlock[]): string
Editor.js blocks → Markdown.
ArgumentTypeDescription
blocksEditorjsBlock[]the blocks to serialize

Returns string - the Markdown

Events

EventDescription
editorjs-change
Fires after the document changed (typing, a block added or converted, a toolbar command) - how many blocks it has now.

detail: EditorjsChangeDetail

FieldTypeDescription
blocksnumberhow many blocks the document has after the change
editorjs-ready
Fires once the editor mounted its blocks - the count it started with.

detail: EditorjsReadyDetail

FieldTypeDescription
blocksnumberhow many blocks the document started with

Types

TypeDescription
EditorjsBlock
One Editor.js block, as the editor saves it.
FieldTypeDescription
id?stringthe block's id (Editor.js writes it on .ce-block[data-id])
typestringthe tool: paragraph, header, list, quote, code, delimiter, table
dataRecord<string, unknown>the tool's data
EditorjsChangeDetail
What editorjs-change carries.
FieldTypeDescription
blocksnumberhow many blocks the document has after the change
EditorjsDocument
The saved document: Editor.js output data.
FieldTypeDescription
blocksEditorjsBlock[]the blocks, in order
version?stringthe editor's version, when saved by it
time?numberwhen it was saved, ms since the epoch
EditorjsReadyDetail
What editorjs-ready carries.
FieldTypeDescription
blocksnumberhow many blocks the document started with

§CSS view file

The loading shimmer, Editor.js's own classes in the tokens, the readonly look.

/* -- Editor.js integration ------------------------------------------------ */
/* The holder Editor.js mounts into, in the theme's tokens: a shimmer until */
/* the editor is ready, the tools' own classes (ce-*, cdx-*) themed, a      */
/* readonly look, the inline marks a comment column adds.                   */
@layer components {
  .editorjs {
    --editorjs-measure: 44rem;
    --editorjs-min-height: 12rem;
    position: relative;
    display: block;
    min-block-size: var(--editorjs-min-height);
    color: var(--foreground);
    font-family: var(--font-sans);
    font-size: 1rem;
    line-height: 1.7;
    & > script {
      display: none;
    }
    & > .editorjs-holder {
      max-inline-size: var(--editorjs-measure);
      margin-inline: auto;
    }
    /* until the editor mounted: a quiet shimmer where the document will be */
    &:not([data-ready]) > .editorjs-holder,
    &:not([data-ready]):not(:has(> .editorjs-holder))::before {
      content: "";
      display: block;
      min-block-size: var(--editorjs-min-height);
      border-radius: var(--radius-lg);
      background: linear-gradient(
        90deg,
        transparent,
        color-mix(in oklab, var(--foreground) 6%, transparent),
        transparent
      );
      background-size: 200% 100%;
    }
    &[data-error]::after {
      content: "The editor could not be loaded.";
      display: block;
      color: var(--destructive);
      font-size: 0.875rem;
    }
    /* Editor.js's own structure, in tokens: the block, its content, the tools */
    & .codex-editor__redactor {
      padding-block-end: 2rem !important;
    }
    & .ce-block__content,
    & .ce-toolbar__content {
      max-inline-size: none;
      margin: 0;
    }
    & .ce-paragraph {
      line-height: inherit;
    }
    & .ce-header {
      margin-block: 1.25em 0.4em;
      font-family: var(--font-serif);
      font-weight: 600;
      letter-spacing: -0.01em;
      text-wrap: balance;
    }
    & h1.ce-header {
      font-size: 2rem;
    }
    & h2.ce-header {
      font-size: 1.5rem;
    }
    & h3.ce-header {
      font-size: 1.25rem;
    }
    & h4.ce-header {
      font-size: 1.0625rem;
    }
    & .cdx-block {
      padding-block: 0.3em;
    }
    & .cdx-quote {
      padding-inline-start: 1rem;
      border-inline-start: 3px solid var(--primary);
      color: var(--muted-foreground);
      font-style: italic;
    }
    & .cdx-quote__text,
    & .cdx-quote__caption {
      border: 0;
      box-shadow: none;
      background: none;
      padding: 0;
      min-block-size: 0;
    }
    & .cdx-quote__caption {
      font-size: 0.875rem;
      font-style: normal;
    }
    & .ce-code__textarea {
      border: 1px solid var(--border);
      border-radius: var(--radius-md);
      background: var(--muted);
      color: var(--foreground);
      font-family: var(--font-mono);
      font-size: 0.875rem;
    }
    & .ce-delimiter::before {
      color: var(--muted-foreground);
    }
    & .cdx-list {
      padding-inline-start: 0;
    }
    & .cdx-list__item {
      padding-block: 0.2em;
    }
    & .cdx-marker,
    & mark.cdx-marker {
      padding: 0.05em 0.1em;
      border-radius: var(--radius-sm);
      background: color-mix(in oklab, var(--chart-4) 45%, transparent);
      color: inherit;
    }
    & code.inline-code {
      padding: 0.1em 0.35em;
      border-radius: var(--radius-sm);
      background: var(--muted);
      color: var(--foreground);
      font-family: var(--font-mono);
      font-size: 0.875em;
    }
    & .tc-table,
    & .tc-wrap {
      --color-border: var(--border);
      --color-background: var(--card);
    }
    & .tc-table .tc-cell {
      border-color: var(--border);
    }
    & .tc-row--heading .tc-cell {
      font-weight: 600;
      background: var(--muted);
    }
    /* Editor.js paints its popovers, toolbars and the table's popover from its own custom
       properties, declared on those elements - redeclare them from the theme tokens (two
       classes beat its one), so dark mode and every preset reach them.
       VERIFIED: (editorjs.e2e "dark mode") no light surface or light-on-light text inside
       the editor with the + popover open on a .dark page. */
    & .ce-popover,
    & .ce-toolbar,
    & .ce-inline-toolbar,
    & .ce-conversion-toolbar,
    & .ce-settings,
    & .tc-popover,
    & .tc-toolbox,
    & .tc-wrap {
      --color-background: var(--popover) !important;
      --color-background-hover: var(--accent) !important;
      --color-background-item-hover: var(--accent) !important;
      --color-background-item-focus: var(--accent) !important;
      --color-background-icon-active: var(--accent) !important;
      --color-background-item-confirm: var(--destructive) !important;
      --color-background-item-confirm-hover: color-mix(in oklab, var(--destructive) 85%, var(--foreground)) !important;
      --color-background-confirm: var(--destructive) !important;
      --color-background-confirm-hover: color-mix(in oklab, var(--destructive) 85%, var(--foreground)) !important;
      --color-text-primary: var(--popover-foreground) !important;
      --color-text-secondary: var(--muted-foreground) !important;
      --color-text-icon-active: var(--accent-foreground) !important;
      --color-text-confirm: var(--destructive-foreground) !important;
      --color-border: var(--border) !important;
      --color-border-icon: var(--border) !important;
      --color-shadow: color-mix(in oklab, var(--foreground) 12%, transparent) !important;
      --color-shadow-item-focus: color-mix(in oklab, var(--ring) 35%, transparent) !important;
      --color-dark: var(--foreground) !important;
      --color-active-icon: var(--primary) !important;
      color-scheme: inherit;
    }
    & .ce-popover__container,
    & .ce-popover__search,
    & .cdx-search-field {
      background: var(--popover) !important;
      color: var(--popover-foreground) !important;
      border-color: var(--border) !important;
    }
    & .cdx-search-field__input {
      color: var(--popover-foreground) !important;
    }
    & .cdx-search-field__input::placeholder {
      color: var(--muted-foreground) !important;
    }
    & .ce-popover-item__icon,
    & .ce-popover-item__icon svg,
    & .tc-toolbox__toggler svg {
      background: transparent !important;
      color: var(--popover-foreground) !important;
    }
    & .cdx-list__checkbox-check {
      --checkbox-background: var(--card) !important;
      --color-border: var(--border) !important;
      --color-bg-checked: var(--primary) !important;
    }
    & .cdx-list__checkbox--checked .cdx-list__checkbox-check svg {
      stroke: var(--primary-foreground) !important;
    }
    & .tc-add-column,
    & .tc-add-row {
      background: var(--card) !important;
      color: var(--muted-foreground) !important;
    }
    & .tc-add-column:hover,
    & .tc-add-row:hover {
      background: var(--accent) !important;
      color: var(--accent-foreground) !important;
    }
    & .tc-table {
      --color-background: var(--card) !important;
    }
    /* the toolbars Editor.js shows: the + and the settings handle, the inline toolbar */
    & .ce-toolbar__plus,
    & .ce-toolbar__settings-btn {
      color: var(--muted-foreground) !important;
      background: var(--card) !important;
      border: 1px solid var(--border);
      border-radius: var(--radius-md);
    }
    & .ce-toolbar__plus:hover,
    & .ce-toolbar__settings-btn:hover {
      background: var(--accent) !important;
      color: var(--accent-foreground) !important;
    }
    & .ce-popover__container,
    & .ce-inline-toolbar,
    & .ce-conversion-toolbar {
      background: var(--popover);
      color: var(--popover-foreground);
      border: 1px solid var(--border);
      border-radius: var(--radius-lg);
      box-shadow: var(--shadow-md);
    }
    & .ce-popover-item:hover,
    & .ce-inline-tool:hover,
    & .ce-conversion-tool:hover {
      background: var(--accent);
      color: var(--accent-foreground);
    }
    & .ce-popover-item--active,
    & .ce-inline-tool--active {
      background: var(--accent);
      color: var(--accent-foreground);
    }
    & .ce-popover-item__icon,
    & .ce-conversion-tool__icon {
      background: var(--muted);
      border: 0;
      box-shadow: none;
    }
    & .cdx-search-field {
      background: var(--muted);
      border-color: var(--border);
      color: var(--foreground);
    }
    & .ce-block--selected .ce-block__content {
      background: color-mix(in oklab, var(--primary) 10%, transparent);
      border-radius: var(--radius-md);
    }
    &[data-readonly] {
      & .ce-toolbar {
        display: none;
      }
      & .codex-editor__redactor {
        cursor: default;
      }
    }
  }
  @media (prefers-reduced-motion: no-preference) {
    .editorjs:not([data-ready]) > .editorjs-holder,
    .editorjs:not([data-ready]):not(:has(> .editorjs-holder))::before {
      animation: editorjs-shimmer 1.4s linear infinite;
    }
  }
  @keyframes editorjs-shimmer {
    from {
      background-position: 200% 0;
    }
    to {
      background-position: -200% 0;
    }
  }
  @media (prefers-reduced-motion: reduce) {
    /* Editor.js fades every block in (its own .ce-block animation) and transitions its toolbars */
    .editorjs .ce-block,
    .editorjs .ce-toolbar,
    .editorjs .ce-inline-toolbar,
    .editorjs .ce-popover {
      animation: none;
      transition: none;
    }
  }
  @media (prefers-contrast: more) {
    .editorjs .ce-code__textarea,
    .editorjs .ce-toolbar__plus,
    .editorjs .ce-toolbar__settings-btn {
      border-color: var(--foreground);
    }
  }
  @media (forced-colors: active) {
    .editorjs .cdx-marker {
      background: Mark;
      color: MarkText;
    }
    .editorjs .ce-block--selected .ce-block__content {
      outline: 2px solid Highlight;
      background: none;
    }
  }
}

§JavaScript view file

The pinned vendor loader, Markdown to blocks and back, the Toolbar commands and the State API.

/* -- Editor.js integration -------------------------------------------- */
/* A thin adapter around the OFFICIAL Editor.js block editor (codex-team,  */
/* Apache-2.0) and its official tools - zero editor bytes ship here. The   */
/* element authors its document as Markdown or as Editor.js block JSON in  */
/* a <script class="editorjs-source">; the runtime loads the pinned ESM    */
/* builds on the first editor, converts Markdown to blocks with marked     */
/* (pinned too), mounts the editor, and serializes blocks back to Markdown.*/
/* A Toolbar with data-editor-command buttons drives the formatting; the   */
/* document DOM stays Editor.js's (block ids on .ce-block[data-id]), so a  */
/* comment column (doc-comments) can anchor spans in it.                   */
/* VERIFIED: (editorjs.e2e, the pinned builds served from node_modules)    */
/* Markdown round trip, the toolbar commands, readonly, the render contract.*/
// Shared preamble (AGENTS.md "State API"); the implementation lives in core.js —
// build.ts rewrites this import into a df$.shadcn.shared binding in dist/.
import { defussGlobals, defussQuery, componentState, bindComponent } from '../../../shared/state-api.js';
const df$ = defussGlobals();
const dfDollar = defussQuery();
const editorjsStates = ['default', 'readonly'];
// VERIFIED: (verify's API docs gate) the states below are exactly the declared ones, each
// described, and every config field typed, described and named in the code.
/** setState() configs per state - the editor's states take none. */
export interface EditorjsStateConfigs {
  /** Editable: the editor accepts input, the toolbar commands apply. */
  default: {};
  /** Read only: the document shows, nothing edits it (data-readonly on the element). */
  readonly: {};
}
/** What editorjs-change carries. */
export interface EditorjsChangeDetail {
  /** how many blocks the document has after the change */
  blocks: number;
}
/** What editorjs-ready carries. */
export interface EditorjsReadyDetail {
  /** how many blocks the document started with */
  blocks: number;
}
/** One Editor.js block, as the editor saves it. */
export interface EditorjsBlock {
  /** the block's id (Editor.js writes it on .ce-block[data-id]) */
  id?: string;
  /** the tool: paragraph, header, list, quote, code, delimiter, table */
  type: string;
  /** the tool's data */
  data: Record<string, unknown>;
}
/** The saved document: Editor.js output data. */
export interface EditorjsDocument {
  /** the blocks, in order */
  blocks: EditorjsBlock[];
  /** the editor's version, when saved by it */
  version?: string;
  /** when it was saved, ms since the epoch */
  time?: number;
}
/** The pinned official builds the component loads (never @latest) - jsDelivr ESM files. */
export const EDITORJS_URL = 'https://cdn.jsdelivr.net/npm/@editorjs/editorjs@2.31.7/dist/editorjs.mjs';
/** The official tools, pinned, keyed by the Editor.js tool name they register as. */
export const EDITORJS_TOOLS: Readonly<Record<string, string>> = {
  header: 'https://cdn.jsdelivr.net/npm/@editorjs/header@2.8.9/dist/header.mjs',
  list: 'https://cdn.jsdelivr.net/npm/@editorjs/list@2.0.9/dist/editorjs-list.mjs',
  quote: 'https://cdn.jsdelivr.net/npm/@editorjs/quote@2.7.6/dist/quote.mjs',
  code: 'https://cdn.jsdelivr.net/npm/@editorjs/code@2.9.4/dist/code.mjs',
  delimiter: 'https://cdn.jsdelivr.net/npm/@editorjs/delimiter@1.4.2/dist/delimiter.mjs',
  marker: 'https://cdn.jsdelivr.net/npm/@editorjs/marker@1.4.0/dist/marker.mjs',
  inlineCode: 'https://cdn.jsdelivr.net/npm/@editorjs/inline-code@1.5.2/dist/inline-code.mjs',
  table: 'https://cdn.jsdelivr.net/npm/@editorjs/table@2.4.6/dist/table.mjs',
};
/** The pinned Markdown parser (marked, MIT) the Markdown source goes through. */
export const MARKED_URL = 'https://cdn.jsdelivr.net/npm/marked@18.1.0/lib/marked.esm.js';
/** The part of Editor.js this component uses. */
interface EditorLike {
  isReady: Promise<void>;
  readOnly: { toggle(state?: boolean): Promise<boolean>; isEnabled: boolean };
  blocks: {
    getCurrentBlockIndex(): number;
    getBlockByIndex(i: number): { id: string; name: string } | undefined;
    getBlockByElement(element: HTMLElement): { id: string; name: string } | undefined;
    getById(id: string): { id: string; name: string } | null;
    getBlockIndex(id: string): number;
    convert(id: string, type: string, data?: Record<string, unknown>): Promise<unknown>;
    update(id: string, data: Record<string, unknown>): Promise<unknown>;
    insert(
      type?: string,
      data?: Record<string, unknown>,
      config?: Record<string, unknown>,
      index?: number,
      needToFocus?: boolean,
    ): unknown;
    render(data: EditorjsDocument): Promise<void>;
    getBlocksCount(): number;
  };
  save(): Promise<EditorjsDocument>;
  destroy(): void;
}
/** The part of marked this component uses. */
interface MarkedLike {
  lexer(md: string): MarkedToken[];
  parseInline(md: string): string;
}
interface MarkedToken {
  type: string;
  depth?: number;
  text?: string;
  raw?: string;
  tokens?: MarkedToken[];
  items?: MarkedToken[];
  ordered?: boolean;
  task?: boolean;
  checked?: boolean;
  lang?: string;
  header?: { text: string }[];
  rows?: { text: string }[][];
}
const modules = new Map<string, Promise<unknown>>();
/** import one pinned vendor module once (the only dynamic import shipped code may contain - verify's vendor gate) */
function loadModule(vendorUrl: string): Promise<unknown> {
  let pending = modules.get(vendorUrl);
  if (!pending) {
    pending = import(/* @vite-ignore */ vendorUrl).then((m) => (m as { default?: unknown }).default ?? m);
    modules.set(vendorUrl, pending);
  }
  return pending;
}
/** every vendor module: the editor, the tools an element uses, the Markdown parser */
async function loadVendor(
  toolNames: string[],
): Promise<{ EditorJS: unknown; tools: Record<string, unknown>; marked: MarkedLike }> {
  const [EditorJS, marked, ...tools] = await Promise.all([
    loadModule(EDITORJS_URL),
    loadModule(MARKED_URL),
    ...toolNames.map((n) => loadModule(EDITORJS_TOOLS[n])),
  ]);
  return { EditorJS, marked: marked as MarkedLike, tools: Object.fromEntries(toolNames.map((n, i) => [n, tools[i]])) };
}
/** Markdown → Editor.js blocks (marked's tokens, inline Markdown as the tools' HTML). Block ids are
 * deterministic (b1, b2, ... in document order), so comments and links can address a Markdown document. */
function markdownToBlocks(md: string, marked: MarkedLike): EditorjsBlock[] {
  // the tools' sanitizer keeps <b> / <i> (the inline tools' tags), not <strong> / <em>
  const inline = (t: string) =>
    marked
      .parseInline(t)
      .replace(/<code>/g, '<code class="inline-code">')
      .replace(/<(\/?)strong>/g, '<$1b>')
      .replace(/<(\/?)em>/g, '<$1i>');
  const items = (list: MarkedToken): { content: string; meta: Record<string, unknown>; items: unknown[] }[] =>
    (list.items ?? []).map((it) => {
      const own = (it.tokens ?? []).filter((t) => t.type !== 'list');
      const sub = (it.tokens ?? []).find((t) => t.type === 'list');
      return {
        content: inline(
          own
            .map((t) => t.text ?? t.raw ?? '')
            .join(' ')
            .trim(),
        ),
        meta: it.task ? { checked: !!it.checked } : {},
        items: sub ? items(sub) : [],
      };
    });
  const blocks: EditorjsBlock[] = [];
  for (const t of marked.lexer(md)) {
    switch (t.type) {
      case 'heading':
        blocks.push({
          type: 'header',
          data: { text: inline(t.text ?? ''), level: Math.min(6, Math.max(1, t.depth ?? 2)) },
        });
        break;
      case 'paragraph':
        blocks.push({ type: 'paragraph', data: { text: inline(t.text ?? '') } });
        break;
      case 'list':
        blocks.push({
          type: 'list',
          data: {
            style: t.items?.some((i) => i.task) ? 'checklist' : t.ordered ? 'ordered' : 'unordered',
            meta: {},
            items: items(t),
          },
        });
        break;
      case 'blockquote':
        blocks.push({
          type: 'quote',
          data: { text: inline((t.tokens ?? []).map((x) => x.text ?? '').join('\n')), caption: '', alignment: 'left' },
        });
        break;
      case 'code':
        blocks.push({ type: 'code', data: { code: t.text ?? '' } });
        break;
      case 'hr':
        blocks.push({ type: 'delimiter', data: {} });
        break;
      case 'table':
        blocks.push({
          type: 'table',
          data: {
            withHeadings: true,
            content: [
              (t.header ?? []).map((c) => inline(c.text)),
              ...(t.rows ?? []).map((r) => r.map((c) => inline(c.text))),
            ],
          },
        });
        break;
      case 'html':
        blocks.push({ type: 'paragraph', data: { text: t.raw ?? '' } });
        break;
      default:
        break; // space
    }
  }
  blocks.forEach((b, i) => {
    b.id = `b${i + 1}`;
  });
  return blocks;
}
/** the tools' inline HTML back to Markdown (marks for comments drop to their text) */
function inlineToMarkdown(html: string): string {
  return html
    .replace(/<br\s*\/?>/gi, '  \n')
    .replace(/<(b|strong)>([\s\S]*?)<\/\1>/gi, '**$2**')
    .replace(/<(i|em)>([\s\S]*?)<\/\1>/gi, '*$2*')
    .replace(/<u>([\s\S]*?)<\/u>/gi, '$1')
    .replace(/<code[^>]*>([\s\S]*?)<\/code>/gi, '`$1`')
    .replace(/<mark[^>]*>([\s\S]*?)<\/mark>/gi, '$1')
    .replace(/<a [^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi, '[$2]($1)')
    .replace(/<[^>]+>/g, '')
    .replace(/&nbsp;/g, ' ')
    .replace(/&lt;/g, '<')
    .replace(/&gt;/g, '>')
    .replace(/&quot;/g, '"')
    .replace(/&amp;/g, '&')
    .trim();
}
/** Editor.js blocks → Markdown. */
function blocksToMarkdown(blocks: EditorjsBlock[]): string {
  const list = (
    items: { content: string; meta?: { checked?: boolean }; items?: unknown[] }[],
    style: string,
    depth: number,
  ): string =>
    items
      .map((it, i) => {
        const bullet =
          style === 'ordered' ? `${i + 1}.` : style === 'checklist' ? `- [${it.meta?.checked ? 'x' : ' '}]` : '-';
        const sub = it.items?.length ? '\n' + list(it.items as typeof items, style, depth + 1) : '';
        return `${'  '.repeat(depth)}${bullet} ${inlineToMarkdown(it.content)}${sub}`;
      })
      .join('\n');
  return (
    blocks
      .map((b) => {
        const d = b.data as Record<string, any>;
        switch (b.type) {
          case 'header':
            return `${'#'.repeat(Number(d.level) || 2)} ${inlineToMarkdown(String(d.text ?? ''))}`;
          case 'paragraph':
            return inlineToMarkdown(String(d.text ?? ''));
          case 'list':
            return list(d.items ?? [], String(d.style ?? 'unordered'), 0);
          case 'quote':
            return (
              inlineToMarkdown(String(d.text ?? ''))
                .split('\n')
                .map((l) => `> ${l}`)
                .join('\n') + (d.caption ? `\n> - ${inlineToMarkdown(String(d.caption))}` : '')
            );
          case 'code':
            return '```\n' + String(d.code ?? '') + '\n```';
          case 'delimiter':
            return '---';
          case 'table': {
            const rows = (d.content ?? []) as string[][];
            if (!rows.length) return '';
            const line = (r: string[]) => `| ${r.map(inlineToMarkdown).join(' | ')} |`;
            const [head, ...body] = rows;
            return d.withHeadings === false
              ? rows.map(line).join('\n')
              : [line(head), `| ${head.map(() => '---').join(' | ')} |`, ...body.map(line)].join('\n');
          }
          default:
            return '';
        }
      })
      .filter(Boolean)
      .join('\n\n') + '\n'
  );
}
/** The markup of a state: readonly marks the element; the editor itself follows in apply. */
function applyMarkup(el: HTMLElement, stateName: string): void {
  dfDollar(el).attr('data-readonly', stateName === 'readonly' ? '' : null);
}
/** The DOM side of a state: the markup, then the editor's own read-only mode (once it is ready). */
function triggerStateChange(el: HTMLElement, stateName: string): void {
  applyMarkup(el, stateName);
  const editor = el._editorjs as EditorLike | undefined;
  if (editor) editor.isReady.then(() => editor.readOnly.toggle(stateName === 'readonly')).catch(() => undefined);
}
/** Registry-level API; pass the element explicitly. Unknown names throw. */
export const editorjsApi = componentState({
  component: 'editorjs',
  states: editorjsStates,
  apply: (el, state) => triggerStateChange(el, state.name),
  read: (el, state) => ({ name: el.hasAttribute('data-readonly') ? 'readonly' : 'default', config: state.config }),
  markup: (el, state) => applyMarkup(el, state.name),
});
df$.editorjsApi = editorjsApi;
df$.editorjsStates = editorjsStates;
const DEFAULT_TOOLS = Object.keys(EDITORJS_TOOLS);
/** the source the element authors: Markdown (type text/markdown) or Editor.js JSON */
function sourceOf(el: HTMLElement): { markdown?: string; data?: EditorjsDocument } {
  const script = dfDollar(el).children<HTMLScriptElement>('script.editorjs-source').get(0);
  const text = script?.textContent ?? '';
  if (!script || !text.trim()) return { markdown: '' };
  if (/json/i.test(script.type)) {
    try {
      return { data: JSON.parse(text) as EditorjsDocument };
    } catch {
      return { markdown: text };
    }
  }
  return { markdown: text.replace(/^\n/, '') };
}
/** the inline commands: what a toolbar button applies to the selection */
function inlineCommand(name: string): boolean {
  if (name === 'bold' || name === 'italic' || name === 'underline') return document.execCommand(name);
  const sel = globalThis.getSelection();
  if (!sel || sel.rangeCount === 0 || sel.isCollapsed) return false;
  const range = sel.getRangeAt(0);
  const tag = name === 'marker' ? 'mark' : name === 'inline-code' ? 'code' : null;
  if (!tag) return false;
  const wrapper = document.createElement(tag);
  if (name === 'marker') wrapper.className = 'cdx-marker';
  if (name === 'inline-code') wrapper.className = 'inline-code';
  try {
    range.surroundContents(wrapper);
  } catch {
    wrapper.append(range.extractContents());
    range.insertNode(wrapper);
  }
  return true;
}
/** the block the selection is in (a toolbar command targets the caret's block, which Editor.js's own
 * current-block tracking only follows after a click or a key inside the editor), else Editor.js's current block */
function currentBlock(el: HTMLElement, editor: EditorLike): { id: string; name: string } | undefined {
  const anchor = globalThis.getSelection()?.anchorNode;
  const node = anchor && (anchor.nodeType === Node.ELEMENT_NODE ? (anchor as HTMLElement) : anchor.parentElement);
  const holder = node && el.contains(node) ? node.closest<HTMLElement>('.ce-block') : null;
  if (holder) return editor.blocks.getBlockByElement(holder);
  const index = editor.blocks.getCurrentBlockIndex();
  return index >= 0 ? editor.blocks.getBlockByIndex(index) : undefined;
}
/** run one command on an element's editor: inline formatting or a block change */
async function runCommand(el: HTMLElement, name: string): Promise<boolean> {
  const editor = el._editorjs as EditorLike | undefined;
  if (!editor) return false;
  await editor.isReady;
  if (['bold', 'italic', 'underline', 'marker', 'inline-code'].includes(name)) return inlineCommand(name);
  const [kind, arg] = name.split(':');
  const current = currentBlock(el, editor);
  if (kind === 'delimiter' || kind === 'table') {
    const index = current ? editor.blocks.getBlockIndex(current.id) : editor.blocks.getBlocksCount() - 1;
    editor.blocks.insert(
      kind,
      kind === 'table'
        ? {
            withHeadings: true,
            content: [
              ['', ''],
              ['', ''],
            ],
          }
        : {},
      {},
      index + 1,
      true,
    );
    return true;
  }
  if (!current) return false;
  if (kind === 'paragraph') {
    await editor.blocks.convert(current.id, 'paragraph');
    return true;
  }
  if (kind === 'header') {
    const level = Math.min(6, Math.max(1, Number(arg) || 2));
    if (current.name !== 'header') await editor.blocks.convert(current.id, 'header', { level });
    const block = editor.blocks.getById(current.id);
    if (block) await editor.blocks.update(block.id, { level });
    return true;
  }
  if (kind === 'list') {
    const style = arg === 'ordered' ? 'ordered' : arg === 'checklist' ? 'checklist' : 'unordered';
    if (current.name !== 'list') await editor.blocks.convert(current.id, 'list', { style });
    else await editor.blocks.update(current.id, { style });
    return true;
  }
  if (kind === 'quote' || kind === 'code') {
    await editor.blocks.convert(current.id, kind);
    return true;
  }
  return false;
}
/** mirror the selection's formatting onto the toolbar's toggles (aria-pressed) */
function syncToolbar(el: HTMLElement): void {
  const bar = el._toolbar as HTMLElement | undefined;
  if (!bar || !el.contains(document.activeElement)) return;
  const editor = el._editorjs as EditorLike | undefined;
  const current = editor ? currentBlock(el, editor) : undefined;
  // the caret's inline formatting is an ancestor of the selection (queryCommandState('bold') reports
  // the computed weight - true inside any heading); a heading's level is its tag, a list's style its class
  const anchor = globalThis.getSelection()?.anchorNode;
  const node = anchor && (anchor.nodeType === Node.ELEMENT_NODE ? (anchor as HTMLElement) : anchor.parentElement);
  const holder = node && el.contains(node) ? node.closest<HTMLElement>('.ce-block') : null;
  const level = holder ? (dfDollar(holder).find('.ce-header').get(0)?.tagName.replace(/^H/i, '') ?? '') : '';
  const listStyle =
    holder && dfDollar(holder).find('.cdx-list').length
      ? dfDollar(holder).find('.cdx-list--checklist').length
        ? 'checklist'
        : dfDollar(holder).find('.cdx-list--ordered').length
          ? 'ordered'
          : 'unordered'
      : '';
  dfDollar(bar)
    .find('[data-editor-command]')
    .toArray()
    .forEach((b: HTMLElement) => {
      const name = b.dataset.editorCommand ?? '';
      const [kind, arg] = name.split(':');
      let on: boolean | null = null;
      if (kind === 'bold') on = !!node?.closest('b, strong');
      else if (kind === 'italic') on = !!node?.closest('i, em');
      else if (kind === 'underline') on = !!node?.closest('u');
      else if (kind === 'paragraph') on = current?.name === 'paragraph';
      else if (kind === 'header') on = current?.name === 'header' && (!arg || arg === level);
      else if (kind === 'list') on = current?.name === 'list' && (!arg || arg === listStyle);
      else if (kind === 'quote' || kind === 'code') on = current?.name === kind;
      if (on !== null && b.hasAttribute('aria-pressed')) dfDollar(b).attr('aria-pressed', String(on));
    });
}
async function mount(el: HTMLElement): Promise<void> {
  const toolNames = (dfDollar(el).attr('data-tools') ?? '').split(/\s+/).filter((n) => EDITORJS_TOOLS[n]);
  const tools = toolNames.length ? toolNames : DEFAULT_TOOLS;
  const { EditorJS, tools: loaded, marked } = await loadVendor(tools);
  if (!el.isConnected) return;
  el._marked = marked;
  const source = sourceOf(el);
  const data: EditorjsDocument = source.data ?? { blocks: markdownToBlocks(source.markdown ?? '', marked) };
  let holder = dfDollar(el).children<HTMLElement>('.editorjs-holder').get(0);
  if (!holder) {
    holder = document.createElement('div');
    holder.className = 'editorjs-holder';
    dfDollar(el).append(holder);
  }
  const config: Record<string, unknown> = {};
  for (const name of tools) {
    const cls = loaded[name];
    config[name] =
      name === 'list'
        ? { class: cls, inlineToolbar: true, config: { defaultStyle: 'unordered' } }
        : name === 'header'
          ? { class: cls, inlineToolbar: true, config: { levels: [1, 2, 3, 4], defaultLevel: 2 } }
          : name === 'quote' || name === 'table'
            ? { class: cls, inlineToolbar: true }
            : cls;
  }
  const Ctor = EditorJS as new (opts: Record<string, unknown>) => EditorLike;
  const editor = new Ctor({
    holder,
    data,
    tools: config,
    readOnly: el.hasAttribute('data-readonly'),
    placeholder: dfDollar(el).attr('data-placeholder') ?? 'Write...',
    minHeight: 0,
    onChange: async () => {
      // Fires after the document changed (typing, a block added or converted, a toolbar command) - how many blocks it has now.
      el.dispatchEvent(
        new CustomEvent<EditorjsChangeDetail>('editorjs-change', {
          bubbles: true,
          detail: { blocks: editor.blocks.getBlocksCount() },
        }),
      );
    },
  });
  el._editorjs = editor;
  await editor.isReady;
  if (!el.isConnected) return;
  dfDollar(el).attr('data-ready', '');
  // Fires once the editor mounted its blocks - the count it started with.
  el.dispatchEvent(
    new CustomEvent<EditorjsReadyDetail>('editorjs-ready', { bubbles: true, detail: { blocks: data.blocks.length } }),
  );
}
function init() {
  dfDollar('.editorjs:not([data-init])')
    .toArray()
    .forEach((el: HTMLElement) => {
      el.dataset.init = '';
      // el.store + el.api (AGENTS.md "State through stores")
      bindComponent(el, editorjsApi);
      const barId = dfDollar(el).attr('data-toolbar');
      const bar = barId ? dfDollar<HTMLElement>('#' + CSS.escape(barId)).get(0) : undefined;
      if (bar) {
        el._toolbar = bar;
        // a toolbar button must not take the selection from the editor
        dfDollar(bar).on('mousedown', (e: Event) => {
          if ((e.target as HTMLElement).closest('[data-editor-command]')) e.preventDefault();
        });
        dfDollar(bar).on('click', (e: Event) => {
          const button = (e.target as HTMLElement).closest<HTMLElement>('[data-editor-command]');
          if (!button) return;
          runCommand(el, button.dataset.editorCommand ?? '').then(() => syncToolbar(el));
        });
      }
      mount(el).catch(() => {
        dfDollar(el).attr('data-error', '');
      });
    });
}
if (!document.__editorjsInit) {
  document.__editorjsInit = true;
  document.addEventListener('selectionchange', () => {
    for (const el of dfDollar('.editorjs[data-ready]').toArray() as HTMLElement[]) syncToolbar(el);
  });
}
const resolve = (target: string | HTMLElement): HTMLElement | undefined =>
  typeof target === 'string' ? dfDollar(target).get(0) : target;
const editorOf = (target: string | HTMLElement): EditorLike | undefined =>
  resolve(target)?._editorjs as EditorLike | undefined;
df$.editorjs = {
  /**
   * Load the pinned editor build ahead of the first element (or a self-hosted copy).
   * @param url - the Editor.js ESM module to load instead of the pinned one
   * @returns resolves when the module is loaded
   */
  load: (url?: string): Promise<unknown> => loadModule(url || EDITORJS_URL),
  /** The pinned Editor.js build the component loads. */
  url: EDITORJS_URL,
  /**
   * The document as Markdown - headings, paragraphs, lists (nested, checklists), quotes, code, rules, tables; inline bold, italic, code and links.
   * @param target - the .editorjs element or its selector
   * @returns the Markdown, '' before the editor is ready
   */
  markdown: async (target: string | HTMLElement): Promise<string> => {
    const editor = editorOf(target);
    if (!editor) return '';
    await editor.isReady;
    return blocksToMarkdown((await editor.save()).blocks);
  },
  /**
   * Replace the document with Markdown.
   * @param target - the .editorjs element or its selector
   * @param markdown - the new document
   * @returns resolves once the blocks are rendered
   */
  setMarkdown: async (target: string | HTMLElement, markdown: string): Promise<void> => {
    const el = resolve(target);
    const editor = el?._editorjs as EditorLike | undefined;
    if (!el || !editor) return;
    await editor.isReady;
    await editor.blocks.render({ blocks: markdownToBlocks(markdown, el._marked as MarkedLike) });
  },
  /**
   * The document as Editor.js data (blocks with their ids).
   * @param target - the .editorjs element or its selector
   * @returns the saved document, or null before the editor is ready
   */
  blocks: async (target: string | HTMLElement): Promise<EditorjsDocument | null> => {
    const editor = editorOf(target);
    if (!editor) return null;
    await editor.isReady;
    return editor.save();
  },
  /**
   * Replace the document with Editor.js data.
   * @param target - the .editorjs element or its selector
   * @param data - the blocks to render
   * @returns resolves once the blocks are rendered
   */
  setBlocks: async (target: string | HTMLElement, data: EditorjsDocument): Promise<void> => {
    const editor = editorOf(target);
    if (!editor) return;
    await editor.isReady;
    await editor.blocks.render(data);
  },
  /**
   * Run a formatting command on the current selection or block - what a toolbar button with data-editor-command sends.
   * @param target - the .editorjs element or its selector
   * @param name - bold, italic, underline, marker, inline-code, paragraph, header:1-6, list:unordered|ordered|checklist, quote, code, delimiter, table
   * @returns true when the command applied
   */
  command: (target: string | HTMLElement, name: string): Promise<boolean> => {
    const el = resolve(target);
    return el ? runCommand(el, name) : Promise.resolve(false);
  },
  /**
   * The Editor.js instance behind an element, for anything this API does not cover.
   * @param target - the .editorjs element or its selector
   * @returns the editor, or undefined before it mounted
   */
  editor: (target: string | HTMLElement): unknown => editorOf(target),
  /**
   * Markdown → Editor.js blocks, with the parser the component loaded (after the first editor is ready).
   * @param target - any mounted .editorjs element or its selector (its parser is used)
   * @param markdown - the Markdown to convert
   * @returns the blocks, [] before a parser is loaded
   */
  toBlocks: (target: string | HTMLElement, markdown: string): EditorjsBlock[] => {
    const marked = resolve(target)?._marked as MarkedLike | undefined;
    return marked ? markdownToBlocks(markdown, marked) : [];
  },
  /**
   * Editor.js blocks → Markdown.
   * @param blocks - the blocks to serialize
   * @returns the Markdown
   */
  toMarkdown: (blocks: EditorjsBlock[]): string => blocksToMarkdown(blocks),
};
init();
new MutationObserver(init).observe(document, { childList: true, subtree: true });

Comments, ideas or improvements? Edit this page's source on GitHub