From fa7677b6ccf7b994e13ca2a11e5ed554e6b39fae Mon Sep 17 00:00:00 2001 From: Murod Khaydarov Date: Tue, 14 Apr 2026 20:26:15 +0300 Subject: [PATCH 1/8] initial --- package.json | 2 +- src/index.ts | 564 +++++---------------------------------------------- 2 files changed, 48 insertions(+), 518 deletions(-) diff --git a/package.json b/package.json index 2a928c5..099f65a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@editorjs/header", - "version": "2.8.8", + "version": "3.0.0", "keywords": [ "codex editor", "header", diff --git a/src/index.ts b/src/index.ts index e36face..56b0f8c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,546 +1,76 @@ -/** - * Build styles - */ -import './index.css'; - -import { IconH1, IconH2, IconH3, IconH4, IconH5, IconH6, IconHeading } from '@codexteam/icons'; -import { API, BlockTune, PasteEvent } from '@editorjs/editorjs'; - -/** -* @description Tool's input and output data format -*/ -export interface HeaderData { - /** Header's content */ - text: string; - /** Header's level from 1 to 6 */ - level: number; -} - -/** - * @description Tool's config from Editor - */ -export interface HeaderConfig { - /** Block's placeholder */ - placeholder?: string; - /** Heading levels */ - levels?: number[]; - /** Default level */ - defaultLevel?: number; -} - -/** - * @description Heading level information - */ -interface Level { - /** Level number */ - number: number; - /** HTML tag corresponding with level number */ - tag: string; - /** Icon */ - svg: string; -} +import type { ToolConfig } from '@editorjs/editorjs'; +import type { TextNodeSerialized } from '@editorjs/model'; +import type { + ToolType, + BlockTool, + BlockToolAdapter, + BlockToolConstructor, + BlockToolConstructorOptions, + BlockToolData +} from '@editorjs/sdk'; /** - * @description Constructor arguments for Header + * Data structure describing the tool's input/output data */ -interface ConstructorArgs { - /** Previously saved data */ - data: HeaderData | {}; - /** User config for the tool */ - config: HeaderConfig; - /** Editor.js API */ - api: API; - /** Read-only mode flag */ - readOnly: boolean; -} - -/** - * Header block for the Editor.js. - * - * @author CodeX (team@ifmo.su) - * @copyright CodeX 2018 - * @license MIT - * @version 2.0.0 - */ -export default class Header { - /** - * Render plugin`s main Element and fill it with saved data - * - * @param {{data: HeaderData, config: HeaderConfig, api: object}} - * data — previously saved data - * config - user config for Tool - * api - Editor.js API - * readOnly - read only mode flag - */ - /** - * Editor.js API - * @private - */ - private api: API; - /** - * Read-only mode flag - * @private - */ - private readOnly: boolean; - /** - * Tool's settings passed from Editor - * @private - */ - private _settings: HeaderConfig; - /** - * Block's data - * @private - */ - private _data: HeaderData; - /** - * Main Block wrapper - * @private - */ - private _element: HTMLHeadingElement; - - constructor({ data, config, api, readOnly }: ConstructorArgs) { - this.api = api; - this.readOnly = readOnly; - - /** - * Tool's settings passed from Editor - * - * @type {HeaderConfig} - * @private - */ - this._settings = config; - - /** - * Block's data - * - * @type {HeaderData} - * @private - */ - this._data = this.normalizeData(data); - - /** - * Main Block wrapper - * - * @type {HTMLElement} - * @private - */ - this._element = this.getTag(); - } - /** - * Styles - */ - private get _CSS() { - return { - block: this.api.styles.block, - wrapper: 'ce-header', - }; - } - - /** - * Check if data is valid - * - * @param {any} data - data to check - * @returns {data is HeaderData} - * @private - */ - isHeaderData(data: any): data is HeaderData { - return (data as HeaderData).text !== undefined; - } - - /** - * Normalize input data - * - * @param {HeaderData} data - saved data to process - * - * @returns {HeaderData} - * @private - */ - normalizeData(data: HeaderData | {}): HeaderData { - const newData: HeaderData = { text: '', level: this.defaultLevel.number }; - - if (this.isHeaderData(data)) { - newData.text = data.text || ''; - - if (data.level !== undefined && !isNaN(parseInt(data.level.toString()))) { - newData.level = parseInt(data.level.toString()); - } - } - - return newData; - } - +export type HeaderData = BlockToolData<{ /** - * Return Tool's view - * - * @returns {HTMLHeadingElement} - * @public + * Text content of the header */ - render(): HTMLHeadingElement { - return this._element; - } - - /** - * Returns header block tunes config - * - * @returns {Array} - */ - renderSettings(): BlockTune[] { - return this.levels.map(level => ({ - icon: level.svg, - label: this.api.i18n.t(`Heading ${level.number}`), - onActivate: () => this.setLevel(level.number), - closeOnActivate: true, - isActive: this.currentLevel.number === level.number, - render: () => document.createElement('div') - })); - } - - /** - * Callback for Block's settings buttons - * - * @param {number} level - level to set - */ - setLevel(level: number): void { - this.data = { - level: level, - text: this.data.text, - }; - } - - /** - * Method that specified how to merge two Text blocks. - * Called by Editor.js by backspace at the beginning of the Block - * - * @param {HeaderData} data - saved data to merger with current block - * @public - */ - merge(data: HeaderData): void { - this._element.insertAdjacentHTML('beforeend', data.text) - } - - /** - * Validate Text block data: - * - check for emptiness - * - * @param {HeaderData} blockData — data received after saving - * @returns {boolean} false if saved data is not correct, otherwise true - * @public - */ - validate(blockData: HeaderData): boolean { - return blockData.text.trim() !== ''; - } - - /** - * Extract Tool's data from the view - * - * @param {HTMLHeadingElement} toolsContent - Text tools rendered view - * @returns {HeaderData} - saved data - * @public - */ - save(toolsContent: HTMLHeadingElement): HeaderData { - return { - text: toolsContent.innerHTML, - level: this.currentLevel.number, - }; - } - - /** - * Allow Header to be converted to/from other blocks - */ - static get conversionConfig() { - return { - export: 'text', // use 'text' property for other blocks - import: 'text', // fill 'text' property from other block's export string - }; - } - - /** - * Sanitizer Rules - */ - static get sanitize() { - return { - level: false, - text: {}, - }; - } - - /** - * Returns true to notify core that read-only is supported - * - * @returns {boolean} - */ - static get isReadOnlySupported() { - return true; - } - - /** - * Get current Tools`s data - * - * @returns {HeaderData} Current data - * @private - */ - get data(): HeaderData { - this._data.text = this._element.innerHTML; - this._data.level = this.currentLevel.number; - - return this._data; - } - - /** - * Store data in plugin: - * - at the this._data property - * - at the HTML - * - * @param {HeaderData} data — data to set - * @private - */ - set data(data: HeaderData) { - this._data = this.normalizeData(data); - - /** - * If level is set and block in DOM - * then replace it to a new block - */ - if (data.level !== undefined && this._element.parentNode) { - /** - * Create a new tag - * - * @type {HTMLHeadingElement} - */ - const newHeader = this.getTag(); - - /** - * Save Block's content - */ - newHeader.innerHTML = this._element.innerHTML; - - /** - * Replace blocks - */ - this._element.parentNode.replaceChild(newHeader, this._element); - - /** - * Save new block to private variable - * - * @type {HTMLHeadingElement} - * @private - */ - this._element = newHeader; - } - - /** - * If data.text was passed then update block's content - */ - if (data.text !== undefined) { - this._element.innerHTML = this._data.text || ''; - } - } + text: TextNodeSerialized; +}>; +/** + * User-end configuration for the tool + */ +export type HeaderConfig = ToolConfig<{ /** - * Get tag for target level - * By default returns second-leveled header - * - * @returns {HTMLElement} + * Placeholder for an empty header */ - getTag(): HTMLHeadingElement { - /** - * Create element for current Block's level - */ - const tag = document.createElement(this.currentLevel.tag) as HTMLHeadingElement; - - /** - * Add text to block - */ - tag.innerHTML = this._data.text || ''; - - /** - * Add styles class - */ - tag.classList.add(this._CSS.wrapper); - - /** - * Make tag editable - */ - tag.contentEditable = this.readOnly ? 'false' : 'true'; + placeholder?: string; +}>; - /** - * Add Placeholder - */ - tag.dataset.placeholder = this.api.i18n.t(this._settings.placeholder || ''); +/** + * Heading block tool + */ +export class Header implements BlockTool { + public static type = ToolType.Block as const; - return tag; - } + public static name = 'header'; /** - * Get current level - * - * @returns {level} + * Adapter for linking block data with the DOM */ - get currentLevel(): Level { - let level = this.levels.find(levelItem => levelItem.number === this._data.level); - - if (!level) { - level = this.defaultLevel; - } - - return level; - } + #adapter: BlockToolAdapter; /** - * Return default level - * - * @returns {level} - */ - get defaultLevel(): Level { - /** - * User can specify own default level value - */ - if (this._settings.defaultLevel) { - const userSpecified = this.levels.find(levelItem => { - return levelItem.number === this._settings.defaultLevel; - }); - - if (userSpecified) { - return userSpecified; - } else { - console.warn('(ง\'̀-\'́)ง Heading Tool: the default level specified was not found in available levels'); - } - } - - /** - * With no additional options, there will be H2 by default - * - * @type {level} - */ - return this.levels[1]; - } - - /** - * @typedef {object} level - * @property {number} number - level number - * @property {string} tag - tag corresponds with level number - * @property {string} svg - icon + * Tool's input/output data */ + #data: HeaderData; /** - * Available header levels - * - * @returns {level[]} + * @param options - Block tool constructor options */ - get levels(): Level[] { - const availableLevels = [ - { - number: 1, - tag: 'H1', - svg: IconH1, - }, - { - number: 2, - tag: 'H2', - svg: IconH2, - }, - { - number: 3, - tag: 'H3', - svg: IconH3, - }, - { - number: 4, - tag: 'H4', - svg: IconH4, - }, - { - number: 5, - tag: 'H5', - svg: IconH5, - }, - { - number: 6, - tag: 'H6', - svg: IconH6, - }, - ]; - - return this._settings.levels ? availableLevels.filter( - l => this._settings.levels!.includes(l.number) - ) : availableLevels; + constructor({ adapter, data }: BlockToolConstructorOptions) { + this.#adapter = adapter; + this.#data = data; } /** - * Handle H1-H6 tags on paste to substitute it with header Tool - * - * @param {PasteEvent} event - event with pasted content + * Creates tool element */ - onPaste(event: PasteEvent): void { - const detail = event.detail; - - if ('data' in detail) { - const content = detail.data as HTMLElement; - /** - * Define default level value - * - * @type {number} - */ - let level = this.defaultLevel.number; - - switch (content.tagName) { - case 'H1': - level = 1; - break; - case 'H2': - level = 2; - break; - case 'H3': - level = 3; - break; - case 'H4': - level = 4; - break; - case 'H5': - level = 5; - break; - case 'H6': - level = 6; - break; - } + public render(): HTMLElement { + const wrapper = document.createElement('h2'); - if (this._settings.levels) { - // Fallback to nearest level when specified not available - level = this._settings.levels.reduce((prevLevel, currLevel) => { - return Math.abs(currLevel - level) < Math.abs(prevLevel - level) ? currLevel : prevLevel; - }); - } + wrapper.classList.add('editorjs-header'); - this.data = { - level, - text: content.innerHTML, - }; - } - } + wrapper.contentEditable = 'true'; + wrapper.style.outline = 'none'; + wrapper.style.whiteSpace = 'pre-wrap'; - /** - * Used by Editor.js paste handling API. - * Provides configuration to handle H1-H6 tags. - * - * @returns {{handler: (function(HTMLElement): {text: string}), tags: string[]}} - */ - static get pasteConfig() { - return { - tags: ['H1', 'H2', 'H3', 'H4', 'H5', 'H6'], - }; - } + this.#adapter.attachInput('text', wrapper); - /** - * Get Tool toolbox settings - * icon - Tool icon's SVG - * title - title to show in toolbox - * - * @returns {{icon: string, title: string}} - */ - static get toolbox() { - return { - icon: IconHeading, - title: 'Heading', - }; + return wrapper; } } + +Header satisfies BlockToolConstructor; \ No newline at end of file From f62291dc9b32272a85ae22dfa643a7290ecb189d Mon Sep 17 00:00:00 2001 From: Murod Khaydarov Date: Thu, 16 Apr 2026 00:07:02 +0300 Subject: [PATCH 2/8] prepare for version 3 --- .gitignore | 31 ++++++++++-- eslint.config.mjs | 32 ++++++++++++ package.json | 50 +++++++++++-------- postcss.config.js | 9 ++++ src/index.css | 20 -------- src/index.module.pcss | 6 +++ src/index.ts | 90 +++++++++++++++++++++++++++------- src/types/codexteam-icons.d.ts | 10 ---- src/types/css.d.ts | 5 ++ tsconfig.json | 32 +++++++----- vite.config.ts | 36 ++++++++++++++ 11 files changed, 238 insertions(+), 83 deletions(-) create mode 100644 eslint.config.mjs create mode 100644 postcss.config.js delete mode 100644 src/index.css create mode 100644 src/index.module.pcss delete mode 100644 src/types/codexteam-icons.d.ts create mode 100644 src/types/css.d.ts create mode 100644 vite.config.ts diff --git a/.gitignore b/.gitignore index 641d5eb..f91aca7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,30 @@ +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/sdks +!.yarn/versions + +# Swap the comments on the following lines if you don't wish to use zero-installs +# Documentation here: https://yarnpkg.com/features/zero-installs +#!.yarn/cache +#.pnp.* + +# IDE +.idea/* + node_modules/* -npm-debug.log -.idea/ +dist/* + +# tests +coverage/ +reports/ + +# stryker temp files +.stryker-tmp +*.tsbuildinfo + .DS_Store -dist + +# ENV +**/.env diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..281f3a4 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,32 @@ +import CodeX from 'eslint-config-codex'; +import { fileURLToPath } from 'url'; +import { dirname } from 'path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export default [ + ...CodeX, + + { + ignores: ['vite.config.ts', 'postcss.config.js'], + }, + + { + languageOptions: { + parserOptions: { + project: './tsconfig.json', + tsconfigRootDir: __dirname, + sourceType: 'module', + }, + }, + rules: { + 'n/no-unpublished-import': ['error', { + allowModules: [ + 'eslint-config-codex', + ], + ignoreTypeImport: true, + }], + 'n/no-missing-import': 'off', + }, + }, +]; diff --git a/package.json b/package.json index 099f65a..67a4a02 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,11 @@ { "name": "@editorjs/header", "version": "3.0.0", + "packageManager": "yarn@4.0.1", + "type": "module", + "main": "./dist/header.cjs", + "module": "./dist/header.js", + "types": "./dist/index.d.ts", "keywords": [ "codex editor", "header", @@ -11,35 +16,38 @@ "description": "Heading Tool for Editor.js", "license": "MIT", "repository": "https://github.com/editor-js/header", - "files": [ - "dist" - ], - "main": "./dist/header.umd.js", - "module": "./dist/header.mjs", - "types": "./dist/index.d.ts", "exports": { ".": { - "import": "./dist/header.mjs", - "require": "./dist/header.umd.js", - "types": "./dist/index.d.ts" + "types": "./dist/index.d.ts", + "import": "./dist/header.js", + "require": "./dist/header.cjs", + "default": "./dist/header.js" } }, + "files": [ + "dist" + ], "scripts": { - "dev": "vite", - "build": "vite build" - }, - "author": { - "name": "CodeX", - "email": "team@codex.so" + "build": "yarn clear && vite build", + "dev": "vite build --watch", + "lint": "eslint ./src", + "lint:ci": "yarn lint --max-warnings 0", + "lint:fix": "yarn lint --fix", + "clear": "rm -rf ./dist && rm -f tsconfig.tsbuildinfo" }, "devDependencies": { - "typescript": "^5.4.5", - "vite": "^4.5.0", - "vite-plugin-css-injected-by-js": "^3.3.0", - "vite-plugin-dts": "^3.9.1" + "eslint": "^9.24.0", + "eslint-config-codex": "^2.0.3", + "postcss-apply": "^0.12.0", + "postcss-preset-env": "^10.1.5", + "typescript": "^5.5.4", + "vite": "^8.0.8", + "vite-plugin-css-injected-by-js": "^3.5.2", + "vite-plugin-dts": "^3.7.3" }, "dependencies": { - "@codexteam/icons": "^0.0.5", - "@editorjs/editorjs": "^2.29.1" + "@editorjs/editorjs": "^2.30.8", + "@editorjs/model": "workspace:^", + "@editorjs/sdk": "workspace:^" } } diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..e4d9981 --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,9 @@ +/** + * PostCSS configuration + */ +export default { + plugins: { + 'postcss-preset-env': {}, + 'postcss-apply': {}, + }, +}; diff --git a/src/index.css b/src/index.css deleted file mode 100644 index 5817e4e..0000000 --- a/src/index.css +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Plugin styles - */ -.ce-header { - padding: 0.6em 0 3px; - margin: 0; - line-height: 1.25em; - outline: none; -} - -.ce-header p, -.ce-header div{ - padding: 0 !important; - margin: 0 !important; -} - -/** - * Styles for Plugin icon in Toolbar - */ -.ce-header__icon {} diff --git a/src/index.module.pcss b/src/index.module.pcss new file mode 100644 index 0000000..ca31ad4 --- /dev/null +++ b/src/index.module.pcss @@ -0,0 +1,6 @@ +.header { + outline: none; + white-space: pre-wrap; + margin: 0; + min-height: 1em; +} diff --git a/src/index.ts b/src/index.ts index 56b0f8c..437417e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,22 +1,32 @@ import type { ToolConfig } from '@editorjs/editorjs'; import type { TextNodeSerialized } from '@editorjs/model'; import type { - ToolType, BlockTool, BlockToolAdapter, - BlockToolConstructor, BlockToolConstructorOptions, BlockToolData } from '@editorjs/sdk'; +import { ToolType } from '@editorjs/sdk'; +import styles from './index.module.pcss'; + +/** + * Heading levels supported by the Header tool + */ +export type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6; /** * Data structure describing the tool's input/output data */ export type HeaderData = BlockToolData<{ /** - * Text content of the header + * Text content of the heading */ text: TextNodeSerialized; + + /** + * Heading level (1–6) + */ + level: HeadingLevel; }>; /** @@ -24,19 +34,44 @@ export type HeaderData = BlockToolData<{ */ export type HeaderConfig = ToolConfig<{ /** - * Placeholder for an empty header + * Placeholder for an empty heading */ placeholder?: string; + + /** + * Default heading level when none is provided + */ + defaultLevel?: HeadingLevel; + + /** + * Heading levels available to the user + */ + levels?: HeadingLevel[]; }>; /** - * Heading block tool + * Header block tool */ export class Header implements BlockTool { public static type = ToolType.Block as const; public static name = 'header'; + /** + * Default valid HTML heading level + */ + static readonly #defaultLevel = 2; + + /** + * Minimum valid HTML heading level + */ + static readonly #minLevel = 1; + + /** + * Maximum valid HTML heading level + */ + static readonly #maxLevel = 6; + /** * Adapter for linking block data with the DOM */ @@ -47,30 +82,51 @@ export class Header implements BlockTool { */ #data: HeaderData; + /** + * User-end configuration + */ + #config: HeaderConfig; + /** * @param options - Block tool constructor options */ - constructor({ adapter, data }: BlockToolConstructorOptions) { + constructor({ adapter, data, config }: BlockToolConstructorOptions) { this.#adapter = adapter; this.#data = data; + this.#config = config ?? {}; } /** - * Creates tool element + * Normalizes a raw value to a valid HeadingLevel (1–6). + * @param raw - Raw level value from persisted data or config */ - public render(): HTMLElement { - const wrapper = document.createElement('h2'); + #normalizeLevel(raw: unknown): HeadingLevel { + if (typeof raw !== 'number' || raw < Header.#minLevel || raw > Header.#maxLevel) { + return Header.#defaultLevel; + } - wrapper.classList.add('editorjs-header'); + return raw as HeadingLevel; + } - wrapper.contentEditable = 'true'; - wrapper.style.outline = 'none'; - wrapper.style.whiteSpace = 'pre-wrap'; + /** + * Returns the current heading level, normalized to a valid value + */ + get #level(): HeadingLevel { + return this.#normalizeLevel(this.#data.level ?? this.#config.defaultLevel); + } + + /** + * Creates the heading element + */ + public render(): HTMLElement { + const tag = `h${this.#level}` as keyof HTMLElementTagNameMap; + const heading = document.createElement(tag) as HTMLHeadingElement; - this.#adapter.attachInput('text', wrapper); + heading.classList.add(styles.header); + heading.contentEditable = 'true'; - return wrapper; + this.#adapter.attachInput('text', heading); + + return heading; } } - -Header satisfies BlockToolConstructor; \ No newline at end of file diff --git a/src/types/codexteam-icons.d.ts b/src/types/codexteam-icons.d.ts deleted file mode 100644 index 8aefcea..0000000 --- a/src/types/codexteam-icons.d.ts +++ /dev/null @@ -1,10 +0,0 @@ -// types/codexteam-icons.d.ts -declare module '@codexteam/icons' { - export const IconH1: any; - export const IconH2: any; - export const IconH3: any; - export const IconH4: any; - export const IconH5: any; - export const IconH6: any; - export const IconHeading: any; - } \ No newline at end of file diff --git a/src/types/css.d.ts b/src/types/css.d.ts new file mode 100644 index 0000000..c095e57 --- /dev/null +++ b/src/types/css.d.ts @@ -0,0 +1,5 @@ +declare module '*.pcss' { + const content: { [className: string]: string }; + + export default content; +} diff --git a/tsconfig.json b/tsconfig.json index ee8329e..69a4359 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,16 +1,24 @@ { "compilerOptions": { - /* Language and Environment */ - "target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ - /* Modules */ - "module": "CommonJS", /* Specify what module code is generated. */ - "typeRoots": ["./node_modules/@types", "./types"], /* Specify multiple folders that act like './node_modules/@types'. */ - /* Interop Constraints */ - "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ - "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ - /* Type Checking */ - "strict": true, /* Enable all strict type-checking options. */ + "composite": true, + "target": "esnext", + "module": "esnext", + "moduleResolution": "bundler", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "experimentalDecorators": true, + "rootDir": "src", + "outDir": "dist", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "preserveConstEnums": true }, - "include": ["src/*", - "src/types/**/*"] + "include": ["src/**/*"], + "exclude": [ + "node_modules/**/*", + "dist/**/*" + ] } diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..8208f24 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,36 @@ +import { defineConfig } from 'vite'; +import { resolve } from 'path'; +import dts from 'vite-plugin-dts'; +import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js'; + +export default defineConfig({ + plugins: [ + dts(), + cssInjectedByJsPlugin(), + ], + build: { + lib: { + entry: resolve(__dirname, 'src/index.ts'), + name: 'Header', + formats: ['es', 'cjs'], + fileName: 'header', + }, + rollupOptions: { + external: [ + '@editorjs/editorjs', + '@editorjs/model', + '@editorjs/sdk', + ], + }, + sourcemap: true, + }, + css: { + modules: { + generateScopedName: (name) => `editorjs-${name}`, + localsConvention: 'dashes', + }, + }, + esbuild: { + target: 'esnext', + }, +}); From 28d36a48ab7d98fd418168ac28db496cb9fc1c18 Mon Sep 17 00:00:00 2001 From: Reversean Date: Tue, 7 Jul 2026 20:55:15 +0300 Subject: [PATCH 3/8] feat: migrate Header tool to the Editor.js v3 SDK Replace the legacy attachInput-based Tool API with the data-first model used by @editorjs/model and @editorjs/sdk: register text and level as model-backed keys and render the DOM lazily in response to adapter events, instead of building elements eagerly in render(). Add a toolbox with H1-H3 entries, conversionConfig, and canBeSplit, and cover the tool with unit tests. --- eslint.config.mjs | 27 +++-- jest.config.ts | 19 +++ package.json | 10 ++ src/__mocks__/styleMock.cjs | 3 + src/index.spec.ts | 234 ++++++++++++++++++++++++++++++++++++ src/index.ts | 176 ++++++++++++++++++++------- tsconfig.eslint.json | 14 +++ tsconfig.json | 1 + vite.config.js | 27 ----- 9 files changed, 427 insertions(+), 84 deletions(-) create mode 100644 jest.config.ts create mode 100644 src/__mocks__/styleMock.cjs create mode 100644 src/index.spec.ts create mode 100644 tsconfig.eslint.json delete mode 100644 vite.config.js diff --git a/eslint.config.mjs b/eslint.config.mjs index 281f3a4..b66d63a 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,32 +1,37 @@ import CodeX from 'eslint-config-codex'; -import { fileURLToPath } from 'url'; -import { dirname } from 'path'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); export default [ ...CodeX, { - ignores: ['vite.config.ts', 'postcss.config.js'], + ignores: ['vite.config.ts', 'postcss.config.js', 'src/__mocks__/*'], }, { languageOptions: { parserOptions: { - project: './tsconfig.json', - tsconfigRootDir: __dirname, + project: './tsconfig.eslint.json', + tsconfigRootDir: import.meta.dirname, sourceType: 'module', }, }, rules: { 'n/no-unpublished-import': ['error', { - allowModules: [ - 'eslint-config-codex', - ], + allowModules: ['eslint-config-codex'], ignoreTypeImport: true, }], - 'n/no-missing-import': 'off', + 'n/no-missing-import': ['error', { + allowModules: ['@editorjs/model', '@editorjs/sdk', '@editorjs/dom-adapters'], + }], + }, + }, + + { + files: ['**/*.spec.ts'], + rules: { + 'n/no-unpublished-import': ['error', { + allowModules: ['@jest/globals'], + }], }, }, ]; diff --git a/jest.config.ts b/jest.config.ts new file mode 100644 index 0000000..18395a5 --- /dev/null +++ b/jest.config.ts @@ -0,0 +1,19 @@ +import type { JestConfigWithTsJest } from 'ts-jest'; + +export default { + preset: 'ts-jest', + testEnvironment: 'jsdom', + testMatch: ['/src/**/*.spec.ts'], + extensionsToTreatAsEsm: ['.ts'], + moduleNameMapper: { + '^(\\.{1,2}/.*)\\.js$': '$1', + '\\.pcss$': '/src/__mocks__/styleMock.cjs', + }, + coverageReporters: ['lcov', 'json-summary', 'text-summary'], + transform: { + '^.+\\.tsx?$': [ + 'ts-jest', + { useESM: true }, + ], + }, +} as JestConfigWithTsJest; diff --git a/package.json b/package.json index 67a4a02..8622da3 100644 --- a/package.json +++ b/package.json @@ -30,22 +30,32 @@ "scripts": { "build": "yarn clear && vite build", "dev": "vite build --watch", + "test": "node --experimental-vm-modules $(yarn bin jest)", + "test:coverage": "yarn test --coverage=true", "lint": "eslint ./src", "lint:ci": "yarn lint --max-warnings 0", "lint:fix": "yarn lint --fix", "clear": "rm -rf ./dist && rm -f tsconfig.tsbuildinfo" }, "devDependencies": { + "@jest/globals": "^29.7.0", + "@types/jest": "^29.5.1", + "@types/node": "^22.10.2", "eslint": "^9.24.0", "eslint-config-codex": "^2.0.3", + "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", "postcss-apply": "^0.12.0", "postcss-preset-env": "^10.1.5", + "ts-jest": "^29.2.5", "typescript": "^5.5.4", "vite": "^8.0.8", "vite-plugin-css-injected-by-js": "^3.5.2", "vite-plugin-dts": "^3.7.3" }, "dependencies": { + "@codexteam/icons": "^0.3.3", + "@editorjs/dom-adapters": "workspace:^", "@editorjs/editorjs": "^2.30.8", "@editorjs/model": "workspace:^", "@editorjs/sdk": "workspace:^" diff --git a/src/__mocks__/styleMock.cjs b/src/__mocks__/styleMock.cjs new file mode 100644 index 0000000..b4622bb --- /dev/null +++ b/src/__mocks__/styleMock.cjs @@ -0,0 +1,3 @@ +module.exports = new Proxy({}, { + get: (_target, prop) => prop, +}); diff --git a/src/index.spec.ts b/src/index.spec.ts new file mode 100644 index 0000000..928f24e --- /dev/null +++ b/src/index.spec.ts @@ -0,0 +1,234 @@ +/* eslint-disable @typescript-eslint/no-magic-numbers, jsdoc/require-jsdoc, @typescript-eslint/explicit-function-return-type */ +import { beforeEach, describe, expect, it, jest } from '@jest/globals'; +import { KeyAddedEvent, KeyRemovedEvent, ToolType, ValueNodeChangedEvent } from '@editorjs/sdk'; +import type { EditorAPI } from '@editorjs/sdk'; +import type { DOMBlockToolAdapter } from '@editorjs/dom-adapters'; +import { Header } from './index.js'; +import type { HeaderData, HeaderConfig, HeadingLevel } from './index.js'; + +const createMockAdapter = () => { + const base = new EventTarget(); + const realAddEventListener = base.addEventListener.bind(base); + + return Object.assign(base, { + registerTextInputKey: jest.fn(), + registerValueKey: jest.fn(), + setInput: jest.fn(), + getBlockId: jest.fn<() => string>().mockReturnValue('test-block-id'), + getBlockIndex: jest.fn<() => number>().mockReturnValue(0), + addEventListener: jest.fn(realAddEventListener), + }); +}; + +let mockAdapter: ReturnType; + +beforeEach(() => { + mockAdapter = createMockAdapter(); +}); + +const createHeader = ( + levelInput: unknown, + configOverrides: Partial = {} +): InstanceType => { + return new Header({ + adapter: mockAdapter as unknown as DOMBlockToolAdapter, + data: { level: levelInput } as unknown as HeaderData, + config: configOverrides as HeaderConfig, + api: {} as EditorAPI, + } as never); +}; + +describe('Header', () => { + describe('static fields', () => { + it('should have type set to Block and name set to header', () => { + expect(Header.type).toBe(ToolType.Block); + expect(Header.name).toBe('header'); + }); + + it('should have toolbox entries for levels 1, 2, and 3', () => { + const { toolbox } = Header.options; + + expect(Array.isArray(toolbox)).toBe(true); + + const entries = toolbox as unknown as Array<{ title: string; + data: { level: number }; }>; + + expect(entries).toHaveLength(3); + expect(entries[0].data.level).toBe(1); + expect(entries[1].data.level).toBe(2); + expect(entries[2].data.level).toBe(3); + }); + + it('should have conversionConfig with import and export pointing to the text key', () => { + const { conversionConfig } = Header.options; + + expect(conversionConfig).toEqual({ import: 'text', + export: 'text' }); + }); + + it('should have canBeSplit set to false', () => { + expect(Header.options.canBeSplit).toBe(false); + }); + }); + + describe('constructor', () => { + it('should register text and level data nodes and subscribe to adapter events', () => { + createHeader(2); + + expect(mockAdapter.registerTextInputKey).toHaveBeenCalledWith('text', undefined); + expect(mockAdapter.registerValueKey).toHaveBeenCalledWith('level', 2); + expect(mockAdapter.addEventListener).toHaveBeenCalledWith( + 'adapter:updated', + expect.any(Function) + ); + }); + + describe('level normalisation via registerValueKey', () => { + it.each([1, 2, 3, 4, 5, 6] as const)( + 'should register level %i as-is when data.level is a valid integer', + (level) => { + createHeader(level); + expect(mockAdapter.registerValueKey).toHaveBeenCalledWith('level', level); + } + ); + + it.each([undefined, null, 'text', 0, 7, -1, 1.5])( + 'should register default level 2 when data.level is %p', + (level) => { + createHeader(level); + expect(mockAdapter.registerValueKey).toHaveBeenCalledWith('level', 2); + } + ); + + it('should use config.defaultLevel when raw level is invalid', () => { + createHeader(undefined, { defaultLevel: 4 }); + expect(mockAdapter.registerValueKey).toHaveBeenCalledWith('level', 4); + }); + + it('should fall back to 2 when both data.level and config.defaultLevel are invalid', () => { + createHeader(undefined, { defaultLevel: 99 as HeadingLevel }); + expect(mockAdapter.registerValueKey).toHaveBeenCalledWith('level', 2); + }); + }); + }); + + describe('render()', () => { + it('should return an HTMLElement', () => { + const header = createHeader(2); + + expect(header.render()).toBeInstanceOf(HTMLElement); + }); + + it('should return the same element on subsequent calls', () => { + const header = createHeader(2); + + expect(header.render()).toBe(header.render()); + }); + }); + + describe('#onUpdate — KeyAddedEvent text', () => { + it.each([1, 2, 3, 4, 5, 6] as const)( + 'should create h%i, call setInput, and append to wrapper when text key is added with level %i', + (level) => { + const header = createHeader(level); + const wrapper = header.render(); + + mockAdapter.dispatchEvent(new KeyAddedEvent('text')); + + const heading = wrapper.querySelector(`h${level}`) as HTMLElement; + + expect(heading).not.toBeNull(); + expect(heading.contentEditable).toBe('true'); + expect(mockAdapter.setInput).toHaveBeenCalledWith('text', heading); + } + ); + + it('should not react to KeyAddedEvent for keys other than text', () => { + const header = createHeader(2); + + header.render(); + + mockAdapter.dispatchEvent(new KeyAddedEvent('someOtherKey')); + + expect(mockAdapter.setInput).not.toHaveBeenCalled(); + }); + }); + + describe('#onUpdate — KeyRemovedEvent text', () => { + it('should call setInput with undefined and remove element from wrapper', () => { + const header = createHeader(2); + const wrapper = header.render(); + + mockAdapter.dispatchEvent(new KeyAddedEvent('text')); + expect(wrapper.firstElementChild).not.toBeNull(); + + mockAdapter.setInput.mockClear(); + mockAdapter.dispatchEvent(new KeyRemovedEvent('text')); + + expect(mockAdapter.setInput).toHaveBeenCalledWith('text', undefined); + expect(wrapper.firstElementChild).toBeNull(); + }); + + it('should not react to KeyRemovedEvent for keys other than text', () => { + const header = createHeader(2); + + header.render(); + mockAdapter.dispatchEvent(new KeyAddedEvent('text')); + mockAdapter.setInput.mockClear(); + + mockAdapter.dispatchEvent(new KeyRemovedEvent('someOtherKey')); + + expect(mockAdapter.setInput).not.toHaveBeenCalled(); + }); + }); + + describe('#onUpdate — ValueNodeChangedEvent level', () => { + const setupWithHeading = (initialLevel: HeadingLevel): { + header: InstanceType; + wrapper: HTMLElement; + } => { + const header = createHeader(initialLevel); + const wrapper = header.render(); + + mockAdapter.dispatchEvent(new KeyAddedEvent('text')); + mockAdapter.setInput.mockClear(); + + return { header, + wrapper }; + }; + + it.each<[HeadingLevel, HeadingLevel]>([[2, 1], [1, 6], [3, 4]])( + 'should replace h%i with h%i and call setInput with new element when level changes', + (from, to) => { + const { wrapper } = setupWithHeading(from); + + mockAdapter.dispatchEvent(new ValueNodeChangedEvent('level', to)); + + const heading = wrapper.querySelector(`h${to}`) as HTMLElement; + + expect(heading).not.toBeNull(); + expect(mockAdapter.setInput).toHaveBeenCalledWith('text', heading); + } + ); + + it('should not recreate the element when the level value is identical', () => { + const { wrapper } = setupWithHeading(2); + const originalHeading = wrapper.querySelector('h2'); + + mockAdapter.dispatchEvent(new ValueNodeChangedEvent('level', 2)); + + expect(wrapper.querySelector('h2')).toBe(originalHeading); + expect(mockAdapter.setInput).not.toHaveBeenCalled(); + }); + + it('should not react to ValueNodeChangedEvent for keys other than level', () => { + const { wrapper } = setupWithHeading(2); + const originalHeading = wrapper.querySelector('h2'); + + mockAdapter.dispatchEvent(new ValueNodeChangedEvent('someOtherKey', 'value')); + + expect(wrapper.querySelector('h2')).toBe(originalHeading); + expect(mockAdapter.setInput).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/index.ts b/src/index.ts index 437417e..11ffb6d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,11 +2,13 @@ import type { ToolConfig } from '@editorjs/editorjs'; import type { TextNodeSerialized } from '@editorjs/model'; import type { BlockTool, - BlockToolAdapter, + BlockToolConstructor, BlockToolConstructorOptions, BlockToolData } from '@editorjs/sdk'; -import { ToolType } from '@editorjs/sdk'; +import { KeyAddedEvent, KeyRemovedEvent, ToolType, ValueNodeChangedEvent } from '@editorjs/sdk'; +import type { DOMBlockToolAdapter } from '@editorjs/dom-adapters'; +import { IconH1, IconH2, IconH3 } from '@codexteam/icons'; import styles from './index.module.pcss'; /** @@ -57,76 +59,158 @@ export class Header implements BlockTool { public static name = 'header'; - /** - * Default valid HTML heading level - */ - static readonly #defaultLevel = 2; - - /** - * Minimum valid HTML heading level - */ + public static readonly options = { + toolbox: [ + { title: 'Heading 1', + icon: IconH1, + data: { level: 1 as HeadingLevel } }, + { title: 'Heading 2', + icon: IconH2, + data: { level: 2 as HeadingLevel } }, + { title: 'Heading 3', + icon: IconH3, + // eslint-disable-next-line @typescript-eslint/no-magic-numbers -- self-evident from the title above + data: { level: 3 as HeadingLevel } }, + ], + conversionConfig: { + import: 'text', + export: 'text', + }, + canBeSplit: false as const, + }; + + static readonly #defaultLevel: HeadingLevel = 2; static readonly #minLevel = 1; - - /** - * Maximum valid HTML heading level - */ static readonly #maxLevel = 6; - /** - * Adapter for linking block data with the DOM - */ - #adapter: BlockToolAdapter; + #adapter: DOMBlockToolAdapter; + #config: HeaderConfig; + #currentLevel: HeadingLevel; + #wrapper: HTMLDivElement | undefined; + #headingEl: HTMLElement | undefined; /** - * Tool's input/output data + * @param options - Block tool constructor options */ - #data: HeaderData; + constructor({ adapter, data, config }: BlockToolConstructorOptions) { + this.#adapter = adapter; + this.#config = config ?? {} as HeaderConfig; - /** - * User-end configuration - */ - #config: HeaderConfig; + const level = this.#normalizeLevel(data?.level); + + this.#currentLevel = level; + + /** + * addEventListener must be called before registerTextInputKey/registerValueKey — + * those synchronously fire a DataNodeAddedEvent, which the adapter turns into a + * KeyAddedEvent. If the listener isn't attached yet, that event is lost. + */ + adapter.addEventListener('adapter:updated', this.#onUpdate); + adapter.registerTextInputKey('text', data?.text); + adapter.registerValueKey('level', level); + } /** - * @param options - Block tool constructor options + * Returns tool's wrapper, creating it if it doesn't exist yet. + * As we maintain the data-first approach, actual inputs should be rendered only when the model is updated. */ - constructor({ adapter, data, config }: BlockToolConstructorOptions) { - this.#adapter = adapter; - this.#data = data; - this.#config = config ?? {}; + public render(): HTMLElement { + if (this.#wrapper === undefined) { + this.#wrapper = document.createElement('div'); + } + + return this.#wrapper; } /** - * Normalizes a raw value to a valid HeadingLevel (1–6). + * Normalizes a raw value to a valid HeadingLevel (1–6), falling back to config.defaultLevel or the tool default. * @param raw - Raw level value from persisted data or config */ #normalizeLevel(raw: unknown): HeadingLevel { - if (typeof raw !== 'number' || raw < Header.#minLevel || raw > Header.#maxLevel) { - return Header.#defaultLevel; + if ( + typeof raw === 'number' + && Number.isInteger(raw) + && raw >= Header.#minLevel + && raw <= Header.#maxLevel + ) { + return raw as HeadingLevel; + } + const fallback = this.#config.defaultLevel; + + if ( + fallback !== undefined + && Number.isInteger(fallback) + && fallback >= Header.#minLevel + && fallback <= Header.#maxLevel + ) { + return fallback; } - return raw as HeadingLevel; + return Header.#defaultLevel; } /** - * Returns the current heading level, normalized to a valid value + * Creates a heading element for the given level + * @param level - heading level to create an element for */ - get #level(): HeadingLevel { - return this.#normalizeLevel(this.#data.level ?? this.#config.defaultLevel); + #createHeadingEl(level: HeadingLevel): HTMLElement { + const el = document.createElement(`h${level}`); + + el.classList.add(styles.header); + el.contentEditable = 'true'; + + return el; } /** - * Creates the heading element + * Replaces the current heading element with one of a new level, preserving the DOM position + * @param level - heading level to swap to */ - public render(): HTMLElement { - const tag = `h${this.#level}` as keyof HTMLElementTagNameMap; - const heading = document.createElement(tag) as HTMLHeadingElement; - - heading.classList.add(styles.header); - heading.contentEditable = 'true'; - - this.#adapter.attachInput('text', heading); + #swapHeadingTag(level: HeadingLevel): void { + if (this.#headingEl === undefined) { + return; + } + const newEl = this.#createHeadingEl(level); - return heading; + this.#headingEl.replaceWith(newEl); + this.#headingEl = newEl; + this.#adapter.setInput('text', newEl); } + + /** + * Callback for Adapter updates + * @param event - adapter event (KeyAdded, KeyRemoved or ValueChanged) + */ + #onUpdate = (event: Event): void => { + if (event instanceof KeyAddedEvent) { + if (event.detail.key !== 'text') { + return; + } + const el = this.#createHeadingEl(this.#currentLevel); + + this.#headingEl = el; + this.#wrapper?.append(el); + this.#adapter.setInput('text', el); + } else if (event instanceof KeyRemovedEvent) { + if (event.detail.key !== 'text') { + return; + } + this.#adapter.setInput('text', undefined); + this.#headingEl?.remove(); + this.#headingEl = undefined; + } else if (event instanceof ValueNodeChangedEvent) { + if (event.detail.key !== 'level') { + return; + } + const newLevel = this.#normalizeLevel(event.detail.value); + + if (newLevel === this.#currentLevel) { + return; + } + this.#currentLevel = newLevel; + this.#swapHeadingTag(newLevel); + } + }; } + +Header satisfies BlockToolConstructor; diff --git a/tsconfig.eslint.json b/tsconfig.eslint.json new file mode 100644 index 0000000..ba2c4ea --- /dev/null +++ b/tsconfig.eslint.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "types": ["node", "jest"] + }, + "extends": "./tsconfig.json", + "include": [ + "src/**/*", + "eslint.config.mjs", + "**/*.spec.ts", + "jest.config.ts", + "vite.config.ts" + ], + "exclude": ["dist/**/*", "node_modules/**/*"] +} diff --git a/tsconfig.json b/tsconfig.json index 69a4359..7cd284a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,6 +9,7 @@ "strict": true, "skipLibCheck": true, "experimentalDecorators": true, + "types": ["jest"], "rootDir": "src", "outDir": "dist", "declaration": true, diff --git a/vite.config.js b/vite.config.js deleted file mode 100644 index 58350f4..0000000 --- a/vite.config.js +++ /dev/null @@ -1,27 +0,0 @@ -import path from "path"; -import cssInjectedByJsPlugin from "vite-plugin-css-injected-by-js"; -import * as pkg from "./package.json"; -import dts from 'vite-plugin-dts'; - -const NODE_ENV = process.argv.mode || "development"; -const VERSION = pkg.version; - -export default { - build: { - copyPublicDir: false, - lib: { - entry: path.resolve(__dirname, "src", "index.ts"), - name: "Header", - fileName: "header", - }, - }, - define: { - NODE_ENV: JSON.stringify(NODE_ENV), - VERSION: JSON.stringify(VERSION), - }, - - plugins: [cssInjectedByJsPlugin(), - dts({ - tsconfigPath: './tsconfig.json' - })], -}; From ceb78cafa37896af0507a2f93a0b12e9d6c67bb3 Mon Sep 17 00:00:00 2001 From: Reversean Date: Tue, 7 Jul 2026 21:40:19 +0300 Subject: [PATCH 4/8] docs: update README for the v3 API and document workspace-only usage Replace the v2-era installation/config/output docs (default export, shortcut config, plain-string text, unused levels option) with the current named export and TextNodeSerialized-based text field. Add a TODO note explaining this package only builds inside the document-model workspace as a git submodule, and can't be installed or published standalone yet. --- README.md | 78 ++++++++++++------------------------------------------- 1 file changed, 17 insertions(+), 61 deletions(-) diff --git a/README.md b/README.md index e0d5d9a..6c52a7d 100644 --- a/README.md +++ b/README.md @@ -1,58 +1,18 @@ # Heading Tool -![Version of EditorJS that the plugin is compatible with](https://badgen.net/badge/Editor.js/v2.0/blue) - -Provides Headings Blocks for the [Editor.js](https://ifmo.su/editor). +Provides Heading blocks for [Editor.js](https://editorjs.io) v3. ## Installation -Get the package - -```shell -yarn add @editorjs/header -``` - -Include module at your application - ```javascript -import Header from '@editorjs/header'; -``` - -Optionally, you can load this tool from CDN [JsDelivr CDN](https://cdn.jsdelivr.net/npm/@editorjs/header@latest) - -## Usage - -Add a new Tool to the `tools` property of the Editor.js initial config. +import { Header } from '@editorjs/header'; -```javascript var editor = EditorJS({ ... - tools: { ... header: Header, }, - - ... -}); -``` - -## Shortcut - -You can insert this Block by a useful shortcut. Set it up with the `tools[].shortcut` property of the Editor's initial config. - -```javascript -var editor = EditorJS({ - ... - - tools: { - ... - header: { - class: Header, - shortcut: 'CMD+SHIFT+H', - }, - }, - ... }); ``` @@ -61,51 +21,47 @@ var editor = EditorJS({ All properties are optional. -| Field | Type | Description | -| ------------ | ---------- | --------------------------- | -| placeholder | `string` | header's placeholder string | -| levels | `number[]` | enabled heading levels | -| defaultLevel | `number` | default heading level | +| Field | Type | Description | +| ------------ | -------- | ----------------------- | +| placeholder | `string` | header's placeholder string | +| defaultLevel | `number` | default heading level (1–6), used when no level is set on the block | ```javascript var editor = EditorJS({ ... - tools: { ... header: { class: Header, config: { placeholder: 'Enter a header', - levels: [2, 3, 4], defaultLevel: 3 } } } - ... }); ``` -## Tool's settings - -![An image showing the header block tool](https://capella.pics/634ad545-08d7-4cb7-8409-f01289e0e5e1.jpg) - -You can select one of six levels for heading. - ## Output data -| Field | Type | Description | -| ----- | -------- | ------------------------------------------------ | -| text | `string` | header's text | -| level | `number` | level of header: 1 for H1, 2 for H2 ... 6 for H6 | +| Field | Type | Description | +| ----- | ------------------- | ------------------------------------------------ | +| text | `TextNodeSerialized` | header's text, with inline formatting fragments | +| level | `number` | level of header: 1 for H1, 2 for H2 ... 6 for H6 | ```json { "type": "header", "data": { - "text": "Why Telegram is the best messenger", + "text": { "value": "Why Telegram is the best messenger", "fragments": [] }, "level": 2 } } ``` + +## TODO + +This package depends on `@editorjs/model`, `@editorjs/sdk`, and `@editorjs/dom-adapters` via `workspace:^`, and is meant to be developed, built, and tested only inside the [`document-model`](https://github.com/editor-js/document-model) workspace (as a git submodule under `packages/tools/header`) — it isn't installable or buildable standalone right now. `yarn install` in this repo alone will fail with a "Workspace not found" error. + +Publishing `@editorjs/header` to npm will happen through `document-model`'s own release pipeline once the submodule is wired in, not through this repository's own CI. From 7fab8b0db5930d78209f832ed251fa54b9136f9a Mon Sep 17 00:00:00 2001 From: Reversean Date: Sun, 12 Jul 2026 15:31:35 +0300 Subject: [PATCH 5/8] refactor: address review feedback on the v3 migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use switch (true) / instanceof for adapter event handling in #onUpdate, matching the pattern used in the base BlockToolAdapter that dispatches these events. - Add JSDoc to all public and private class members. - Reformat the toolbox array for consistent indentation. - Move @editorjs/editorjs, @editorjs/model, and @editorjs/dom-adapters to devDependencies — they're only used for types, never at runtime. --- package.json | 6 +-- src/index.ts | 126 +++++++++++++++++++++++++++++++++++++++------------ 2 files changed, 100 insertions(+), 32 deletions(-) diff --git a/package.json b/package.json index 8622da3..23b79c0 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,9 @@ "clear": "rm -rf ./dist && rm -f tsconfig.tsbuildinfo" }, "devDependencies": { + "@editorjs/dom-adapters": "workspace:^", + "@editorjs/editorjs": "^2.30.8", + "@editorjs/model": "workspace:^", "@jest/globals": "^29.7.0", "@types/jest": "^29.5.1", "@types/node": "^22.10.2", @@ -55,9 +58,6 @@ }, "dependencies": { "@codexteam/icons": "^0.3.3", - "@editorjs/dom-adapters": "workspace:^", - "@editorjs/editorjs": "^2.30.8", - "@editorjs/model": "workspace:^", "@editorjs/sdk": "workspace:^" } } diff --git a/src/index.ts b/src/index.ts index 11ffb6d..6a63a66 100644 --- a/src/index.ts +++ b/src/index.ts @@ -55,38 +55,94 @@ export type HeaderConfig = ToolConfig<{ * Header block tool */ export class Header implements BlockTool { + /** + * Tool type — Header is a Block Tool + */ public static type = ToolType.Block as const; + /** + * Tool name used to register and identify it within the editor + */ public static name = 'header'; + /** + * Static tool options: toolbox entries, conversion config, and split behavior + */ public static readonly options = { + /** + * Toolbox entries — one button per heading level offered to the user + */ toolbox: [ - { title: 'Heading 1', + { + title: 'Heading 1', icon: IconH1, - data: { level: 1 as HeadingLevel } }, - { title: 'Heading 2', + data: { level: 1 as HeadingLevel }, + }, + { + title: 'Heading 2', icon: IconH2, - data: { level: 2 as HeadingLevel } }, - { title: 'Heading 3', + data: { level: 2 as HeadingLevel }, + }, + { + title: 'Heading 3', icon: IconH3, // eslint-disable-next-line @typescript-eslint/no-magic-numbers -- self-evident from the title above - data: { level: 3 as HeadingLevel } }, + data: { level: 3 as HeadingLevel }, + }, ], + + /** + * Maps the block's text content to/from the shared "text" conversion key + */ conversionConfig: { import: 'text', export: 'text', }, + + /** + * Header blocks can't be split into two header blocks on Enter + */ canBeSplit: false as const, }; + /** + * Heading level used when neither persisted data nor config specify a valid one + */ static readonly #defaultLevel: HeadingLevel = 2; + + /** + * Lowest valid HTML heading level + */ static readonly #minLevel = 1; + + /** + * Highest valid HTML heading level + */ static readonly #maxLevel = 6; + /** + * Adapter for linking block data with the DOM + */ #adapter: DOMBlockToolAdapter; + + /** + * User-end configuration passed to the tool + */ #config: HeaderConfig; + + /** + * Currently applied heading level + */ #currentLevel: HeadingLevel; + + /** + * Tool's wrapper element, created lazily on first render() + */ #wrapper: HTMLDivElement | undefined; + + /** + * Heading input element, created lazily once the model registers the "text" key + */ #headingEl: HTMLElement | undefined; /** @@ -182,33 +238,45 @@ export class Header implements BlockTool { * @param event - adapter event (KeyAdded, KeyRemoved or ValueChanged) */ #onUpdate = (event: Event): void => { - if (event instanceof KeyAddedEvent) { - if (event.detail.key !== 'text') { - return; - } - const el = this.#createHeadingEl(this.#currentLevel); - - this.#headingEl = el; - this.#wrapper?.append(el); - this.#adapter.setInput('text', el); - } else if (event instanceof KeyRemovedEvent) { - if (event.detail.key !== 'text') { - return; + switch (true) { + case event instanceof KeyAddedEvent: { + if (event.detail.key !== 'text') { + break; + } + const el = this.#createHeadingEl(this.#currentLevel); + + this.#headingEl = el; + this.#wrapper?.append(el); + this.#adapter.setInput('text', el); + + break; } - this.#adapter.setInput('text', undefined); - this.#headingEl?.remove(); - this.#headingEl = undefined; - } else if (event instanceof ValueNodeChangedEvent) { - if (event.detail.key !== 'level') { - return; + + case event instanceof KeyRemovedEvent: { + if (event.detail.key !== 'text') { + break; + } + this.#adapter.setInput('text', undefined); + this.#headingEl?.remove(); + this.#headingEl = undefined; + + break; } - const newLevel = this.#normalizeLevel(event.detail.value); - if (newLevel === this.#currentLevel) { - return; + case event instanceof ValueNodeChangedEvent: { + if (event.detail.key !== 'level') { + break; + } + const newLevel = this.#normalizeLevel(event.detail.value); + + if (newLevel === this.#currentLevel) { + break; + } + this.#currentLevel = newLevel; + this.#swapHeadingTag(newLevel); + + break; } - this.#currentLevel = newLevel; - this.#swapHeadingTag(newLevel); } }; } From a7c8211f693b5a9d69c28e279b40d86a8f4ad7b3 Mon Sep 17 00:00:00 2001 From: Reversean Date: Sun, 12 Jul 2026 18:28:25 +0300 Subject: [PATCH 6/8] docs: document levels as reserved, not yet enforced Restore the levels field in the config table with an honest status: it's declared in the type but the static per-level toolbox can't read per-registration config, so it has no effect today. Filtering which levels are offered belongs to a future block-settings UI (Block Tunes) that will have access to config at selection time. --- README.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 6c52a7d..d3ac46c 100644 --- a/README.md +++ b/README.md @@ -21,10 +21,11 @@ var editor = EditorJS({ All properties are optional. -| Field | Type | Description | -| ------------ | -------- | ----------------------- | -| placeholder | `string` | header's placeholder string | -| defaultLevel | `number` | default heading level (1–6), used when no level is set on the block | +| Field | Type | Description | +| ------------ | ---------- | ----------------------- | +| placeholder | `string` | header's placeholder string | +| defaultLevel | `number` | default heading level (1–6), used when no level is set on the block | +| levels | `number[]` | reserved for restricting which heading levels are offered to the user — not yet enforced, see TODO below | ```javascript var editor = EditorJS({ @@ -65,3 +66,5 @@ var editor = EditorJS({ This package depends on `@editorjs/model`, `@editorjs/sdk`, and `@editorjs/dom-adapters` via `workspace:^`, and is meant to be developed, built, and tested only inside the [`document-model`](https://github.com/editor-js/document-model) workspace (as a git submodule under `packages/tools/header`) — it isn't installable or buildable standalone right now. `yarn install` in this repo alone will fail with a "Workspace not found" error. Publishing `@editorjs/header` to npm will happen through `document-model`'s own release pipeline once the submodule is wired in, not through this repository's own CI. + +`HeaderConfig.levels` is declared but not enforced yet — the toolbox is a static, per-level list of buttons (see `options.toolbox` in `src/index.ts`) evaluated before any config exists, so it can't currently be filtered by `levels`. Restricting which heading levels are offered belongs to a future block-settings UI (Block Tunes, still in design in document-model) that has access to the block's own config at the point a level is chosen. The internal level tracking (`registerValueKey`/`ValueNodeChangedEvent`) is already wired up and doesn't need to change for that to work. From aa7f944afb781c1324b212988f94415dece94897 Mon Sep 17 00:00:00 2001 From: Reversean Date: Sun, 12 Jul 2026 19:36:48 +0300 Subject: [PATCH 7/8] docs: minimize README changes to what this PR actually affects Revert to heading-v3's original README content and apply only the three changes actually forced by this PR's code: - named export (Header is a named, not default, export) - text field is TextNodeSerialized, not a plain string - workspace:^-only / submodule TODO note (package.json now needs model/sdk/dom-adapters, unlike heading-v3's plain editorjs dep) Everything else (registration syntax, Shortcut, Tool's settings, levels) was already stale or non-functional in heading-v3, predates this PR, and is left untouched rather than "fixed" here. --- README.md | 69 ++++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 58 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index d3ac46c..186243a 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,58 @@ # Heading Tool -Provides Heading blocks for [Editor.js](https://editorjs.io) v3. +![Version of EditorJS that the plugin is compatible with](https://badgen.net/badge/Editor.js/v2.0/blue) + +Provides Headings Blocks for the [Editor.js](https://ifmo.su/editor). ## Installation +Get the package + +```shell +yarn add @editorjs/header +``` + +Include module at your application + ```javascript import { Header } from '@editorjs/header'; +``` + +Optionally, you can load this tool from CDN [JsDelivr CDN](https://cdn.jsdelivr.net/npm/@editorjs/header@latest) + +## Usage + +Add a new Tool to the `tools` property of the Editor.js initial config. +```javascript var editor = EditorJS({ ... + tools: { ... header: Header, }, + + ... +}); +``` + +## Shortcut + +You can insert this Block by a useful shortcut. Set it up with the `tools[].shortcut` property of the Editor's initial config. + +```javascript +var editor = EditorJS({ + ... + + tools: { + ... + header: { + class: Header, + shortcut: 'CMD+SHIFT+H', + }, + }, + ... }); ``` @@ -21,35 +61,44 @@ var editor = EditorJS({ All properties are optional. -| Field | Type | Description | -| ------------ | ---------- | ----------------------- | +| Field | Type | Description | +| ------------ | ---------- | --------------------------- | | placeholder | `string` | header's placeholder string | -| defaultLevel | `number` | default heading level (1–6), used when no level is set on the block | -| levels | `number[]` | reserved for restricting which heading levels are offered to the user — not yet enforced, see TODO below | +| levels | `number[]` | enabled heading levels | +| defaultLevel | `number` | default heading level | ```javascript var editor = EditorJS({ ... + tools: { ... header: { class: Header, config: { placeholder: 'Enter a header', + levels: [2, 3, 4], defaultLevel: 3 } } } + ... }); ``` +## Tool's settings + +![An image showing the header block tool](https://capella.pics/634ad545-08d7-4cb7-8409-f01289e0e5e1.jpg) + +You can select one of six levels for heading. + ## Output data -| Field | Type | Description | -| ----- | ------------------- | ------------------------------------------------ | -| text | `TextNodeSerialized` | header's text, with inline formatting fragments | -| level | `number` | level of header: 1 for H1, 2 for H2 ... 6 for H6 | +| Field | Type | Description | +| ----- | -------------------- | ------------------------------------------------ | +| text | `TextNodeSerialized` | header's text, with inline formatting fragments | +| level | `number` | level of header: 1 for H1, 2 for H2 ... 6 for H6 | ```json { @@ -66,5 +115,3 @@ var editor = EditorJS({ This package depends on `@editorjs/model`, `@editorjs/sdk`, and `@editorjs/dom-adapters` via `workspace:^`, and is meant to be developed, built, and tested only inside the [`document-model`](https://github.com/editor-js/document-model) workspace (as a git submodule under `packages/tools/header`) — it isn't installable or buildable standalone right now. `yarn install` in this repo alone will fail with a "Workspace not found" error. Publishing `@editorjs/header` to npm will happen through `document-model`'s own release pipeline once the submodule is wired in, not through this repository's own CI. - -`HeaderConfig.levels` is declared but not enforced yet — the toolbox is a static, per-level list of buttons (see `options.toolbox` in `src/index.ts`) evaluated before any config exists, so it can't currently be filtered by `levels`. Restricting which heading levels are offered belongs to a future block-settings UI (Block Tunes, still in design in document-model) that has access to the block's own config at the point a level is chosen. The internal level tracking (`registerValueKey`/`ValueNodeChangedEvent`) is already wired up and doesn't need to change for that to work. From f11ad3319d8eb4574ddf687bc8d8c89db91cf94d Mon Sep 17 00:00:00 2001 From: Reversean Date: Sun, 12 Jul 2026 22:52:33 +0300 Subject: [PATCH 8/8] docs: refine JSDoc wording and fix stray formatting - defaultLevel: cover both "missing" and "invalid" fallback cases, not just "missing" - #normalizeLevel's @param: describe the parameter's contract (unvalidated candidate value) instead of enumerating call sites - Use {@link render} / {@link HeaderConfig.defaultLevel} instead of plain-text method/field references, matching existing convention in dom-adapters/core/sdk - Fix formatting drift introduced by an editor auto-format pass (double quotes, trailing commas) back to project style --- src/index.ts | 39 ++++++++++++++++++++++++++++----------- 1 file changed, 28 insertions(+), 11 deletions(-) diff --git a/src/index.ts b/src/index.ts index 6a63a66..83a8350 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,7 +6,12 @@ import type { BlockToolConstructorOptions, BlockToolData } from '@editorjs/sdk'; -import { KeyAddedEvent, KeyRemovedEvent, ToolType, ValueNodeChangedEvent } from '@editorjs/sdk'; +import { + KeyAddedEvent, + KeyRemovedEvent, + ToolType, + ValueNodeChangedEvent +} from '@editorjs/sdk'; import type { DOMBlockToolAdapter } from '@editorjs/dom-adapters'; import { IconH1, IconH2, IconH3 } from '@codexteam/icons'; import styles from './index.module.pcss'; @@ -41,7 +46,7 @@ export type HeaderConfig = ToolConfig<{ placeholder?: string; /** - * Default heading level when none is provided + * Fallback heading level used when the persisted or provided level is missing or invalid */ defaultLevel?: HeadingLevel; @@ -100,7 +105,7 @@ export class Header implements BlockTool { }, /** - * Header blocks can't be split into two header blocks on Enter + * Header blocks can't be split into two header blocks */ canBeSplit: false as const, }; @@ -111,12 +116,12 @@ export class Header implements BlockTool { static readonly #defaultLevel: HeadingLevel = 2; /** - * Lowest valid HTML heading level + * Lowest valid heading level */ static readonly #minLevel = 1; /** - * Highest valid HTML heading level + * Highest valid heading level */ static readonly #maxLevel = 6; @@ -136,7 +141,7 @@ export class Header implements BlockTool { #currentLevel: HeadingLevel; /** - * Tool's wrapper element, created lazily on first render() + * Tool's wrapper element, created lazily on first {@link render} call */ #wrapper: HTMLDivElement | undefined; @@ -148,9 +153,17 @@ export class Header implements BlockTool { /** * @param options - Block tool constructor options */ - constructor({ adapter, data, config }: BlockToolConstructorOptions) { + constructor({ + adapter, + data, + config, + }: BlockToolConstructorOptions< + HeaderData, + HeaderConfig, + DOMBlockToolAdapter + >) { this.#adapter = adapter; - this.#config = config ?? {} as HeaderConfig; + this.#config = config ?? ({} as HeaderConfig); const level = this.#normalizeLevel(data?.level); @@ -179,8 +192,8 @@ export class Header implements BlockTool { } /** - * Normalizes a raw value to a valid HeadingLevel (1–6), falling back to config.defaultLevel or the tool default. - * @param raw - Raw level value from persisted data or config + * Normalizes a raw value to a valid HeadingLevel (1–6), falling back to {@link HeaderConfig.defaultLevel} or the tool default. + * @param raw - Candidate level to validate; not guaranteed to be a number, an integer, or in range */ #normalizeLevel(raw: unknown): HeadingLevel { if ( @@ -281,4 +294,8 @@ export class Header implements BlockTool { }; } -Header satisfies BlockToolConstructor; +Header satisfies BlockToolConstructor< + HeaderData, + HeaderConfig, + DOMBlockToolAdapter +>;