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