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/README.md b/README.md index e0d5d9a..186243a 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ yarn add @editorjs/header Include module at your application ```javascript -import Header from '@editorjs/header'; +import { Header } from '@editorjs/header'; ``` Optionally, you can load this tool from CDN [JsDelivr CDN](https://cdn.jsdelivr.net/npm/@editorjs/header@latest) @@ -95,17 +95,23 @@ 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. diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..b66d63a --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,37 @@ +import CodeX from 'eslint-config-codex'; + +export default [ + ...CodeX, + + { + ignores: ['vite.config.ts', 'postcss.config.js', 'src/__mocks__/*'], + }, + + { + languageOptions: { + parserOptions: { + project: './tsconfig.eslint.json', + tsconfigRootDir: import.meta.dirname, + sourceType: 'module', + }, + }, + rules: { + 'n/no-unpublished-import': ['error', { + allowModules: ['eslint-config-codex'], + ignoreTypeImport: true, + }], + '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 2a928c5..23b79c0 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,48 @@ "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", + "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": { - "typescript": "^5.4.5", - "vite": "^4.5.0", - "vite-plugin-css-injected-by-js": "^3.3.0", - "vite-plugin-dts": "^3.9.1" + "@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", + "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.0.5", - "@editorjs/editorjs": "^2.29.1" + "@codexteam/icons": "^0.3.3", + "@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/__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.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.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 e36face..83a8350 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,546 +1,301 @@ -/** - * Build styles - */ -import './index.css'; - -import { IconH1, IconH2, IconH3, IconH4, IconH5, IconH6, IconHeading } from '@codexteam/icons'; -import { API, BlockTune, PasteEvent } from '@editorjs/editorjs'; +import type { ToolConfig } from '@editorjs/editorjs'; +import type { TextNodeSerialized } from '@editorjs/model'; +import type { + BlockTool, + BlockToolConstructor, + BlockToolConstructorOptions, + BlockToolData +} 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'; /** -* @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 + * Heading levels supported by the Header tool */ -export interface HeaderConfig { - /** Block's placeholder */ - placeholder?: string; - /** Heading levels */ - levels?: number[]; - /** Default level */ - defaultLevel?: number; -} +export type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6; /** - * @description Heading level information + * Data structure describing the tool's input/output data */ -interface Level { - /** Level number */ - number: number; - /** HTML tag corresponding with level number */ - tag: string; - /** Icon */ - svg: string; -} +export type HeaderData = BlockToolData<{ + /** + * Text content of the heading + */ + text: TextNodeSerialized; -/** - * @description Constructor arguments for Header - */ -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; -} + /** + * Heading level (1–6) + */ + level: HeadingLevel; +}>; /** - * Header block for the Editor.js. - * - * @author CodeX (team@ifmo.su) - * @copyright CodeX 2018 - * @license MIT - * @version 2.0.0 + * User-end configuration for the tool */ -export default class Header { +export type HeaderConfig = ToolConfig<{ /** - * 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 + * Placeholder for an empty heading */ + placeholder?: string; + /** - * 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; + * Fallback heading level used when the persisted or provided level is missing or invalid + */ + defaultLevel?: HeadingLevel; + /** - * Block's data - * @private - */ - private _data: HeaderData; + * Heading levels available to the user + */ + levels?: HeadingLevel[]; +}>; + +/** + * Header block tool + */ +export class Header implements BlockTool { /** - * Main Block wrapper - * @private - */ - private _element: HTMLHeadingElement; + * Tool type — Header is a Block Tool + */ + public static type = ToolType.Block as const; - constructor({ data, config, api, readOnly }: ConstructorArgs) { - this.api = api; - this.readOnly = readOnly; + /** + * 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 = { /** - * Tool's settings passed from Editor - * - * @type {HeaderConfig} - * @private + * Toolbox entries — one button per heading level offered to the user */ - this._settings = config; + 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 }, + }, + ], /** - * Block's data - * - * @type {HeaderData} - * @private + * Maps the block's text content to/from the shared "text" conversion key */ - this._data = this.normalizeData(data); + conversionConfig: { + import: 'text', + export: 'text', + }, /** - * Main Block wrapper - * - * @type {HTMLElement} - * @private + * Header blocks can't be split into two header blocks */ - this._element = this.getTag(); - } - /** - * Styles - */ - private get _CSS() { - return { - block: this.api.styles.block, - wrapper: 'ce-header', - }; - } + canBeSplit: false as const, + }; /** - * Check if data is valid - * - * @param {any} data - data to check - * @returns {data is HeaderData} - * @private + * Heading level used when neither persisted data nor config specify a valid one */ - isHeaderData(data: any): data is HeaderData { - return (data as HeaderData).text !== undefined; - } + static readonly #defaultLevel: HeadingLevel = 2; /** - * Normalize input data - * - * @param {HeaderData} data - saved data to process - * - * @returns {HeaderData} - * @private + * Lowest valid heading level */ - 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; - } + static readonly #minLevel = 1; /** - * Return Tool's view - * - * @returns {HTMLHeadingElement} - * @public + * Highest valid heading level */ - render(): HTMLHeadingElement { - return this._element; - } + static readonly #maxLevel = 6; /** - * Returns header block tunes config - * - * @returns {Array} + * Adapter for linking block data with the DOM */ - 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') - })); - } + #adapter: DOMBlockToolAdapter; /** - * Callback for Block's settings buttons - * - * @param {number} level - level to set + * User-end configuration passed to the tool */ - setLevel(level: number): void { - this.data = { - level: level, - text: this.data.text, - }; - } + #config: HeaderConfig; /** - * 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 + * Currently applied heading level */ - merge(data: HeaderData): void { - this._element.insertAdjacentHTML('beforeend', data.text) - } + #currentLevel: HeadingLevel; /** - * 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 + * Tool's wrapper element, created lazily on first {@link render} call */ - validate(blockData: HeaderData): boolean { - return blockData.text.trim() !== ''; - } + #wrapper: HTMLDivElement | undefined; /** - * Extract Tool's data from the view - * - * @param {HTMLHeadingElement} toolsContent - Text tools rendered view - * @returns {HeaderData} - saved data - * @public + * Heading input element, created lazily once the model registers the "text" key */ - save(toolsContent: HTMLHeadingElement): HeaderData { - return { - text: toolsContent.innerHTML, - level: this.currentLevel.number, - }; - } + #headingEl: HTMLElement | undefined; /** - * Allow Header to be converted to/from other blocks + * @param options - Block tool constructor options */ - static get conversionConfig() { - return { - export: 'text', // use 'text' property for other blocks - import: 'text', // fill 'text' property from other block's export string - }; - } + constructor({ + adapter, + data, + config, + }: BlockToolConstructorOptions< + HeaderData, + HeaderConfig, + DOMBlockToolAdapter + >) { + this.#adapter = adapter; + this.#config = config ?? ({} as HeaderConfig); + + const level = this.#normalizeLevel(data?.level); + + this.#currentLevel = level; - /** - * Sanitizer Rules - */ - static get sanitize() { - return { - level: false, - text: {}, - }; + /** + * 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); } /** - * Returns true to notify core that read-only is supported - * - * @returns {boolean} + * 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. */ - static get isReadOnlySupported() { - return true; - } + public render(): HTMLElement { + if (this.#wrapper === undefined) { + this.#wrapper = document.createElement('div'); + } - /** - * 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; + return this.#wrapper; } /** - * Store data in plugin: - * - at the this._data property - * - at the HTML - * - * @param {HeaderData} data — data to set - * @private + * 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 */ - 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; + #normalizeLevel(raw: unknown): HeadingLevel { + if ( + typeof raw === 'number' + && Number.isInteger(raw) + && raw >= Header.#minLevel + && raw <= Header.#maxLevel + ) { + return raw as HeadingLevel; } - - /** - * If data.text was passed then update block's content - */ - if (data.text !== undefined) { - this._element.innerHTML = this._data.text || ''; + const fallback = this.#config.defaultLevel; + + if ( + fallback !== undefined + && Number.isInteger(fallback) + && fallback >= Header.#minLevel + && fallback <= Header.#maxLevel + ) { + return fallback; } - } - - /** - * Get tag for target level - * By default returns second-leveled header - * - * @returns {HTMLElement} - */ - 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; + return Header.#defaultLevel; } /** - * Get current level - * - * @returns {level} + * Creates a heading element for the given level + * @param level - heading level to create an element for */ - get currentLevel(): Level { - let level = this.levels.find(levelItem => levelItem.number === this._data.level); + #createHeadingEl(level: HeadingLevel): HTMLElement { + const el = document.createElement(`h${level}`); - if (!level) { - level = this.defaultLevel; - } + el.classList.add(styles.header); + el.contentEditable = 'true'; - return level; + return el; } /** - * Return default level - * - * @returns {level} + * Replaces the current heading element with one of a new level, preserving the DOM position + * @param level - heading level to swap to */ - 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'); - } + #swapHeadingTag(level: HeadingLevel): void { + if (this.#headingEl === undefined) { + return; } + const newEl = this.#createHeadingEl(level); - /** - * With no additional options, there will be H2 by default - * - * @type {level} - */ - return this.levels[1]; + this.#headingEl.replaceWith(newEl); + this.#headingEl = newEl; + this.#adapter.setInput('text', newEl); } /** - * @typedef {object} level - * @property {number} number - level number - * @property {string} tag - tag corresponds with level number - * @property {string} svg - icon + * Callback for Adapter updates + * @param event - adapter event (KeyAdded, KeyRemoved or ValueChanged) */ + #onUpdate = (event: Event): void => { + switch (true) { + case event instanceof KeyAddedEvent: { + if (event.detail.key !== 'text') { + break; + } + const el = this.#createHeadingEl(this.#currentLevel); - /** - * Available header levels - * - * @returns {level[]} - */ - 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, - }, - ]; + this.#headingEl = el; + this.#wrapper?.append(el); + this.#adapter.setInput('text', el); - return this._settings.levels ? availableLevels.filter( - l => this._settings.levels!.includes(l.number) - ) : availableLevels; - } + break; + } - /** - * Handle H1-H6 tags on paste to substitute it with header Tool - * - * @param {PasteEvent} event - event with pasted content - */ - 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; + case event instanceof KeyRemovedEvent: { + if (event.detail.key !== 'text') { break; - case 'H5': - level = 5; - break; - case 'H6': - level = 6; - break; - } + } + this.#adapter.setInput('text', undefined); + this.#headingEl?.remove(); + this.#headingEl = undefined; - 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; - }); + break; } - this.data = { - level, - text: content.innerHTML, - }; - } - } + case event instanceof ValueNodeChangedEvent: { + if (event.detail.key !== 'level') { + break; + } + const newLevel = this.#normalizeLevel(event.detail.value); - /** - * 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'], - }; - } + if (newLevel === this.#currentLevel) { + break; + } + this.#currentLevel = newLevel; + this.#swapHeadingTag(newLevel); - /** - * 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', - }; - } + break; + } + } + }; } + +Header satisfies BlockToolConstructor< + HeaderData, + HeaderConfig, + DOMBlockToolAdapter +>; 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.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 ee8329e..7cd284a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,16 +1,25 @@ { "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, + "types": ["jest"], + "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.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' - })], -}; 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', + }, +});