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 2a928c5..67a4a02 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,11 @@ { "name": "@editorjs/header", - "version": "2.8.8", + "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 e36face..437417e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,546 +1,132 @@ -/** - * 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; -} +import type { ToolConfig } from '@editorjs/editorjs'; +import type { TextNodeSerialized } from '@editorjs/model'; +import type { + BlockTool, + BlockToolAdapter, + BlockToolConstructorOptions, + BlockToolData +} from '@editorjs/sdk'; +import { ToolType } from '@editorjs/sdk'; +import styles from './index.module.pcss'; /** - * @description Tool's config from Editor + * Heading levels supported by the Header tool */ -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; -} +export type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6; /** - * @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', - }; - } - +export type HeaderData = BlockToolData<{ /** - * Check if data is valid - * - * @param {any} data - data to check - * @returns {data is HeaderData} - * @private + * Text content of the heading */ - isHeaderData(data: any): data is HeaderData { - return (data as HeaderData).text !== undefined; - } + text: TextNodeSerialized; /** - * Normalize input data - * - * @param {HeaderData} data - saved data to process - * - * @returns {HeaderData} - * @private + * Heading level (1–6) */ - 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; - } + level: HeadingLevel; +}>; +/** + * User-end configuration for the tool + */ +export type HeaderConfig = ToolConfig<{ /** - * Return Tool's view - * - * @returns {HTMLHeadingElement} - * @public + * Placeholder for an empty heading */ - render(): HTMLHeadingElement { - return this._element; - } + placeholder?: string; /** - * Returns header block tunes config - * - * @returns {Array} + * Default heading level when none is provided */ - 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') - })); - } + defaultLevel?: HeadingLevel; /** - * Callback for Block's settings buttons - * - * @param {number} level - level to set + * Heading levels available to the user */ - setLevel(level: number): void { - this.data = { - level: level, - text: this.data.text, - }; - } + levels?: HeadingLevel[]; +}>; - /** - * 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) - } +/** + * Header block tool + */ +export class Header implements BlockTool { + public static type = ToolType.Block as const; - /** - * 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() !== ''; - } + public static name = 'header'; /** - * Extract Tool's data from the view - * - * @param {HTMLHeadingElement} toolsContent - Text tools rendered view - * @returns {HeaderData} - saved data - * @public + * Default valid HTML heading level */ - save(toolsContent: HTMLHeadingElement): HeaderData { - return { - text: toolsContent.innerHTML, - level: this.currentLevel.number, - }; - } + static readonly #defaultLevel = 2; /** - * Allow Header to be converted to/from other blocks + * Minimum valid HTML heading level */ - static get conversionConfig() { - return { - export: 'text', // use 'text' property for other blocks - import: 'text', // fill 'text' property from other block's export string - }; - } + static readonly #minLevel = 1; /** - * Sanitizer Rules + * Maximum valid HTML heading level */ - static get sanitize() { - return { - level: false, - text: {}, - }; - } + static readonly #maxLevel = 6; /** - * Returns true to notify core that read-only is supported - * - * @returns {boolean} + * Adapter for linking block data with the DOM */ - static get isReadOnlySupported() { - return true; - } + #adapter: BlockToolAdapter; /** - * Get current Tools`s data - * - * @returns {HeaderData} Current data - * @private + * Tool's input/output data */ - get data(): HeaderData { - this._data.text = this._element.innerHTML; - this._data.level = this.currentLevel.number; - - return this._data; - } + #data: HeaderData; /** - * Store data in plugin: - * - at the this._data property - * - at the HTML - * - * @param {HeaderData} data — data to set - * @private + * User-end configuration */ - 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 || ''; - } - } + #config: HeaderConfig; /** - * Get tag for target level - * By default returns second-leveled header - * - * @returns {HTMLElement} + * @param options - Block tool constructor options */ - 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'; - - /** - * Add Placeholder - */ - tag.dataset.placeholder = this.api.i18n.t(this._settings.placeholder || ''); - - return tag; + constructor({ adapter, data, config }: BlockToolConstructorOptions) { + this.#adapter = adapter; + this.#data = data; + this.#config = config ?? {}; } /** - * Get current level - * - * @returns {level} + * Normalizes a raw value to a valid HeadingLevel (1–6). + * @param raw - Raw level value from persisted data or config */ - get currentLevel(): Level { - let level = this.levels.find(levelItem => levelItem.number === this._data.level); - - if (!level) { - level = this.defaultLevel; + #normalizeLevel(raw: unknown): HeadingLevel { + if (typeof raw !== 'number' || raw < Header.#minLevel || raw > Header.#maxLevel) { + return Header.#defaultLevel; } - return level; + return raw as HeadingLevel; } /** - * 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 - */ - - /** - * Available header levels - * - * @returns {level[]} + * Returns the current heading level, normalized to a valid value */ - 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; + get #level(): HeadingLevel { + return this.#normalizeLevel(this.#data.level ?? this.#config.defaultLevel); } /** - * Handle H1-H6 tags on paste to substitute it with header Tool - * - * @param {PasteEvent} event - event with pasted content + * Creates the heading 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 tag = `h${this.#level}` as keyof HTMLElementTagNameMap; + const heading = document.createElement(tag) as HTMLHeadingElement; - 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; - }); - } + heading.classList.add(styles.header); + heading.contentEditable = 'true'; - this.data = { - level, - text: content.innerHTML, - }; - } - } + this.#adapter.attachInput('text', heading); - /** - * 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'], - }; - } - - /** - * 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 heading; } } 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', + }, +});