Editor.jsMOL
The official Editor.js block editor as a component: write the document as Markdown (or as Editor.js JSON) inside the element, the runtime loads the pinned builds of the editor and its tools on the first editor of a page, mounts it in the theme's tokens and hands the document back as Markdown or as blocks. A Toolbar drives the formatting through data-editor-command buttons. The Document Editor scaffold builds a Google-Docs-style editor with a comment column on it.
On this page (9)
§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 applyreadonly-data-readonlyon 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:
| State | Type | Values | Default | Description |
|---|---|---|---|---|
readonly | boolean | true, false | false | Read 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.
| State | Description |
|---|---|
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
| Member | Description | |||||||||
|---|---|---|---|---|---|---|---|---|---|---|
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.
Returns | |||||||||
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 | |||||||||
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.
Returns | |||||||||
el.api.settled(): Promise<void> | Wait for the last state's DOM work (async states: a diagram rendering, a chart mounting). Returns | |||||||||
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
| Member | Description | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
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.
Returns | ||||||||||||
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.
Returns | ||||||||||||
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.
Returns | ||||||||||||
df$.shadcn.editorjsApi.store(el: HTMLElement): Store<{ name: EditorjsState; config: EditorjsStateConfigs[EditorjsState] }> | The element's store (bindComponent made it).
Returns | ||||||||||||
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.
| ||||||||||||
df$.shadcn.editorjsStates: EditorjsState[] | The declared states, 'default' first: default, readonly. |
df$.shadcn.editorjs
| Member | Description | |||||||||
|---|---|---|---|---|---|---|---|---|---|---|
load(url?: string): Promise<unknown> | Load the pinned editor build ahead of the first element (or a self-hosted copy).
Returns | |||||||||
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.
Returns | |||||||||
setMarkdown(target: string | HTMLElement, markdown: string): Promise<void> | Replace the document with Markdown.
Returns | |||||||||
blocks(target: string | HTMLElement): Promise<EditorjsDocument | null> | The document as Editor.js data (blocks with their ids).
Returns | |||||||||
setBlocks(target: string | HTMLElement, data: EditorjsDocument): Promise<void> | Replace the document with Editor.js data.
Returns | |||||||||
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.
Returns | |||||||||
editor(target: string | HTMLElement): unknown | The Editor.js instance behind an element, for anything this API does not cover.
Returns | |||||||||
toBlocks(target: string | HTMLElement, markdown: string): EditorjsBlock[] | Markdown → Editor.js blocks, with the parser the component loaded (after the first editor is ready).
Returns | |||||||||
toMarkdown(blocks: EditorjsBlock[]): string | Editor.js blocks → Markdown.
Returns |
Events
| Event | Description | ||||||
|---|---|---|---|---|---|---|---|
editorjs-change | Fires after the document changed (typing, a block added or converted, a toolbar command) - how many blocks it has now.
| ||||||
editorjs-ready | Fires once the editor mounted its blocks - the count it started with.
|
Types
| Type | Description | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
EditorjsBlock | One Editor.js block, as the editor saves it.
| ||||||||||||
EditorjsChangeDetail | What editorjs-change carries.
| ||||||||||||
EditorjsDocument | The saved document: Editor.js output data.
| ||||||||||||
EditorjsReadyDetail | What editorjs-ready carries.
|
§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(/ /g, ' ').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/&/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