From 7ff5faa46f188aa9081b1fb19ed429173c140d9b Mon Sep 17 00:00:00 2001 From: Yaroslav Gusev <59017579+GoldenJaden@users.noreply.github.com> Date: Wed, 28 Feb 2024 19:33:50 +0700 Subject: [PATCH 01/61] Change workflows trigger (#2595) --- .github/workflows/bump-version-on-merge-next.yml | 9 ++++++++- .github/workflows/create-a-release-draft.yml | 9 ++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/.github/workflows/bump-version-on-merge-next.yml b/.github/workflows/bump-version-on-merge-next.yml index 3cfa5e1cf..28c346885 100644 --- a/.github/workflows/bump-version-on-merge-next.yml +++ b/.github/workflows/bump-version-on-merge-next.yml @@ -1,7 +1,14 @@ name: Bump version on merge +# Caution: +# the use of "pull_request_target" trigger allows to successfully +# run workflow even when triggered from a fork. The trigger grants +# access to repo's secrets and gives write permission to the runner. +# This can be used to run malicious code on untrusted PR, so, please +# DO NOT checkout any PR's ongoing commits (aka github.event.pull_request.head.sha) +# while using this trigger. on: - pull_request: + pull_request_target: branches: - next types: [closed] diff --git a/.github/workflows/create-a-release-draft.yml b/.github/workflows/create-a-release-draft.yml index c4c5d3323..176fe2858 100644 --- a/.github/workflows/create-a-release-draft.yml +++ b/.github/workflows/create-a-release-draft.yml @@ -1,7 +1,14 @@ name: Create a release draft +# Caution: +# the use of "pull_request_target" trigger allows to successfully +# run workflow even when triggered from a fork. The trigger grants +# access to repo's secrets and gives write permission to the runner. +# This can be used to run malicious code on untrusted PR, so, please +# DO NOT checkout any PR's ongoing commits (aka github.event.pull_request.head.sha) +# while using this trigger. on: - pull_request: + pull_request_target: branches: - next types: [closed] From 8138ce95b2fb482e8618c5b260b48f5095815beb Mon Sep 17 00:00:00 2001 From: VikhorKonstantin Date: Wed, 28 Feb 2024 21:08:08 +0400 Subject: [PATCH 02/61] fix issue #2523 (#2639) * fix isMutationBelongsToElement function: make it return true if the whole text node is deleted inside of some descendant of the passed element * isMutationBelongsToElement function shouldn't return true if some of the ancestors of the passed element were added or deleted, only if the element itself * add test case verifying that 'onChange' is fired when the whole text inside some nested descendant of the block is removed * replace introduced dependency with ToolMock * add comment explaining isMutationBelongsToElement behaviour in case of adding/removing the passed element itself * fix formatting * added some more explanation * added record to the changelog --------- Co-authored-by: Peter Savchenko --- docs/CHANGELOG.md | 4 +++ src/components/utils/mutations.ts | 30 ++++++++-------- test/cypress/tests/onchange.cy.ts | 58 +++++++++++++++++++++++++++++++ 3 files changed, 77 insertions(+), 15 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 0a1108fcb..6d63e3c4b 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +### 2.30.0 + +- `Fix` — `onChange` will be called when removing the entire text within a descendant element of a block. + ### 2.29.1 - `Fix` — Toolbox wont be shown when Slash pressed with along with Shift or Alt diff --git a/src/components/utils/mutations.ts b/src/components/utils/mutations.ts index ce8bcff8b..6e98456df 100644 --- a/src/components/utils/mutations.ts +++ b/src/components/utils/mutations.ts @@ -8,28 +8,28 @@ export function isMutationBelongsToElement(mutationRecord: MutationRecord, eleme const { type, target, addedNodes, removedNodes } = mutationRecord; /** - * In case of removing the whole text in element, mutation type will be 'childList', - * 'removedNodes' will contain text node that is not existed anymore, so we can't check it with 'contains' method - * But Target will be the element itself, so we can detect it. + * Covers all types of mutations happened to the element or it's descendants with the only one exception - removing/adding the element itself; */ - if (target === element) { + if (element.contains(target)) { return true; } /** - * Check typing and attributes changes + * In case of removing/adding the element itself, mutation type will be 'childList' and 'removedNodes'/'addedNodes' will contain the element. */ - if (['characterData', 'attributes'].includes(type)) { - const targetElement = target.nodeType === Node.TEXT_NODE ? target.parentNode : target; + if (type === 'childList') { + const elementAddedItself = Array.from(addedNodes).some(node => node === element); - return element.contains(targetElement); - } + if (elementAddedItself) { + return true; + } - /** - * Check new/removed nodes - */ - const addedNodesBelongsToBlock = Array.from(addedNodes).some(node => element.contains(node)); - const removedNodesBelongsToBlock = Array.from(removedNodes).some(node => element.contains(node)); + const elementRemovedItself = Array.from(removedNodes).some(node => node === element); + + if (elementRemovedItself) { + return true; + } + } - return addedNodesBelongsToBlock || removedNodesBelongsToBlock; + return false; } diff --git a/test/cypress/tests/onchange.cy.ts b/test/cypress/tests/onchange.cy.ts index d4c908354..ef26d1bd8 100644 --- a/test/cypress/tests/onchange.cy.ts +++ b/test/cypress/tests/onchange.cy.ts @@ -1,5 +1,6 @@ import Header from '@editorjs/header'; import Code from '@editorjs/code'; +import ToolMock from '../fixtures/tools/ToolMock'; import Delimiter from '@editorjs/delimiter'; import { BlockAddedMutationType } from '../../../types/events/block/BlockAdded'; import { BlockChangedMutationType } from '../../../types/events/block/BlockChanged'; @@ -787,4 +788,61 @@ describe('onChange callback', () => { })); }); }); + + it('should be fired when the whole text inside some descendant of the block is removed', () => { + /** + * Mock of Tool with nested contenteditable element + */ + class ToolWithContentEditableDescendant extends ToolMock { + /** + * Creates element with nested contenteditable element + */ + public render(): HTMLElement { + const contenteditable = document.createElement('div'); + + contenteditable.contentEditable = 'true'; + contenteditable.innerText = 'a'; + contenteditable.setAttribute('data-cy', 'nested-contenteditable'); + + const wrapper = document.createElement('div'); + + wrapper.appendChild(contenteditable); + + return wrapper; + } + } + + const config = { + tools: { + testTool: { + class: ToolWithContentEditableDescendant, + }, + }, + data: { + blocks: [ + { + type: 'testTool', + data: 'a', + }, + ], + }, + onChange: (): void => { + console.log('something changed'); + }, + }; + + cy.spy(config, 'onChange').as('onChange'); + cy.createEditor(config).as('editorInstance'); + + cy.get('[data-cy=nested-contenteditable]') + .click() + .clear(); + + cy.get('@onChange').should('be.calledWithMatch', EditorJSApiMock, Cypress.sinon.match({ + type: BlockChangedMutationType, + detail: { + index: 0, + }, + })); + }); }); From e9b4c30407d351d3772ff9f809ba02f3fd537422 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 28 Feb 2024 20:17:42 +0300 Subject: [PATCH 03/61] Bump version up to 2.30.0-rc.0 (#2640) * Bump version * Update package.json --------- Co-authored-by: github-actions Co-authored-by: Peter Savchenko --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d00b8af2a..6327cada1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@editorjs/editorjs", - "version": "2.29.1", + "version": "2.30.0-rc.0", "description": "Editor.js — Native JS, based on API and Open Source", "main": "dist/editorjs.umd.js", "module": "dist/editorjs.mjs", From ee6433201da69d22d503cb0afe0e5776803453c9 Mon Sep 17 00:00:00 2001 From: Peter Savchenko Date: Wed, 13 Mar 2024 17:30:16 +0300 Subject: [PATCH 04/61] fix(block-tunes): enter keydown problems (#2650) * debug enter press * fix sync set caret * fix enter keydown problems + tests addedd * Update search-input.ts * add changelog * add useful log to cypress custom comand * Update commands.ts --- .gitignore | 1 + docs/CHANGELOG.md | 3 + package.json | 2 +- src/components/block/index.ts | 4 + src/components/constants.ts | 5 + src/components/modules/crossBlockSelection.ts | 6 +- src/components/modules/ui.ts | 11 +- src/components/utils/popover/index.ts | 4 +- test/cypress/support/commands.ts | 27 +++++ test/cypress/support/index.d.ts | 8 ++ test/cypress/tests/ui/BlockTunes.cy.ts | 107 ++++++++++++++++++ 11 files changed, 170 insertions(+), 8 deletions(-) create mode 100644 src/components/constants.ts create mode 100644 test/cypress/tests/ui/BlockTunes.cy.ts diff --git a/.gitignore b/.gitignore index db93fc475..150d50708 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ dist/ coverage/ .nyc_output/ +.vscode/launch.json diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 6d63e3c4b..a1b00479d 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -3,6 +3,9 @@ ### 2.30.0 - `Fix` — `onChange` will be called when removing the entire text within a descendant element of a block. +- `Fix` - Unexpected new line on Enter press with selected block without caret +- `Fix` - Search input autofocus loosing after Block Tunes opening +- `Fix` - Block removing while Enter press on Block Tunes ### 2.29.1 diff --git a/package.json b/package.json index 6327cada1..3bede1182 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@editorjs/editorjs", - "version": "2.30.0-rc.0", + "version": "2.30.0-rc.1", "description": "Editor.js — Native JS, based on API and Open Source", "main": "dist/editorjs.umd.js", "module": "dist/editorjs.mjs", diff --git a/src/components/block/index.ts b/src/components/block/index.ts index 493f9657c..25e898f04 100644 --- a/src/components/block/index.ts +++ b/src/components/block/index.ts @@ -738,6 +738,10 @@ export default class Block extends EventsDispatcher { contentNode = $.make('div', Block.CSS.content), pluginsContent = this.toolInstance.render(); + if (import.meta.env.MODE === 'test') { + wrapper.setAttribute('data-cy', 'block-wrapper'); + } + /** * Export id to the DOM three * Useful for standalone modules development. For example, allows to identify Block by some child node. Or scroll to a particular Block by id. diff --git a/src/components/constants.ts b/src/components/constants.ts new file mode 100644 index 000000000..0fe2aac08 --- /dev/null +++ b/src/components/constants.ts @@ -0,0 +1,5 @@ +/** + * Debounce timeout for selection change event + * {@link modules/ui.ts} + */ +export const selectionChangeDebounceTimeout = 180; diff --git a/src/components/modules/crossBlockSelection.ts b/src/components/modules/crossBlockSelection.ts index 5807dc0a9..bcebfa4f3 100644 --- a/src/components/modules/crossBlockSelection.ts +++ b/src/components/modules/crossBlockSelection.ts @@ -48,11 +48,11 @@ export default class CrossBlockSelection extends Module { } /** - * return boolean is cross block selection started + * Return boolean is cross block selection started: + * there should be at least 2 selected blocks */ public get isCrossBlockSelectionStarted(): boolean { - return !!this.firstSelectedBlock && - !!this.lastSelectedBlock; + return !!this.firstSelectedBlock && !!this.lastSelectedBlock && this.firstSelectedBlock !== this.lastSelectedBlock; } /** diff --git a/src/components/modules/ui.ts b/src/components/modules/ui.ts index e92883631..70e6f2db7 100644 --- a/src/components/modules/ui.ts +++ b/src/components/modules/ui.ts @@ -15,6 +15,7 @@ import { mobileScreenBreakpoint } from '../utils'; import styles from '../../styles/main.css?inline'; import { BlockHovered } from '../events/BlockHovered'; +import { selectionChangeDebounceTimeout } from '../constants'; /** * HTML Elements used for UI */ @@ -350,7 +351,6 @@ export default class UI extends Module { /** * Handle selection change to manipulate Inline Toolbar appearance */ - const selectionChangeDebounceTimeout = 180; const selectionChangeDebounced = _.debounce(() => { this.selectionChanged(); }, selectionChangeDebounceTimeout); @@ -556,6 +556,11 @@ export default class UI extends Module { */ private enterPressed(event: KeyboardEvent): void { const { BlockManager, BlockSelection } = this.Editor; + + if (this.someToolbarOpened) { + return; + } + const hasPointerToBlock = BlockManager.currentBlockIndex >= 0; /** @@ -591,6 +596,10 @@ export default class UI extends Module { */ const newBlock = this.Editor.BlockManager.insert(); + /** + * Prevent default enter behaviour to prevent adding a new line (

) to the inserted block + */ + event.preventDefault(); this.Editor.Caret.setToBlock(newBlock); /** diff --git a/src/components/utils/popover/index.ts b/src/components/utils/popover/index.ts index e305afd97..34b483b3d 100644 --- a/src/components/utils/popover/index.ts +++ b/src/components/utils/popover/index.ts @@ -237,9 +237,7 @@ export default class Popover extends EventsDispatcher { this.flipper.activate(this.flippableElements); if (this.search !== undefined) { - requestAnimationFrame(() => { - this.search?.focus(); - }); + this.search?.focus(); } if (isMobileScreen()) { diff --git a/test/cypress/support/commands.ts b/test/cypress/support/commands.ts index d680cba2a..09a52db81 100644 --- a/test/cypress/support/commands.ts +++ b/test/cypress/support/commands.ts @@ -234,3 +234,30 @@ Cypress.Commands.add('getLineWrapPositions', { return cy.wrap(lineWraps); }); + +/** + * Dispatches keydown event on subject + * Uses the correct KeyboardEvent object to make it work with our code (see below) + */ +Cypress.Commands.add('keydown', { + prevSubject: true, +}, (subject, keyCode: number) => { + cy.log('Dispatching KeyboardEvent with keyCode: ' + keyCode); + /** + * We use the "reason instanceof KeyboardEvent" statement in blockSelection.ts + * but by default cypress' KeyboardEvent is not an instance of the native KeyboardEvent, + * so real-world and Cypress behaviour were different. + * + * To make it work we need to trigger Cypress event with "eventConstructor: 'KeyboardEvent'", + * + * @see https://github.com/cypress-io/cypress/issues/5650 + * @see https://github.com/cypress-io/cypress/pull/8305/files + */ + subject.trigger('keydown', { + eventConstructor: 'KeyboardEvent', + keyCode, + bubbles: false, + }); + + return cy.wrap(subject); +}); diff --git a/test/cypress/support/index.d.ts b/test/cypress/support/index.d.ts index 210895d20..89468b813 100644 --- a/test/cypress/support/index.d.ts +++ b/test/cypress/support/index.d.ts @@ -85,6 +85,14 @@ declare global { * @returns number[] - array of line wrap positions */ getLineWrapPositions(): Chainable; + + /** + * Dispatches keydown event on subject + * Uses the correct KeyboardEvent object to make it work with our code (see below) + * + * @param keyCode - key code to dispatch + */ + keydown(keyCode: number): Chainable; } interface ApplicationWindow { diff --git a/test/cypress/tests/ui/BlockTunes.cy.ts b/test/cypress/tests/ui/BlockTunes.cy.ts new file mode 100644 index 000000000..f652d2c71 --- /dev/null +++ b/test/cypress/tests/ui/BlockTunes.cy.ts @@ -0,0 +1,107 @@ +import { selectionChangeDebounceTimeout } from '../../../../src/components/constants'; + +describe('BlockTunes', function () { + describe('Search', () => { + it('should be focused after popover opened', () => { + cy.createEditor({ + data: { + blocks: [ + { + type: 'paragraph', + data: { + text: 'Some text', + }, + }, + ], + }, + }); + + cy.get('[data-cy=editorjs]') + .find('.ce-paragraph') + .click() + .type('{cmd}/') + .wait(selectionChangeDebounceTimeout); + + /** + * Caret is set to the search input + */ + cy.window() + .then((window) => { + const selection = window.getSelection(); + + expect(selection.rangeCount).to.be.equal(1); + + const range = selection.getRangeAt(0); + + cy.get('[data-cy=editorjs]') + .find('[data-cy="block-tunes"] .cdx-search-field') + .should(($block) => { + expect($block[0].contains(range.startContainer)).to.be.true; + }); + }); + }); + }); + + describe('Keyboard only', function () { + it('should not delete the currently selected block when Enter pressed on a search input (or any block tune)', function () { + const ENTER_KEY_CODE = 13; + + cy.createEditor({ + data: { + blocks: [ + { + type: 'paragraph', + data: { + text: 'Some text', + }, + }, + ], + }, + }); + + cy.get('[data-cy=editorjs]') + .find('.ce-paragraph') + .click() + .type('{cmd}/') + .wait(selectionChangeDebounceTimeout) + .keydown(ENTER_KEY_CODE); + + /** + * Block should have same text + */ + cy.get('[data-cy="block-wrapper"') + .should('have.text', 'Some text'); + }); + + it('should not unselect currently selected block when Enter pressed on a block tune', function () { + const ENTER_KEY_CODE = 13; + + cy.createEditor({ + data: { + blocks: [ + { + type: 'paragraph', + data: { + text: 'Some text', + }, + }, + ], + }, + }); + + cy.get('[data-cy=editorjs]') + .find('.ce-paragraph') + .click() + .type('{cmd}/') + .wait(selectionChangeDebounceTimeout) + .keydown(ENTER_KEY_CODE); + + /** + * Block should not be selected + */ + cy.get('[data-cy="block-wrapper"') + .first() + .should('have.class', 'ce-block--selected'); + }); + }); +}); From 9b3e9615b03cab7333a9769a2c2e8f488536a809 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 13 Mar 2024 17:36:26 +0300 Subject: [PATCH 05/61] build(deps-dev): bump vite from 4.2.1 to 4.5.2 (#2651) Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 4.2.1 to 4.5.2. - [Release notes](https://github.com/vitejs/vite/releases) - [Changelog](https://github.com/vitejs/vite/blob/v4.5.2/packages/vite/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite/commits/v4.5.2/packages/vite) --- updated-dependencies: - dependency-name: vite dependency-type: direct:development ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Peter Savchenko --- yarn.lock | 309 +++++++++++++++++++++++++++--------------------------- 1 file changed, 154 insertions(+), 155 deletions(-) diff --git a/yarn.lock b/yarn.lock index f53c136f9..0a3caccc0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -594,115 +594,115 @@ esquery "^1.4.0" jsdoc-type-pratt-parser "~3.1.0" -"@esbuild/android-arm64@0.17.15": - version "0.17.15" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.17.15.tgz#893ad71f3920ccb919e1757c387756a9bca2ef42" - integrity sha512-0kOB6Y7Br3KDVgHeg8PRcvfLkq+AccreK///B4Z6fNZGr/tNHX0z2VywCc7PTeWp+bPvjA5WMvNXltHw5QjAIA== - -"@esbuild/android-arm@0.17.15": - version "0.17.15" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.17.15.tgz#143e0d4e4c08c786ea410b9a7739779a9a1315d8" - integrity sha512-sRSOVlLawAktpMvDyJIkdLI/c/kdRTOqo8t6ImVxg8yT7LQDUYV5Rp2FKeEosLr6ZCja9UjYAzyRSxGteSJPYg== - -"@esbuild/android-x64@0.17.15": - version "0.17.15" - resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.17.15.tgz#d2d12a7676b2589864281b2274355200916540bc" - integrity sha512-MzDqnNajQZ63YkaUWVl9uuhcWyEyh69HGpMIrf+acR4otMkfLJ4sUCxqwbCyPGicE9dVlrysI3lMcDBjGiBBcQ== - -"@esbuild/darwin-arm64@0.17.15": - version "0.17.15" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.17.15.tgz#2e88e79f1d327a2a7d9d06397e5232eb0a473d61" - integrity sha512-7siLjBc88Z4+6qkMDxPT2juf2e8SJxmsbNVKFY2ifWCDT72v5YJz9arlvBw5oB4W/e61H1+HDB/jnu8nNg0rLA== - -"@esbuild/darwin-x64@0.17.15": - version "0.17.15" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.17.15.tgz#9384e64c0be91388c57be6d3a5eaf1c32a99c91d" - integrity sha512-NbImBas2rXwYI52BOKTW342Tm3LTeVlaOQ4QPZ7XuWNKiO226DisFk/RyPk3T0CKZkKMuU69yOvlapJEmax7cg== - -"@esbuild/freebsd-arm64@0.17.15": - version "0.17.15" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.17.15.tgz#2ad5a35bc52ebd9ca6b845dbc59ba39647a93c1a" - integrity sha512-Xk9xMDjBVG6CfgoqlVczHAdJnCs0/oeFOspFap5NkYAmRCT2qTn1vJWA2f419iMtsHSLm+O8B6SLV/HlY5cYKg== - -"@esbuild/freebsd-x64@0.17.15": - version "0.17.15" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.17.15.tgz#b513a48446f96c75fda5bef470e64d342d4379cd" - integrity sha512-3TWAnnEOdclvb2pnfsTWtdwthPfOz7qAfcwDLcfZyGJwm1SRZIMOeB5FODVhnM93mFSPsHB9b/PmxNNbSnd0RQ== - -"@esbuild/linux-arm64@0.17.15": - version "0.17.15" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.17.15.tgz#9697b168175bfd41fa9cc4a72dd0d48f24715f31" - integrity sha512-T0MVnYw9KT6b83/SqyznTs/3Jg2ODWrZfNccg11XjDehIved2oQfrX/wVuev9N936BpMRaTR9I1J0tdGgUgpJA== - -"@esbuild/linux-arm@0.17.15": - version "0.17.15" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.17.15.tgz#5b22062c54f48cd92fab9ffd993732a52db70cd3" - integrity sha512-MLTgiXWEMAMr8nmS9Gigx43zPRmEfeBfGCwxFQEMgJ5MC53QKajaclW6XDPjwJvhbebv+RzK05TQjvH3/aM4Xw== - -"@esbuild/linux-ia32@0.17.15": - version "0.17.15" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.17.15.tgz#eb28a13f9b60b5189fcc9e98e1024f6b657ba54c" - integrity sha512-wp02sHs015T23zsQtU4Cj57WiteiuASHlD7rXjKUyAGYzlOKDAjqK6bk5dMi2QEl/KVOcsjwL36kD+WW7vJt8Q== - -"@esbuild/linux-loong64@0.17.15": - version "0.17.15" - resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.17.15.tgz#32454bdfe144cf74b77895a8ad21a15cb81cfbe5" - integrity sha512-k7FsUJjGGSxwnBmMh8d7IbObWu+sF/qbwc+xKZkBe/lTAF16RqxRCnNHA7QTd3oS2AfGBAnHlXL67shV5bBThQ== - -"@esbuild/linux-mips64el@0.17.15": - version "0.17.15" - resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.17.15.tgz#af12bde0d775a318fad90eb13a0455229a63987c" - integrity sha512-ZLWk6czDdog+Q9kE/Jfbilu24vEe/iW/Sj2d8EVsmiixQ1rM2RKH2n36qfxK4e8tVcaXkvuV3mU5zTZviE+NVQ== - -"@esbuild/linux-ppc64@0.17.15": - version "0.17.15" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.17.15.tgz#34c5ed145b2dfc493d3e652abac8bd3baa3865a5" - integrity sha512-mY6dPkIRAiFHRsGfOYZC8Q9rmr8vOBZBme0/j15zFUKM99d4ILY4WpOC7i/LqoY+RE7KaMaSfvY8CqjJtuO4xg== - -"@esbuild/linux-riscv64@0.17.15": - version "0.17.15" - resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.17.15.tgz#87bd515e837f2eb004b45f9e6a94dc5b93f22b92" - integrity sha512-EcyUtxffdDtWjjwIH8sKzpDRLcVtqANooMNASO59y+xmqqRYBBM7xVLQhqF7nksIbm2yHABptoioS9RAbVMWVA== - -"@esbuild/linux-s390x@0.17.15": - version "0.17.15" - resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.17.15.tgz#20bf7947197f199ddac2ec412029a414ceae3aa3" - integrity sha512-BuS6Jx/ezxFuHxgsfvz7T4g4YlVrmCmg7UAwboeyNNg0OzNzKsIZXpr3Sb/ZREDXWgt48RO4UQRDBxJN3B9Rbg== - -"@esbuild/linux-x64@0.17.15": - version "0.17.15" - resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.17.15.tgz#31b93f9c94c195e852c20cd3d1914a68aa619124" - integrity sha512-JsdS0EgEViwuKsw5tiJQo9UdQdUJYuB+Mf6HxtJSPN35vez1hlrNb1KajvKWF5Sa35j17+rW1ECEO9iNrIXbNg== - -"@esbuild/netbsd-x64@0.17.15": - version "0.17.15" - resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.17.15.tgz#8da299b3ac6875836ca8cdc1925826498069ac65" - integrity sha512-R6fKjtUysYGym6uXf6qyNephVUQAGtf3n2RCsOST/neIwPqRWcnc3ogcielOd6pT+J0RDR1RGcy0ZY7d3uHVLA== - -"@esbuild/openbsd-x64@0.17.15": - version "0.17.15" - resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.17.15.tgz#04a1ec3d4e919714dba68dcf09eeb1228ad0d20c" - integrity sha512-mVD4PGc26b8PI60QaPUltYKeSX0wxuy0AltC+WCTFwvKCq2+OgLP4+fFd+hZXzO2xW1HPKcytZBdjqL6FQFa7w== - -"@esbuild/sunos-x64@0.17.15": - version "0.17.15" - resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.17.15.tgz#6694ebe4e16e5cd7dab6505ff7c28f9c1c695ce5" - integrity sha512-U6tYPovOkw3459t2CBwGcFYfFRjivcJJc1WC8Q3funIwX8x4fP+R6xL/QuTPNGOblbq/EUDxj9GU+dWKX0oWlQ== - -"@esbuild/win32-arm64@0.17.15": - version "0.17.15" - resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.17.15.tgz#1f95b2564193c8d1fee8f8129a0609728171d500" - integrity sha512-W+Z5F++wgKAleDABemiyXVnzXgvRFs+GVKThSI+mGgleLWluv0D7Diz4oQpgdpNzh4i2nNDzQtWbjJiqutRp6Q== - -"@esbuild/win32-ia32@0.17.15": - version "0.17.15" - resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.17.15.tgz#c362b88b3df21916ed7bcf75c6d09c6bf3ae354a" - integrity sha512-Muz/+uGgheShKGqSVS1KsHtCyEzcdOn/W/Xbh6H91Etm+wiIfwZaBn1W58MeGtfI8WA961YMHFYTthBdQs4t+w== - -"@esbuild/win32-x64@0.17.15": - version "0.17.15" - resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.17.15.tgz#c2e737f3a201ebff8e2ac2b8e9f246b397ad19b8" - integrity sha512-DjDa9ywLUUmjhV2Y9wUTIF+1XsmuFGvZoCmOWkli1XcNAh5t25cc7fgsCx4Zi/Uurep3TTLyDiKATgGEg61pkA== +"@esbuild/android-arm64@0.18.20": + version "0.18.20" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz#984b4f9c8d0377443cc2dfcef266d02244593622" + integrity sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ== + +"@esbuild/android-arm@0.18.20": + version "0.18.20" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.18.20.tgz#fedb265bc3a589c84cc11f810804f234947c3682" + integrity sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw== + +"@esbuild/android-x64@0.18.20": + version "0.18.20" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.18.20.tgz#35cf419c4cfc8babe8893d296cd990e9e9f756f2" + integrity sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg== + +"@esbuild/darwin-arm64@0.18.20": + version "0.18.20" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz#08172cbeccf95fbc383399a7f39cfbddaeb0d7c1" + integrity sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA== + +"@esbuild/darwin-x64@0.18.20": + version "0.18.20" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz#d70d5790d8bf475556b67d0f8b7c5bdff053d85d" + integrity sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ== + +"@esbuild/freebsd-arm64@0.18.20": + version "0.18.20" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz#98755cd12707f93f210e2494d6a4b51b96977f54" + integrity sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw== + +"@esbuild/freebsd-x64@0.18.20": + version "0.18.20" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz#c1eb2bff03915f87c29cece4c1a7fa1f423b066e" + integrity sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ== + +"@esbuild/linux-arm64@0.18.20": + version "0.18.20" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz#bad4238bd8f4fc25b5a021280c770ab5fc3a02a0" + integrity sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA== + +"@esbuild/linux-arm@0.18.20": + version "0.18.20" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz#3e617c61f33508a27150ee417543c8ab5acc73b0" + integrity sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg== + +"@esbuild/linux-ia32@0.18.20": + version "0.18.20" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz#699391cccba9aee6019b7f9892eb99219f1570a7" + integrity sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA== + +"@esbuild/linux-loong64@0.18.20": + version "0.18.20" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz#e6fccb7aac178dd2ffb9860465ac89d7f23b977d" + integrity sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg== + +"@esbuild/linux-mips64el@0.18.20": + version "0.18.20" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz#eeff3a937de9c2310de30622a957ad1bd9183231" + integrity sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ== + +"@esbuild/linux-ppc64@0.18.20": + version "0.18.20" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz#2f7156bde20b01527993e6881435ad79ba9599fb" + integrity sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA== + +"@esbuild/linux-riscv64@0.18.20": + version "0.18.20" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz#6628389f210123d8b4743045af8caa7d4ddfc7a6" + integrity sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A== + +"@esbuild/linux-s390x@0.18.20": + version "0.18.20" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz#255e81fb289b101026131858ab99fba63dcf0071" + integrity sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ== + +"@esbuild/linux-x64@0.18.20": + version "0.18.20" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz#c7690b3417af318a9b6f96df3031a8865176d338" + integrity sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w== + +"@esbuild/netbsd-x64@0.18.20": + version "0.18.20" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz#30e8cd8a3dded63975e2df2438ca109601ebe0d1" + integrity sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A== + +"@esbuild/openbsd-x64@0.18.20": + version "0.18.20" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz#7812af31b205055874c8082ea9cf9ab0da6217ae" + integrity sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg== + +"@esbuild/sunos-x64@0.18.20": + version "0.18.20" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz#d5c275c3b4e73c9b0ecd38d1ca62c020f887ab9d" + integrity sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ== + +"@esbuild/win32-arm64@0.18.20": + version "0.18.20" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz#73bc7f5a9f8a77805f357fab97f290d0e4820ac9" + integrity sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg== + +"@esbuild/win32-ia32@0.18.20": + version "0.18.20" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz#ec93cbf0ef1085cc12e71e0d661d20569ff42102" + integrity sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g== + +"@esbuild/win32-x64@0.18.20": + version "0.18.20" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz#786c5f41f043b07afb1af37683d7c33668858f6d" + integrity sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ== "@eslint-community/eslint-utils@^4.2.0": version "4.4.0" @@ -1953,33 +1953,33 @@ es6-error@^4.0.1: resolved "https://registry.yarnpkg.com/es6-error/-/es6-error-4.1.1.tgz#9e3af407459deed47e9a91f9b885a84eb05c561d" integrity sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg== -esbuild@^0.17.5: - version "0.17.15" - resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.17.15.tgz#209ebc87cb671ffb79574db93494b10ffaf43cbc" - integrity sha512-LBUV2VsUIc/iD9ME75qhT4aJj0r75abCVS0jakhFzOtR7TQsqQA5w0tZ+KTKnwl3kXE0MhskNdHDh/I5aCR1Zw== +esbuild@^0.18.10: + version "0.18.20" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.18.20.tgz#4709f5a34801b43b799ab7d6d82f7284a9b7a7a6" + integrity sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA== optionalDependencies: - "@esbuild/android-arm" "0.17.15" - "@esbuild/android-arm64" "0.17.15" - "@esbuild/android-x64" "0.17.15" - "@esbuild/darwin-arm64" "0.17.15" - "@esbuild/darwin-x64" "0.17.15" - "@esbuild/freebsd-arm64" "0.17.15" - "@esbuild/freebsd-x64" "0.17.15" - "@esbuild/linux-arm" "0.17.15" - "@esbuild/linux-arm64" "0.17.15" - "@esbuild/linux-ia32" "0.17.15" - "@esbuild/linux-loong64" "0.17.15" - "@esbuild/linux-mips64el" "0.17.15" - "@esbuild/linux-ppc64" "0.17.15" - "@esbuild/linux-riscv64" "0.17.15" - "@esbuild/linux-s390x" "0.17.15" - "@esbuild/linux-x64" "0.17.15" - "@esbuild/netbsd-x64" "0.17.15" - "@esbuild/openbsd-x64" "0.17.15" - "@esbuild/sunos-x64" "0.17.15" - "@esbuild/win32-arm64" "0.17.15" - "@esbuild/win32-ia32" "0.17.15" - "@esbuild/win32-x64" "0.17.15" + "@esbuild/android-arm" "0.18.20" + "@esbuild/android-arm64" "0.18.20" + "@esbuild/android-x64" "0.18.20" + "@esbuild/darwin-arm64" "0.18.20" + "@esbuild/darwin-x64" "0.18.20" + "@esbuild/freebsd-arm64" "0.18.20" + "@esbuild/freebsd-x64" "0.18.20" + "@esbuild/linux-arm" "0.18.20" + "@esbuild/linux-arm64" "0.18.20" + "@esbuild/linux-ia32" "0.18.20" + "@esbuild/linux-loong64" "0.18.20" + "@esbuild/linux-mips64el" "0.18.20" + "@esbuild/linux-ppc64" "0.18.20" + "@esbuild/linux-riscv64" "0.18.20" + "@esbuild/linux-s390x" "0.18.20" + "@esbuild/linux-x64" "0.18.20" + "@esbuild/netbsd-x64" "0.18.20" + "@esbuild/openbsd-x64" "0.18.20" + "@esbuild/sunos-x64" "0.18.20" + "@esbuild/win32-arm64" "0.18.20" + "@esbuild/win32-ia32" "0.18.20" + "@esbuild/win32-x64" "0.18.20" escalade@^3.1.1: version "3.1.1" @@ -3593,10 +3593,10 @@ nanoid@3.3.3: resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.3.tgz#fd8e8b7aa761fe807dba2d1b98fb7241bb724a25" integrity sha512-p1sjXuopFs0xg+fPASzQ28agW1oHD7xDsd9Xkf3T15H3c/cifrFHVwrh74PdoklAPi+i7MdRsE47vm2r6JoB+w== -nanoid@^3.3.4: - version "3.3.4" - resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.4.tgz#730b67e3cd09e2deacf03c027c81c9d9dbc5e8ab" - integrity sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw== +nanoid@^3.3.7: + version "3.3.7" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.7.tgz#d0c301a691bc8d54efa0a2226ccf3fe2fd656bd8" + integrity sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g== nanoid@^4.0.2: version "4.0.2" @@ -4236,12 +4236,12 @@ postcss@^7.0.14: picocolors "^0.2.1" source-map "^0.6.1" -postcss@^8.4.21: - version "8.4.21" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.21.tgz#c639b719a57efc3187b13a1d765675485f4134f4" - integrity sha512-tP7u/Sn/dVxK2NnruI4H9BG+x+Wxz6oeZ1cJ8P6G/PZY0IKk4k/63TDsQf2kQq3+qoJeLm2kIBUNlZe3zgb4Zg== +postcss@^8.4.21, postcss@^8.4.27: + version "8.4.35" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.35.tgz#60997775689ce09011edf083a549cea44aabe2f7" + integrity sha512-u5U8qYpBCpN13BsiEB0CbR1Hhh4Gc0zLFuedrHJKMctHCHAGrMdG0PRM/KErzAL3CU6/eckEtmHNB3x6e3c0vA== dependencies: - nanoid "^3.3.4" + nanoid "^3.3.7" picocolors "^1.0.0" source-map-js "^1.0.2" @@ -4449,10 +4449,10 @@ rollup-plugin-license@^3.0.1: spdx-expression-validate "~2.0.0" spdx-satisfies "~5.0.1" -rollup@^3.18.0: - version "3.20.2" - resolved "https://registry.yarnpkg.com/rollup/-/rollup-3.20.2.tgz#f798c600317f216de2e4ad9f4d9ab30a89b690ff" - integrity sha512-3zwkBQl7Ai7MFYQE0y1MeQ15+9jsi7XxfrqwTb/9EK8D9C9+//EBR4M+CuA1KODRaNbFez/lWxA5vhEGZp4MUg== +rollup@^3.27.1: + version "3.29.4" + resolved "https://registry.yarnpkg.com/rollup/-/rollup-3.29.4.tgz#4d70c0f9834146df8705bfb69a9a19c9e1109981" + integrity sha512-oWzmBZwvYrU0iJHtDmhsm662rC15FRXmcjCk1xD771dFDx5jJ02ufAQQTn0etB2emNk4J9EZg/yWKpsn9BWGRw== optionalDependencies: fsevents "~2.3.2" @@ -5125,14 +5125,13 @@ vite-plugin-css-injected-by-js@^3.1.0: integrity sha512-qogCmpocZfcbSAYZQjS88ieIY0PzLUm7RkLFWFgAxkXdz3N6roZbSTNTxeIOj5IxFbZWACUPuVBBoo6qCuXDcw== vite@^4.2.1: - version "4.2.1" - resolved "https://registry.yarnpkg.com/vite/-/vite-4.2.1.tgz#6c2eb337b0dfd80a9ded5922163b94949d7fc254" - integrity sha512-7MKhqdy0ISo4wnvwtqZkjke6XN4taqQ2TBaTccLIpOKv7Vp2h4Y+NpmWCnGDeSvvn45KxvWgGyb0MkHvY1vgbg== + version "4.5.2" + resolved "https://registry.yarnpkg.com/vite/-/vite-4.5.2.tgz#d6ea8610e099851dad8c7371599969e0f8b97e82" + integrity sha512-tBCZBNSBbHQkaGyhGCDUGqeo2ph8Fstyp6FMSvTtsXeZSPpSMGlviAOav2hxVTqFcx8Hj/twtWKsMJXNY0xI8w== dependencies: - esbuild "^0.17.5" - postcss "^8.4.21" - resolve "^1.22.1" - rollup "^3.18.0" + esbuild "^0.18.10" + postcss "^8.4.27" + rollup "^3.27.1" optionalDependencies: fsevents "~2.3.2" From cc0d6de04be5513951f87ce5b5f265ce94d2101d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 13 Mar 2024 17:47:18 +0300 Subject: [PATCH 06/61] build(deps): bump semver from 5.7.1 to 5.7.2 (#2411) Bumps [semver](https://github.com/npm/node-semver) from 5.7.1 to 5.7.2. - [Release notes](https://github.com/npm/node-semver/releases) - [Changelog](https://github.com/npm/node-semver/blob/v5.7.2/CHANGELOG.md) - [Commits](https://github.com/npm/node-semver/compare/v5.7.1...v5.7.2) --- updated-dependencies: - dependency-name: semver dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Peter Savchenko --- yarn.lock | 29 +++++++++++------------------ 1 file changed, 11 insertions(+), 18 deletions(-) diff --git a/yarn.lock b/yarn.lock index 0a3caccc0..cc5eb2f56 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4499,26 +4499,19 @@ safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0: integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== "semver@2 || 3 || 4 || 5", semver@^5.3.0, semver@^5.6.0: - version "5.7.1" - resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7" - integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ== + version "5.7.2" + resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.2.tgz#48d55db737c3287cd4835e17fa13feace1c41ef8" + integrity sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g== semver@^6.0.0, semver@^6.3.0: - version "6.3.0" - resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d" - integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw== - -semver@^7.0.0, semver@^7.3.2, semver@^7.3.4, semver@^7.3.7, semver@^7.3.8: - version "7.3.8" - resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.8.tgz#07a78feafb3f7b32347d725e33de7e2a2df67798" - integrity sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A== - dependencies: - lru-cache "^6.0.0" - -semver@^7.3.5: - version "7.5.4" - resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.4.tgz#483986ec4ed38e1c6c48c34894a9182dbff68a6e" - integrity sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA== + version "6.3.1" + resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" + integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== + +semver@^7.0.0, semver@^7.3.2, semver@^7.3.4, semver@^7.3.5, semver@^7.3.7, semver@^7.3.8: + version "7.6.0" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.0.tgz#1a46a4db4bffcccd97b743b5005c8325f23d4e2d" + integrity sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg== dependencies: lru-cache "^6.0.0" From bb2047c60fe72a62206f7bebb9628d08fa94a8bd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 13 Mar 2024 17:53:00 +0300 Subject: [PATCH 07/61] build(deps): bump word-wrap from 1.2.3 to 1.2.5 (#2433) Bumps [word-wrap](https://github.com/jonschlinkert/word-wrap) from 1.2.3 to 1.2.5. - [Release notes](https://github.com/jonschlinkert/word-wrap/releases) - [Commits](https://github.com/jonschlinkert/word-wrap/compare/1.2.3...1.2.5) --- updated-dependencies: - dependency-name: word-wrap dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Peter Savchenko --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index cc5eb2f56..dd9c6c0af 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5176,9 +5176,9 @@ which@^2.0.1: isexe "^2.0.0" word-wrap@^1.2.3: - version "1.2.3" - resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c" - integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ== + version "1.2.5" + resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34" + integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== workerpool@6.2.1: version "6.2.1" From b355f1673c11ec3c0603303aefaa6a546cf3a377 Mon Sep 17 00:00:00 2001 From: Alex Yang Date: Wed, 13 Mar 2024 22:57:52 +0800 Subject: [PATCH 08/61] fix: strict css type (#2573) Co-authored-by: Peter Savchenko Co-authored-by: Ilya Maroz <37909603+ilyamore88@users.noreply.github.com> --- src/components/ui/toolbox.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/ui/toolbox.ts b/src/components/ui/toolbox.ts index 318275c3e..3984e36ce 100644 --- a/src/components/ui/toolbox.ts +++ b/src/components/ui/toolbox.ts @@ -106,10 +106,10 @@ export default class Toolbox extends EventsDispatcher { /** * CSS styles - * - * @returns {Object} */ - private static get CSS(): { [name: string]: string } { + private static get CSS(): { + toolbox: string; + } { return { toolbox: 'ce-toolbox', }; From 1320b047a20afe7ab6cf0e63b4125616ec6dc06b Mon Sep 17 00:00:00 2001 From: Peter Savchenko Date: Mon, 1 Apr 2024 12:29:47 +0300 Subject: [PATCH 09/61] feat(merge): blocks of different types can be merged (#2671) * feature: possibilities to merge blocks of different types * fix: remove scope change * feat: use convert config instead of defined property * chore:: use built-in function for type check * fix: remove console.log * chore: remove styling added by mistakes * test: add testing for different blocks types merging * fix: remove unused import * fix: remove type argument * fix: use existing functions for data export * chore: update changelog * fix: re put await * fix: remove unnecessary check * fix: typo in test name * fix: re-add condition for merge * test: add caret position test * fix caret issues, add sanitize * make if-else statement more clear * upgrade cypress * Update cypress.yml * upd cypress to 13 * make sanitize test simpler * patch rc version --------- Co-authored-by: GuillaumeOnepilot Co-authored-by: Guillaume Leon <97881811+GuillaumeOnepilot@users.noreply.github.com> --- .github/workflows/cypress.yml | 6 +- docs/CHANGELOG.md | 1 + package.json | 6 +- src/components/block/index.ts | 2 +- src/components/modules/blockEvents.ts | 9 +- src/components/modules/blockManager.ts | 40 +- src/components/modules/caret.ts | 2 +- src/components/utils/blocks.ts | 38 +- test/cypress/fixtures/tools/SimpleHeader.ts | 90 +++++ .../tests/modules/BlockEvents/Backspace.cy.ts | 132 +++++++ test/cypress/tests/sanitisation.cy.ts | 43 ++- yarn.lock | 342 ++++++++++++------ 12 files changed, 571 insertions(+), 140 deletions(-) create mode 100644 test/cypress/fixtures/tools/SimpleHeader.ts diff --git a/.github/workflows/cypress.yml b/.github/workflows/cypress.yml index 3d9c50a1b..766075563 100644 --- a/.github/workflows/cypress.yml +++ b/.github/workflows/cypress.yml @@ -12,9 +12,9 @@ jobs: steps: - uses: actions/setup-node@v3 with: - node-version: 16 - - uses: actions/checkout@v3 - - uses: cypress-io/github-action@v5 + node-version: 18 + - uses: actions/checkout@v4 + - uses: cypress-io/github-action@v6 with: config: video=false browser: ${{ matrix.browser }} diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index a1b00479d..c98cd547c 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,7 @@ ### 2.30.0 +- `Improvement` — The ability to merge blocks of different types (if both tools provide the conversionConfig) - `Fix` — `onChange` will be called when removing the entire text within a descendant element of a block. - `Fix` - Unexpected new line on Enter press with selected block without caret - `Fix` - Search input autofocus loosing after Block Tunes opening diff --git a/package.json b/package.json index 3bede1182..16a857fab 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@editorjs/editorjs", - "version": "2.30.0-rc.1", + "version": "2.30.0-rc.2", "description": "Editor.js — Native JS, based on API and Open Source", "main": "dist/editorjs.umd.js", "module": "dist/editorjs.mjs", @@ -45,14 +45,14 @@ "@editorjs/code": "^2.7.0", "@editorjs/delimiter": "^1.2.0", "@editorjs/header": "^2.7.0", - "@editorjs/paragraph": "^2.11.3", + "@editorjs/paragraph": "^2.11.4", "@editorjs/simple-image": "^1.4.1", "@types/node": "^18.15.11", "chai-subset": "^1.6.0", "codex-notifier": "^1.1.2", "codex-tooltip": "^1.0.5", "core-js": "3.30.0", - "cypress": "^12.9.0", + "cypress": "^13.7.1", "cypress-intellij-reporter": "^0.0.7", "cypress-plugin-tab": "^1.0.5", "cypress-terminal-report": "^5.3.2", diff --git a/src/components/block/index.ts b/src/components/block/index.ts index 25e898f04..a9977f2a6 100644 --- a/src/components/block/index.ts +++ b/src/components/block/index.ts @@ -550,7 +550,7 @@ export default class Block extends EventsDispatcher { * * @returns {object} */ - public async save(): Promise { + public async save(): Promise { const extractedBlock = await this.toolInstance.save(this.pluginsContent as HTMLElement); const tunesData: { [name: string]: BlockTuneData } = this.unavailableTunesData; diff --git a/src/components/modules/blockEvents.ts b/src/components/modules/blockEvents.ts index e6425789e..88b55ccb6 100644 --- a/src/components/modules/blockEvents.ts +++ b/src/components/modules/blockEvents.ts @@ -493,12 +493,9 @@ export default class BlockEvents extends Module { BlockManager .mergeBlocks(targetBlock, blockToMerge) .then(() => { - window.requestAnimationFrame(() => { - /** Restore caret position after merge */ - Caret.restoreCaret(targetBlock.pluginsContent as HTMLElement); - targetBlock.pluginsContent.normalize(); - Toolbar.close(); - }); + /** Restore caret position after merge */ + Caret.restoreCaret(targetBlock.pluginsContent as HTMLElement); + Toolbar.close(); }); } diff --git a/src/components/modules/blockManager.ts b/src/components/modules/blockManager.ts index ae8e4818c..bfc9236be 100644 --- a/src/components/modules/blockManager.ts +++ b/src/components/modules/blockManager.ts @@ -18,8 +18,8 @@ import { BlockAddedMutationType } from '../../../types/events/block/BlockAdded'; import { BlockMovedMutationType } from '../../../types/events/block/BlockMoved'; import { BlockChangedMutationType } from '../../../types/events/block/BlockChanged'; import { BlockChanged } from '../events'; -import { clean } from '../utils/sanitizer'; -import { convertStringToBlockData } from '../utils/blocks'; +import { clean, sanitizeBlocks } from '../utils/sanitizer'; +import { convertStringToBlockData, isBlockConvertable } from '../utils/blocks'; import PromiseQueue from '../utils/promise-queue'; /** @@ -69,7 +69,7 @@ export default class BlockManager extends Module { * * @returns {Block} */ - public get currentBlock(): Block { + public get currentBlock(): Block | undefined { return this._blocks[this.currentBlockIndex]; } @@ -471,12 +471,40 @@ export default class BlockManager extends Module { * @returns {Promise} - the sequence that can be continued */ public async mergeBlocks(targetBlock: Block, blockToMerge: Block): Promise { - const blockToMergeData = await blockToMerge.data; + let blockToMergeData: BlockToolData | undefined; - if (!_.isEmpty(blockToMergeData)) { - await targetBlock.mergeWith(blockToMergeData); + /** + * We can merge: + * 1) Blocks with the same Tool if tool provides merge method + */ + if (targetBlock.name === blockToMerge.name && targetBlock.mergeable) { + const blockToMergeDataRaw = await blockToMerge.data; + + if (_.isEmpty(blockToMergeDataRaw)) { + console.error('Could not merge Block. Failed to extract original Block data.'); + + return; + } + + const [ cleanData ] = sanitizeBlocks([ blockToMergeDataRaw ], targetBlock.tool.sanitizeConfig); + + blockToMergeData = cleanData; + + /** + * 2) Blocks with different Tools if they provides conversionConfig + */ + } else if (targetBlock.mergeable && isBlockConvertable(blockToMerge, 'export') && isBlockConvertable(targetBlock, 'import')) { + const blockToMergeDataStringified = await blockToMerge.exportDataAsString(); + const cleanData = clean(blockToMergeDataStringified, targetBlock.tool.sanitizeConfig); + + blockToMergeData = convertStringToBlockData(cleanData, targetBlock.tool.conversionConfig); + } + + if (blockToMergeData === undefined) { + return; } + await targetBlock.mergeWith(blockToMergeData); this.removeBlock(blockToMerge); this.currentBlockIndex = this._blocks.indexOf(targetBlock); } diff --git a/src/components/modules/caret.ts b/src/components/modules/caret.ts index 425c8d5e0..57d5c964d 100644 --- a/src/components/modules/caret.ts +++ b/src/components/modules/caret.ts @@ -50,7 +50,7 @@ export default class Caret extends Module { /** * If Block does not contain inputs, treat caret as "at start" */ - if (!currentBlock.focusable) { + if (!currentBlock?.focusable) { return true; } diff --git a/src/components/utils/blocks.ts b/src/components/utils/blocks.ts index 92a802eef..288e0057e 100644 --- a/src/components/utils/blocks.ts +++ b/src/components/utils/blocks.ts @@ -3,18 +3,54 @@ import type { BlockToolData } from '../../../types/tools/block-tool-data'; import type Block from '../block'; import { isFunction, isString, log } from '../utils'; +/** + * Check if block has valid conversion config for export or import. + * + * @param block - block to check + * @param direction - export for block to merge from, import for block to merge to + */ +export function isBlockConvertable(block: Block, direction: 'export' | 'import'): boolean { + if (!block.tool.conversionConfig) { + return false; + } + + const conversionProp = block.tool.conversionConfig[direction]; + + return isFunction(conversionProp) || isString(conversionProp); +} + /** * Check if two blocks could be merged. * * We can merge two blocks if: * - they have the same type * - they have a merge function (.mergeable = true) + * - If they have valid conversions config * * @param targetBlock - block to merge to * @param blockToMerge - block to merge from */ export function areBlocksMergeable(targetBlock: Block, blockToMerge: Block): boolean { - return targetBlock.mergeable && targetBlock.name === blockToMerge.name; + /** + * If target block has not 'merge' method, we can't merge blocks. + * + * Technically we can (through the conversion) but it will lead a target block delete and recreation, which is unexpected behavior. + */ + if (!targetBlock.mergeable) { + return false; + } + + /** + * Tool knows how to merge own data format + */ + if (targetBlock.name === blockToMerge.name) { + return true; + } + + /** + * We can merge blocks if they have valid conversion config + */ + return isBlockConvertable(blockToMerge, 'export') && isBlockConvertable(targetBlock, 'import'); } /** diff --git a/test/cypress/fixtures/tools/SimpleHeader.ts b/test/cypress/fixtures/tools/SimpleHeader.ts new file mode 100644 index 000000000..17cb89a9f --- /dev/null +++ b/test/cypress/fixtures/tools/SimpleHeader.ts @@ -0,0 +1,90 @@ +import { + BaseTool, + BlockToolConstructorOptions, + BlockToolData, + ConversionConfig +} from '../../../../types'; + +/** + * Simplified Header for testing + */ +export class SimpleHeader implements BaseTool { + private _data: BlockToolData; + private element: HTMLHeadingElement; + + /** + * + * @param options - constructor options + */ + constructor({ data }: BlockToolConstructorOptions) { + this._data = data; + } + + /** + * Return Tool's view + * + * @returns {HTMLHeadingElement} + * @public + */ + public render(): HTMLHeadingElement { + this.element = document.createElement('h1'); + + this.element.contentEditable = 'true'; + this.element.innerHTML = this._data.text; + + return this.element; + } + + /** + * @param data - saved data to merger with current block + */ + public merge(data: BlockToolData): void { + this.data = { + text: this.data.text + data.text, + level: this.data.level, + }; + } + + /** + * Extract Tool's data from the view + * + * @param toolsContent - Text tools rendered view + */ + public save(toolsContent: HTMLHeadingElement): BlockToolData { + return { + text: toolsContent.innerHTML, + level: 1, + }; + } + + /** + * Allow Header to be converted to/from other blocks + */ + public static get conversionConfig(): ConversionConfig { + return { + export: 'text', // use 'text' property for other blocks + import: 'text', // fill 'text' property from other block's export string + }; + } + + /** + * Data getter + */ + private get data(): BlockToolData { + this._data.text = this.element.innerHTML; + this._data.level = 1; + + return this._data; + } + + /** + * Data setter + */ + private set data(data: BlockToolData) { + this._data = data; + + if (data.text !== undefined) { + this.element.innerHTML = this._data.text || ''; + } + } +} diff --git a/test/cypress/tests/modules/BlockEvents/Backspace.cy.ts b/test/cypress/tests/modules/BlockEvents/Backspace.cy.ts index ac988a0b0..d9c8be04a 100644 --- a/test/cypress/tests/modules/BlockEvents/Backspace.cy.ts +++ b/test/cypress/tests/modules/BlockEvents/Backspace.cy.ts @@ -1,5 +1,6 @@ import type EditorJS from '../../../../../types/index'; import Chainable = Cypress.Chainable; +import { SimpleHeader } from '../../../fixtures/tools/SimpleHeader'; /** @@ -293,6 +294,137 @@ describe('Backspace keydown', function () { .should('not.have.class', 'ce-toolbar--opened'); }); + it('should merge blocks of different types (Paragraph -> Header) if they have a valid conversion config. Also, should close the Toolbox. Caret should be places in a place of glue', function () { + cy.createEditor({ + tools: { + header: SimpleHeader, + }, + data: { + blocks: [ + { + id: 'block1', + type: 'header', + data: { + text: 'First block heading', + }, + }, + { + id: 'block2', + type: 'paragraph', + data: { + text: 'Second block paragraph', + }, + }, + ], + }, + }).as('editorInstance'); + + cy.get('[data-cy=editorjs]') + .find('.ce-paragraph') + .last() + .click() + .type('{home}') // move caret to the beginning + .type('{backspace}'); + + cy.get('@editorInstance') + .then(async (editor) => { + const { blocks } = await editor.save(); + + expect(blocks.length).to.eq(1); // one block has been removed + expect(blocks[0].id).to.eq('block1'); // second block is still here + expect(blocks[0].data.text).to.eq('First block headingSecond block paragraph'); // text has been merged + }); + + /** + * Caret is set to the place of merging + */ + cy.window() + .then((window) => { + const selection = window.getSelection(); + const range = selection.getRangeAt(0); + + cy.get('[data-cy=editorjs]') + .find('[data-cy=block-wrapper]') + .should(($block) => { + expect($block[0].contains(range.startContainer)).to.be.true; + range.startContainer.normalize(); // glue merged text nodes + expect(range.startOffset).to.be.eq('First block heading'.length); + }); + }); + + /** + * Toolbox has been closed + */ + cy.get('[data-cy=editorjs]') + .find('.ce-toolbar') + .should('not.have.class', 'ce-toolbar--opened'); + }); + + it('should merge blocks of different types (Header -> Paragraph) if they have a valid conversion config. Also, should close the Toolbox. Caret should be places in a place of glue', function () { + cy.createEditor({ + tools: { + header: SimpleHeader, + }, + data: { + blocks: [ + { + id: 'block1', + type: 'paragraph', + data: { + text: 'First block paragraph', + }, + }, + { + id: 'block2', + type: 'header', + data: { + text: 'Second block heading', + }, + }, + ], + }, + }).as('editorInstance'); + + cy.get('[data-cy=editorjs]') + .find('[data-cy="block-wrapper"][data-id="block2"]') + .click() + .type('{home}') // move caret to the beginning + .type('{backspace}'); + + cy.get('@editorInstance') + .then(async (editor) => { + const { blocks } = await editor.save(); + + expect(blocks.length).to.eq(1); // one block has been removed + expect(blocks[0].id).to.eq('block1'); // second block is still here + expect(blocks[0].data.text).to.eq('First block paragraphSecond block heading'); // text has been merged + }); + + /** + * Caret is set to the place of merging + */ + cy.window() + .then((window) => { + const selection = window.getSelection(); + const range = selection.getRangeAt(0); + + cy.get('[data-cy=editorjs]') + .find('[data-cy=block-wrapper]') + .should(($block) => { + expect($block[0].contains(range.startContainer)).to.be.true; + range.startContainer.normalize(); // glue merged text nodes + expect(range.startOffset).to.be.eq('First block paragraph'.length); + }); + }); + + /** + * Toolbox has been closed + */ + cy.get('[data-cy=editorjs]') + .find('.ce-toolbar') + .should('not.have.class', 'ce-toolbar--opened'); + }); + it('should simply set Caret to the end of the previous Block if Caret at the start of the Block but Blocks are not mergeable. Also, should close the Toolbox.', function () { /** * Mock of tool without merge method diff --git a/test/cypress/tests/sanitisation.cy.ts b/test/cypress/tests/sanitisation.cy.ts index 62610444e..1c5a83ef3 100644 --- a/test/cypress/tests/sanitisation.cy.ts +++ b/test/cypress/tests/sanitisation.cy.ts @@ -3,7 +3,7 @@ import { OutputData } from '../../../types/index'; /* eslint-disable @typescript-eslint/no-explicit-any */ -describe('Output sanitization', () => { +describe('Sanitizing', () => { context('Output should save inline formatting', () => { it('should save initial formatting for paragraph', () => { cy.createEditor({ @@ -74,4 +74,45 @@ describe('Output sanitization', () => { }); }); }); + + it('should sanitize unwanted html on blocks merging', function () { + cy.createEditor({ + data: { + blocks: [ + { + id: 'block1', + type: 'paragraph', + data: { + text: 'First block', + }, + }, + { + id: 'paragraph', + type: 'paragraph', + data: { + /** + * Tool does not support spans in its sanitization config + */ + text: 'Second XSS block', + }, + }, + ], + }, + }).as('editorInstance'); + + cy.get('[data-cy=editorjs]') + .find('.ce-paragraph') + .last() + .click() + .type('{home}') + .type('{backspace}'); + + cy.get('@editorInstance') + .then(async (editor) => { + const { blocks } = await editor.save(); + + expect(blocks[0].data.text).to.eq('First blockSecond XSS block'); // text has been merged, span has been removed + }); + }); }); + diff --git a/yarn.lock b/yarn.lock index dd9c6c0af..00de3f0fb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -504,10 +504,10 @@ js-yaml "4.1.0" nyc "15.1.0" -"@cypress/request@^2.88.10": - version "2.88.11" - resolved "https://registry.yarnpkg.com/@cypress/request/-/request-2.88.11.tgz#5a4c7399bc2d7e7ed56e92ce5acb620c8b187047" - integrity sha512-M83/wfQ1EkspjkE2lNWNV5ui2Cv7UCv1swW1DqljahbzLVWltcsexQh8jYtuS/vzFXP+HySntGM83ZXA9fn17w== +"@cypress/request@^3.0.0": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@cypress/request/-/request-3.0.1.tgz#72d7d5425236a2413bd3d8bb66d02d9dc3168960" + integrity sha512-TWivJlJi8ZDx2wGOw1dbLuHJKUYX7bWySw377nlnGOW3hP9/MUKIsEdXT/YngWxVdgNCHRBmFlBipE+5/2ZZlQ== dependencies: aws-sign2 "~0.7.0" aws4 "^1.8.0" @@ -522,9 +522,9 @@ json-stringify-safe "~5.0.1" mime-types "~2.1.19" performance-now "^2.1.0" - qs "~6.10.3" + qs "6.10.4" safe-buffer "^5.1.2" - tough-cookie "~2.5.0" + tough-cookie "^4.1.3" tunnel-agent "^0.6.0" uuid "^8.3.2" @@ -571,10 +571,10 @@ dependencies: "@codexteam/icons" "^0.0.5" -"@editorjs/paragraph@^2.11.3": - version "2.11.3" - resolved "https://registry.yarnpkg.com/@editorjs/paragraph/-/paragraph-2.11.3.tgz#fb438de863179739f18de7d8851671a0d8447923" - integrity sha512-ON72lhvhgWzPrq4VXpHUeut9bsFeJgVATDeL850FVToOwYHKvdsNpfu0VgxEodhkXgzU/IGl4FzdqC2wd3AJUQ== +"@editorjs/paragraph@^2.11.4": + version "2.11.4" + resolved "https://registry.yarnpkg.com/@editorjs/paragraph/-/paragraph-2.11.4.tgz#ee91fc2d97f0aa9790860854fd804ef5d83d988e" + integrity sha512-OuTINHoHrJwKxlpTm6FtiXazwagALJbP49hfbQWBOLTNiBICncqPe1hdGfgDpeEgH9ZEGZsJelhEDxw2iwcmPA== dependencies: "@codexteam/icons" "^0.0.4" @@ -848,14 +848,11 @@ integrity sha512-jhuKLIRrhvCPLqwPcx6INqmKeiA5EWrsCOPhrlFSrbrmU4ZMPjj5Ul/oLCMDO98XRUIwVm78xICz4EPCektzeQ== "@types/node@*": - version "18.14.6" - resolved "https://registry.yarnpkg.com/@types/node/-/node-18.14.6.tgz#ae1973dd2b1eeb1825695bb11ebfb746d27e3e93" - integrity sha512-93+VvleD3mXwlLI/xASjw0FzKcwzl3OdTCzm1LaRfqgS21gfFtK3zDXM5Op9TeeMsJVOaJ2VRDpT9q4Y3d0AvA== - -"@types/node@^14.14.31": - version "14.18.42" - resolved "https://registry.yarnpkg.com/@types/node/-/node-14.18.42.tgz#fa39b2dc8e0eba61bdf51c66502f84e23b66e114" - integrity sha512-xefu+RBie4xWlK8hwAzGh3npDz/4VhF6icY/shU+zv/1fNn+ZVG7T7CRwe9LId9sAYRPxI+59QBPuKL3WpyGRg== + version "20.12.2" + resolved "https://registry.yarnpkg.com/@types/node/-/node-20.12.2.tgz#9facdd11102f38b21b4ebedd9d7999663343d72e" + integrity sha512-zQ0NYO87hyN6Xrclcqp7f8ZbXNbRfoGWNcMvHTPQp9UUrwI0mI7XBz+cu7/W6/VClYo2g63B0cjull/srU7LgQ== + dependencies: + undici-types "~5.26.4" "@types/node@^18.15.11": version "18.15.11" @@ -878,14 +875,14 @@ integrity sha512-0kSuKjAS0TrGLJ0M/+8MaFkGsQhZpB6pxOmvS3K8FYI72K//YmdfoW9X2qPsAKh1mkwxGD5zib9s1FIFed6E8g== "@types/sizzle@^2.3.2": - version "2.3.3" - resolved "https://registry.yarnpkg.com/@types/sizzle/-/sizzle-2.3.3.tgz#ff5e2f1902969d305225a047c8a0fd5c915cebef" - integrity sha512-JYM8x9EGF163bEyhdJBpR2QX1R5naCJHC8ucJylJ3w9/CVBaskdQ8WqBf8MmQrd1kRvp/a4TS8HJ+bxzR7ZJYQ== + version "2.3.8" + resolved "https://registry.yarnpkg.com/@types/sizzle/-/sizzle-2.3.8.tgz#518609aefb797da19bf222feb199e8f653ff7627" + integrity sha512-0vWLNK2D5MT9dg0iOo8GlKguPAU02QjmZitPEsXRuJXU/OGIOt9vT9Fc26wtYuavLxtO45v9PGleoL9Z0k1LHg== "@types/yauzl@^2.9.1": - version "2.10.0" - resolved "https://registry.yarnpkg.com/@types/yauzl/-/yauzl-2.10.0.tgz#b3248295276cf8c6f153ebe6a9aba0c988cb2599" - integrity sha512-Cn6WYCm0tXv8p6k+A8PvbDG763EDpBoTzHdA+Q/MF6H3sapGjCm9NzoaJncJS9tUKSuCoDs9XHxYYsQDgxR6kw== + version "2.10.3" + resolved "https://registry.yarnpkg.com/@types/yauzl/-/yauzl-2.10.3.tgz#e9b2808b4f109504a03cda958259876f61017999" + integrity sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q== dependencies: "@types/node" "*" @@ -1146,9 +1143,9 @@ astral-regex@^2.0.0: integrity sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ== async@^3.2.0: - version "3.2.4" - resolved "https://registry.yarnpkg.com/async/-/async-3.2.4.tgz#2d22e00f8cddeb5fde5dd33522b56d1cf569a81c" - integrity sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ== + version "3.2.5" + resolved "https://registry.yarnpkg.com/async/-/async-3.2.5.tgz#ebd52a8fdaf7a2289a24df399f8d8485c8a46b66" + integrity sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg== asynckit@^0.4.0: version "0.4.0" @@ -1199,6 +1196,7 @@ balanced-match@^2.0.0: base64-js@^1.3.1: version "1.5.1" resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" + integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== bcrypt-pbkdf@^1.0.0: version "1.0.2" @@ -1279,7 +1277,7 @@ buffer-from@^1.0.0: resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== -buffer@^5.6.0: +buffer@^5.7.1: version "5.7.1" resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.7.1.tgz#ba62e7c13133053582197160851a8f648e99eed0" integrity sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ== @@ -1300,9 +1298,9 @@ builtins@^5.0.1: semver "^7.0.0" cachedir@^2.3.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/cachedir/-/cachedir-2.3.0.tgz#0c75892a052198f0b21c7c1804d8331edfcae0e8" - integrity sha512-A+Fezp4zxnit6FanDmv9EqXNAi3vt9DWp51/71UEhXukb7QUuvtv9344h91dyAxuTLoSYJFU299qzR3tzwPAhw== + version "2.4.0" + resolved "https://registry.yarnpkg.com/cachedir/-/cachedir-2.4.0.tgz#7fef9cf7367233d7c88068fe6e34ed0d355a610d" + integrity sha512-9EtFOZR8g22CL7BWjJ9BUx1+A/djkofnyW3aOXZORNW2kxoUpx2h+uN2cOqwPmFhnpVmxg+KW2OjOSgChTEvsQ== caching-transform@^4.0.0: version "4.0.0" @@ -1314,7 +1312,7 @@ caching-transform@^4.0.0: package-hash "^4.0.0" write-file-atomic "^3.0.0" -call-bind@^1.0.0, call-bind@^1.0.2: +call-bind@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.2.tgz#b1d4e89e688119c3c9a903ad30abb2f6a919be3c" integrity sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA== @@ -1322,6 +1320,17 @@ call-bind@^1.0.0, call-bind@^1.0.2: function-bind "^1.1.1" get-intrinsic "^1.0.2" +call-bind@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.7.tgz#06016599c40c56498c18769d2730be242b6fa3b9" + integrity sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w== + dependencies: + es-define-property "^1.0.0" + es-errors "^1.3.0" + function-bind "^1.1.2" + get-intrinsic "^1.2.4" + set-function-length "^1.2.1" + callsites@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" @@ -1416,9 +1425,9 @@ chokidar@3.5.3: fsevents "~2.3.2" ci-info@^3.2.0: - version "3.8.0" - resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.8.0.tgz#81408265a5380c929f0bc665d62256628ce9ef91" - integrity sha512-eXTggHWSooYhq49F2opQhuHWgzucfF2YgODK4e1566GQs5BIfP30B0oenwBJHfWxAs2fyPB1s7Mg949zLf61Yw== + version "3.9.0" + resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.9.0.tgz#4279a62028a7b1f262f3473fc9605f5e218c59b4" + integrity sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ== clean-stack@^2.0.0: version "2.2.0" @@ -1433,9 +1442,9 @@ cli-cursor@^3.1.0: restore-cursor "^3.1.0" cli-table3@~0.6.1: - version "0.6.3" - resolved "https://registry.yarnpkg.com/cli-table3/-/cli-table3-0.6.3.tgz#61ab765aac156b52f222954ffc607a6f01dbeeb2" - integrity sha512-w5Jac5SykAeZJKntOxJCrm63Eg5/4dhMWIcuTbo9rpE+brgaSZo0RuNJZeOyMgsUdhDeojvgyQLmjI+K50ZGyg== + version "0.6.4" + resolved "https://registry.yarnpkg.com/cli-table3/-/cli-table3-0.6.4.tgz#d1c536b8a3f2e7bec58f67ac9e5769b1b30088b0" + integrity sha512-Lm3L0p+/npIQWNIiyF/nAn7T5dnOwR3xNTHXYEBFBFVPXzCVNZ5lqEC/1eo/EVfpDsQ1I+TX4ORPQgp+UI0CRw== dependencies: string-width "^4.2.0" optionalDependencies: @@ -1516,9 +1525,9 @@ colord@^2.9.3: integrity sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw== colorette@^2.0.16: - version "2.0.19" - resolved "https://registry.yarnpkg.com/colorette/-/colorette-2.0.19.tgz#cdf044f47ad41a0f4b56b3a0d5b4e6e1a2d5a798" - integrity sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ== + version "2.0.20" + resolved "https://registry.yarnpkg.com/colorette/-/colorette-2.0.20.tgz#9eb793e6833067f7235902fcd3b09917a000a95a" + integrity sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w== combined-stream@^1.0.6, combined-stream@~1.0.6: version "1.0.8" @@ -1532,10 +1541,10 @@ commander@^2.12.1: resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== -commander@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/commander/-/commander-5.1.0.tgz#46abbd1652f8e059bddaef99bbdcb2ad9cf179ae" - integrity sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg== +commander@^6.2.1: + version "6.2.1" + resolved "https://registry.yarnpkg.com/commander/-/commander-6.2.1.tgz#0792eb682dfbc325999bb2b84fddddba110ac73c" + integrity sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA== comment-parser@1.3.1: version "1.3.1" @@ -1680,26 +1689,25 @@ cypress-terminal-report@^5.3.2: semver "^7.3.5" tv4 "^1.3.0" -cypress@^12.9.0: - version "12.9.0" - resolved "https://registry.yarnpkg.com/cypress/-/cypress-12.9.0.tgz#e6ab43cf329fd7c821ef7645517649d72ccf0a12" - integrity sha512-Ofe09LbHKgSqX89Iy1xen2WvpgbvNxDzsWx3mgU1mfILouELeXYGwIib3ItCwoRrRifoQwcBFmY54Vs0zw7QCg== +cypress@^13.7.1: + version "13.7.1" + resolved "https://registry.yarnpkg.com/cypress/-/cypress-13.7.1.tgz#d1208eb04efd46ef52a30480a5da71a03373261a" + integrity sha512-4u/rpFNxOFCoFX/Z5h+uwlkBO4mWzAjveURi3vqdSu56HPvVdyGTxGw4XKGWt399Y1JwIn9E1L9uMXQpc0o55w== dependencies: - "@cypress/request" "^2.88.10" + "@cypress/request" "^3.0.0" "@cypress/xvfb" "^1.2.4" - "@types/node" "^14.14.31" "@types/sinonjs__fake-timers" "8.1.1" "@types/sizzle" "^2.3.2" arch "^2.2.0" blob-util "^2.0.2" bluebird "^3.7.2" - buffer "^5.6.0" + buffer "^5.7.1" cachedir "^2.3.0" chalk "^4.1.0" check-more-types "^2.24.0" cli-cursor "^3.1.0" cli-table3 "~0.6.1" - commander "^5.1.0" + commander "^6.2.1" common-tags "^1.8.0" dayjs "^1.10.4" debug "^4.3.4" @@ -1711,18 +1719,19 @@ cypress@^12.9.0: figures "^3.2.0" fs-extra "^9.1.0" getos "^3.2.1" - is-ci "^3.0.0" + is-ci "^3.0.1" is-installed-globally "~0.4.0" lazy-ass "^1.6.0" listr2 "^3.8.3" lodash "^4.17.21" log-symbols "^4.0.0" - minimist "^1.2.6" + minimist "^1.2.8" ospath "^1.2.2" pretty-bytes "^5.6.0" + process "^0.11.10" proxy-from-env "1.0.0" request-progress "^3.0.0" - semver "^7.3.2" + semver "^7.5.3" supports-color "^8.1.1" tmp "~0.2.1" untildify "^4.0.0" @@ -1741,9 +1750,9 @@ dayjs@1.10.7: integrity sha512-P6twpd70BcPK34K26uJ1KT3wlhpuOAPoMwJzpsIWUxHZ7wpmbdZL/hQqBDfz7hGurYSa5PhzdhDHtt319hL3ig== dayjs@^1.10.4: - version "1.11.7" - resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.7.tgz#4b296922642f70999544d1144a2c25730fce63e2" - integrity sha512-+Yw9U6YO5TQohxLcIkrXBeY73WP3ejHWVvx8XCk3gxvQDCTEmS48ZrSZCKciI7Bhl/uCMyxYtE9UqRILmFphkQ== + version "1.11.10" + resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.10.tgz#68acea85317a6e164457d6d6947564029a6a16a0" + integrity sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ== debug@4.3.4, debug@^4.1.0, debug@^4.1.1, debug@^4.3.2, debug@^4.3.4: version "4.3.4" @@ -1796,6 +1805,15 @@ default-require-extensions@^3.0.0: dependencies: strip-bom "^4.0.0" +define-data-property@^1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/define-data-property/-/define-data-property-1.1.4.tgz#894dc141bb7d3060ae4366f6a0107e68fbe48c5e" + integrity sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A== + dependencies: + es-define-property "^1.0.0" + es-errors "^1.3.0" + gopd "^1.0.1" + define-properties@^1.1.3, define-properties@^1.1.4: version "1.2.0" resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.2.0.tgz#52988570670c9eacedd8064f4a990f2405849bd5" @@ -1871,11 +1889,12 @@ end-of-stream@^1.1.0: once "^1.4.0" enquirer@^2.3.6: - version "2.3.6" - resolved "https://registry.yarnpkg.com/enquirer/-/enquirer-2.3.6.tgz#2a7fe5dd634a1e4125a975ec994ff5456dc3734d" - integrity sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg== + version "2.4.1" + resolved "https://registry.yarnpkg.com/enquirer/-/enquirer-2.4.1.tgz#93334b3fbd74fc7097b224ab4a8fb7e40bf4ae56" + integrity sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ== dependencies: ansi-colors "^4.1.1" + strip-ansi "^6.0.1" error-ex@^1.3.1: version "1.3.2" @@ -1923,6 +1942,18 @@ es-abstract@^1.19.0, es-abstract@^1.20.4: unbox-primitive "^1.0.2" which-typed-array "^1.1.9" +es-define-property@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.0.tgz#c7faefbdff8b2696cf5f46921edfb77cc4ba3845" + integrity sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ== + dependencies: + get-intrinsic "^1.2.4" + +es-errors@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f" + integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw== + es-set-tostringtag@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/es-set-tostringtag/-/es-set-tostringtag-2.0.1.tgz#338d502f6f674301d710b80c8592de8a15f09cd8" @@ -2492,10 +2523,10 @@ fsevents@~2.3.2: resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== -function-bind@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" - integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A== +function-bind@^1.1.1, function-bind@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c" + integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== function.prototype.name@^1.1.5: version "1.1.5" @@ -2522,7 +2553,18 @@ get-caller-file@^2.0.1, get-caller-file@^2.0.5: resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== -get-intrinsic@^1.0.2, get-intrinsic@^1.1.1, get-intrinsic@^1.1.3, get-intrinsic@^1.2.0: +get-intrinsic@^1.0.2, get-intrinsic@^1.2.4: + version "1.2.4" + resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.2.4.tgz#e385f5a4b5227d449c3eabbad05494ef0abbeadd" + integrity sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ== + dependencies: + es-errors "^1.3.0" + function-bind "^1.1.2" + has-proto "^1.0.1" + has-symbols "^1.0.3" + hasown "^2.0.0" + +get-intrinsic@^1.1.1, get-intrinsic@^1.1.3, get-intrinsic@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.2.0.tgz#7ad1dc0535f3a2904bba075772763e5051f6d05f" integrity sha512-L049y6nFOuom5wGyRc3/gdTLO94dySVKRACj1RmJZBQXlbTMhtNIgkWkUHq+jYmZvKf14EW1EoJnnjbmoHij0Q== @@ -2723,6 +2765,13 @@ has-property-descriptors@^1.0.0: dependencies: get-intrinsic "^1.1.1" +has-property-descriptors@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz#963ed7d071dc7bf5f084c5bfbe0d1b6222586854" + integrity sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg== + dependencies: + es-define-property "^1.0.0" + has-proto@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/has-proto/-/has-proto-1.0.1.tgz#1885c1305538958aff469fef37937c22795408e0" @@ -2741,11 +2790,9 @@ has-tostringtag@^1.0.0: has-symbols "^1.0.2" has@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796" - integrity sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw== - dependencies: - function-bind "^1.1.1" + version "1.0.4" + resolved "https://registry.yarnpkg.com/has/-/has-1.0.4.tgz#2eb2860e000011dae4f1406a86fe80e530fb2ec6" + integrity sha512-qdSAmqLF6209RFj4VVItywPMbm3vWylknmB3nvNiUIs72xAimcM8nVYxYr7ncvZq5qzk9MKIZR8ijqD/1QuYjQ== hasha@^5.0.0: version "5.2.2" @@ -2755,6 +2802,13 @@ hasha@^5.0.0: is-stream "^2.0.0" type-fest "^0.8.0" +hasown@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.2.tgz#003eaf91be7adc372e84ec59dc37252cedb80003" + integrity sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ== + dependencies: + function-bind "^1.1.2" + he@1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" @@ -2804,6 +2858,7 @@ human-signals@^1.1.1: ieee754@^1.1.13: version "1.2.1" resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" + integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== ignore@^5.1.1, ignore@^5.1.4: version "5.1.4" @@ -2924,7 +2979,7 @@ is-callable@^1.1.3, is-callable@^1.1.4, is-callable@^1.2.7: resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.7.tgz#3bc2a85ea742d9e36205dcacdd72ca1fdc51b055" integrity sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA== -is-ci@^3.0.0: +is-ci@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/is-ci/-/is-ci-3.0.1.tgz#db6ecbed1bd659c43dac0f45661e7674103d1867" integrity sha512-ZYvCgrefwqoQ6yTyYUbQu64HsITZ3NfKX1lzaEYdkTDcfKzzCI/wthRRYKkdjHKFVgNiXKAKm65Zo1pk2as/QQ== @@ -3524,7 +3579,7 @@ minimist-options@4.1.0: is-plain-obj "^1.1.0" kind-of "^6.0.3" -minimist@^1.2.0, minimist@^1.2.6: +minimist@^1.2.0, minimist@^1.2.6, minimist@^1.2.8: version "1.2.8" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== @@ -3695,11 +3750,16 @@ nyc@15.1.0: test-exclude "^6.0.0" yargs "^15.0.2" -object-inspect@^1.12.2, object-inspect@^1.9.0: +object-inspect@^1.12.2: version "1.12.3" resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.12.3.tgz#ba62dffd67ee256c8c086dfae69e016cd1f198b9" integrity sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g== +object-inspect@^1.13.1: + version "1.13.1" + resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.1.tgz#b96c6109324ccfef6b12216a956ca4dc2ff94bc2" + integrity sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ== + object-keys@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e" @@ -4262,12 +4322,17 @@ process-on-spawn@^1.0.0: dependencies: fromentries "^1.2.0" +process@^0.11.10: + version "0.11.10" + resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182" + integrity sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A== + proxy-from-env@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.0.0.tgz#33c50398f70ea7eb96d21f7b817630a55791c7ee" integrity sha512-F2JHgJQ1iqwnHDcQjVBsq3n/uoaFL+iPW/eAeL7kVxy/2RrWaN4WroKjjvbsoRtv0ftelNyC01bjRhn/bhcf4A== -psl@^1.1.28: +psl@^1.1.33: version "1.9.0" resolved "https://registry.yarnpkg.com/psl/-/psl-1.9.0.tgz#d0df2a137f00794565fcaf3b2c00cd09f8d5a5a7" integrity sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag== @@ -4285,17 +4350,22 @@ punycode@^2.1.0: resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" punycode@^2.1.1: - version "2.3.0" - resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.0.tgz#f67fa67c94da8f4d0cfff981aee4118064199b8f" - integrity sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA== + version "2.3.1" + resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" + integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== -qs@~6.10.3: - version "6.10.5" - resolved "https://registry.yarnpkg.com/qs/-/qs-6.10.5.tgz#974715920a80ff6a262264acd2c7e6c2a53282b4" - integrity sha512-O5RlPh0VFtR78y79rgcgKK4wbAI0C5zGVLztOIdpWX6ep368q5Hv6XRxDvXuZ9q3C6v+e3n8UfZZJw7IIG27eQ== +qs@6.10.4: + version "6.10.4" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.10.4.tgz#6a3003755add91c0ec9eacdc5f878b034e73f9e7" + integrity sha512-OQiU+C+Ds5qiH91qh/mg0w+8nwQuLjM4F4M/PbmhDOoYehPh+Fb0bDjtR1sOvy7YKxvj28Y/M0PhP5uVX0kB+g== dependencies: side-channel "^1.0.4" +querystringify@^2.1.1: + version "2.2.0" + resolved "https://registry.yarnpkg.com/querystringify/-/querystringify-2.2.0.tgz#3345941b4153cb9d082d8eee4cda2016a9aef7f6" + integrity sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ== + queue-microtask@^1.2.2: version "1.2.3" resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" @@ -4390,6 +4460,11 @@ require-main-filename@^2.0.0: resolved "https://registry.yarnpkg.com/require-main-filename/-/require-main-filename-2.0.0.tgz#d0b329ecc7cc0f61649f62215be69af54aa8989b" integrity sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg== +requires-port@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff" + integrity sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ== + resolve-from@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" @@ -4423,9 +4498,9 @@ reusify@^1.0.4: integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw== rfdc@^1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/rfdc/-/rfdc-1.3.0.tgz#d0b7c441ab2720d05dc4cf26e01c89631d9da08b" - integrity sha512-V2hovdzFbOi77/WajaSMXk2OLm+xNIeQdMMuB7icj7bk6zi2F8GGAxigcnDFpJHbNyNcgyJDiP+8nOrY5cZGrA== + version "1.3.1" + resolved "https://registry.yarnpkg.com/rfdc/-/rfdc-1.3.1.tgz#2b6d4df52dffe8bb346992a10ea9451f24373a8f" + integrity sha512-r5a3l5HzYlIC68TpmYKlxWjmOP6wiPJ1vWv2HeLhNsRZMrCkxeqxiHlQ21oXmQ4F3SiryXBHhAD7JZqvOJjFmg== rimraf@^3.0.0, rimraf@^3.0.2: version "3.0.2" @@ -4464,9 +4539,9 @@ run-parallel@^1.1.9: queue-microtask "^1.2.2" rxjs@^7.5.1: - version "7.8.0" - resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-7.8.0.tgz#90a938862a82888ff4c7359811a595e14e1e09a4" - integrity sha512-F2+gxDshqmIub1KdvZkaEfGDwLNpPvk9Fs6LD/MyQxNgMds/WH9OdDDXOmxUZpME+iSK3rQCctkL0DYyytUqMg== + version "7.8.1" + resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-7.8.1.tgz#6f6f3d99ea8044291efd92e7c7fcf562c4057543" + integrity sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg== dependencies: tslib "^2.1.0" @@ -4508,7 +4583,7 @@ semver@^6.0.0, semver@^6.3.0: resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== -semver@^7.0.0, semver@^7.3.2, semver@^7.3.4, semver@^7.3.5, semver@^7.3.7, semver@^7.3.8: +semver@^7.0.0, semver@^7.3.4, semver@^7.3.5, semver@^7.3.7, semver@^7.3.8, semver@^7.5.3: version "7.6.0" resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.0.tgz#1a46a4db4bffcccd97b743b5005c8325f23d4e2d" integrity sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg== @@ -4527,6 +4602,18 @@ set-blocking@^2.0.0: resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" integrity sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw== +set-function-length@^1.2.1: + version "1.2.2" + resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.2.2.tgz#aac72314198eaed975cf77b2c3b6b880695e5449" + integrity sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg== + dependencies: + define-data-property "^1.1.4" + es-errors "^1.3.0" + function-bind "^1.1.2" + get-intrinsic "^1.2.4" + gopd "^1.0.1" + has-property-descriptors "^1.0.2" + shallow-clone@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/shallow-clone/-/shallow-clone-3.0.1.tgz#8f2981ad92531f55035b01fb230769a40e02efa3" @@ -4547,13 +4634,14 @@ shebang-regex@^3.0.0: integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== side-channel@^1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.4.tgz#efce5c8fdc104ee751b25c58d4290011fa5ea2cf" - integrity sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw== + version "1.0.6" + resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.6.tgz#abd25fb7cd24baf45466406b1096b7831c9215f2" + integrity sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA== dependencies: - call-bind "^1.0.0" - get-intrinsic "^1.0.2" - object-inspect "^1.9.0" + call-bind "^1.0.7" + es-errors "^1.3.0" + get-intrinsic "^1.2.4" + object-inspect "^1.13.1" signal-exit@^3.0.2, signal-exit@^3.0.7: version "3.0.7" @@ -4680,9 +4768,9 @@ sprintf-js@~1.0.2: integrity sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g== sshpk@^1.14.1: - version "1.17.0" - resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.17.0.tgz#578082d92d4fe612b13007496e543fa0fbcbe4c5" - integrity sha512-/9HIEs1ZXGhSPE8X6Ccm7Nam1z8KcoCqPdI7ecm1N33EzAetWahvQWVqLZtaZQ+IDKX4IyA2o0gBzqIMkAagHQ== + version "1.18.0" + resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.18.0.tgz#1663e55cddf4d688b86a46b77f0d5fe363aba028" + integrity sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ== dependencies: asn1 "~0.2.3" assert-plus "^1.0.0" @@ -4873,9 +4961,9 @@ text-table@^0.2.0: integrity sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw== throttleit@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/throttleit/-/throttleit-1.0.0.tgz#9e785836daf46743145a5984b6268d828528ac6c" - integrity sha512-rkTVqu6IjfQ/6+uNuuc3sZek4CEYxTJom3IktzgdSxcZqdARuebbA/f4QmAxMQIxqq9ZLEUkSYqvuk1I6VKq4g== + version "1.0.1" + resolved "https://registry.yarnpkg.com/throttleit/-/throttleit-1.0.1.tgz#304ec51631c3b770c65c6c6f76938b384000f4d5" + integrity sha512-vDZpf9Chs9mAdfY046mcPt8fg5QSZr37hEH4TXYBnDF+izxgrbRGUAAaBvIk/fJm9aOFCGFd1EsNg5AZCbnQCQ== through@^2.3.8: version "2.3.8" @@ -4883,11 +4971,9 @@ through@^2.3.8: integrity sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg== tmp@~0.2.1: - version "0.2.1" - resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.2.1.tgz#8457fc3037dcf4719c251367a1af6500ee1ccf14" - integrity sha512-76SUhtfqR2Ijn+xllcI5P1oyannHNHByD80W1q447gU3mp9G9PSpGdWmjUOHRDPiHYacIk66W7ubDTuPF3BEtQ== - dependencies: - rimraf "^3.0.0" + version "0.2.3" + resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.2.3.tgz#eb783cc22bc1e8bebd0671476d46ea4eb32a79ae" + integrity sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w== to-fast-properties@^2.0.0: version "2.0.0" @@ -4901,13 +4987,15 @@ to-regex-range@^5.0.1: dependencies: is-number "^7.0.0" -tough-cookie@~2.5.0: - version "2.5.0" - resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.5.0.tgz#cd9fb2a0aa1d5a12b473bd9fb96fa3dcff65ade2" - integrity sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g== +tough-cookie@^4.1.3: + version "4.1.3" + resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-4.1.3.tgz#97b9adb0728b42280aa3d814b6b999b2ff0318bf" + integrity sha512-aX/y5pVRkfRnfmuX+OdbSdXvPe6ieKX/G2s7e98f4poJHnqH3281gDPm/metm6E/WRamfx7WC4HUqkWHfQHprw== dependencies: - psl "^1.1.28" + psl "^1.1.33" punycode "^2.1.1" + universalify "^0.2.0" + url-parse "^1.5.3" trim-newlines@^3.0.0: version "3.0.1" @@ -4933,9 +5021,9 @@ tslib@^1.8.1: resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.11.1.tgz#eb15d128827fbee2841549e171f45ed338ac7e35" tslib@^2.1.0: - version "2.5.0" - resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.5.0.tgz#42bfed86f5787aeb41d031866c8f402429e0fddf" - integrity sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg== + version "2.6.2" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae" + integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q== tslint@^6.1.1: version "6.1.3" @@ -5050,15 +5138,25 @@ unbox-primitive@^1.0.2: has-symbols "^1.0.3" which-boxed-primitive "^1.0.2" +undici-types@~5.26.4: + version "5.26.5" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617" + integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA== + uniq@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/uniq/-/uniq-1.0.1.tgz#b31c5ae8254844a3a8281541ce2b04b865a734ff" integrity sha512-Gw+zz50YNKPDKXs+9d+aKAjVwpjNwqzvNpLigIruT4HA9lMZNdMqs9x07kKHB/L9WRzqp4+DlTU5s4wG2esdoA== +universalify@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.2.0.tgz#6451760566fa857534745ab1dde952d1b1761be0" + integrity sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg== + universalify@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.0.tgz#75a4984efedc4b08975c5aeb73f530d02df25717" - integrity sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ== + version "2.0.1" + resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.1.tgz#168efc2180964e6386d061e094df61afe239b18d" + integrity sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw== untildify@^4.0.0: version "4.0.0" @@ -5080,6 +5178,14 @@ uri-js@^4.2.2: dependencies: punycode "^2.1.0" +url-parse@^1.5.3: + version "1.5.10" + resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.5.10.tgz#9d3c2f736c1d75dd3bd2be507dcc111f1e2ea9c1" + integrity sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ== + dependencies: + querystringify "^2.1.1" + requires-port "^1.0.0" + util-deprecate@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" From ecdd73347c8b0adcff0d05b3368d3f11928862a0 Mon Sep 17 00:00:00 2001 From: Peter Savchenko Date: Thu, 11 Apr 2024 17:00:48 +0300 Subject: [PATCH 10/61] fix(dx): dev example page fixed (#2682) * fix dev example * embed goes to master --- example/example-dev.html | 32 ++++++++++++++++---------------- example/tools/checklist | 2 +- example/tools/code | 2 +- example/tools/delimiter | 2 +- example/tools/embed | 2 +- example/tools/header | 2 +- example/tools/image | 2 +- example/tools/inline-code | 2 +- example/tools/link | 2 +- example/tools/list | 2 +- example/tools/marker | 2 +- example/tools/nested-list | 2 +- example/tools/quote | 2 +- example/tools/raw | 2 +- example/tools/simple-image | 2 +- example/tools/table | 2 +- example/tools/warning | 2 +- 17 files changed, 32 insertions(+), 32 deletions(-) diff --git a/example/example-dev.html b/example/example-dev.html index c1d389975..05d713175 100644 --- a/example/example-dev.html +++ b/example/example-dev.html @@ -89,22 +89,22 @@ Read more in Tool's README file. For example: https://github.com/editor-js/header#installation --> - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + diff --git a/example/tools/checklist b/example/tools/checklist index b1367277e..1c116d5e0 160000 --- a/example/tools/checklist +++ b/example/tools/checklist @@ -1 +1 @@ -Subproject commit b1367277e070bbbf80b7b14b1963845ba9a71d8c +Subproject commit 1c116d5e09e19951948d6166047aa2f30877aaf9 diff --git a/example/tools/code b/example/tools/code index 193f5f6f0..f281996f8 160000 --- a/example/tools/code +++ b/example/tools/code @@ -1 +1 @@ -Subproject commit 193f5f6f00288679a97bfe620a4d811e5acd9b16 +Subproject commit f281996f82c7ac676172757e45687cae27443427 diff --git a/example/tools/delimiter b/example/tools/delimiter index 86e8c5501..4ca1c1c97 160000 --- a/example/tools/delimiter +++ b/example/tools/delimiter @@ -1 +1 @@ -Subproject commit 86e8c5501dcbb8eaaeec756e1145db49b8339160 +Subproject commit 4ca1c1c972261f47dd34f6b8754763a4a79a4866 diff --git a/example/tools/embed b/example/tools/embed index 23de06be6..dfdbf2423 160000 --- a/example/tools/embed +++ b/example/tools/embed @@ -1 +1 @@ -Subproject commit 23de06be69bb9e636a2278b0d54f8c2d85d7ae13 +Subproject commit dfdbf2423d2777f7026a7df768c6582e1a409db7 diff --git a/example/tools/header b/example/tools/header index 80278ee75..5118ce87a 160000 --- a/example/tools/header +++ b/example/tools/header @@ -1 +1 @@ -Subproject commit 80278ee75146ff461e9dcaeff1a337167ef97162 +Subproject commit 5118ce87a752515fb6b31325f234f4ccd62f42c9 diff --git a/example/tools/image b/example/tools/image index 927ec04ed..25d46cd8d 160000 --- a/example/tools/image +++ b/example/tools/image @@ -1 +1 @@ -Subproject commit 927ec04edae75fb2e9a83add24be38d439dc3a19 +Subproject commit 25d46cd8d3930851b14ddc26ee80fb5b485e1496 diff --git a/example/tools/inline-code b/example/tools/inline-code index 7cc94718e..dcd4c1774 160000 --- a/example/tools/inline-code +++ b/example/tools/inline-code @@ -1 +1 @@ -Subproject commit 7cc94718e4c20d6f9db2c236a60b119c39d389e0 +Subproject commit dcd4c17740c9ba636140751596aff1e9f6ef6b01 diff --git a/example/tools/link b/example/tools/link index 861de29b1..aaa69d540 160000 --- a/example/tools/link +++ b/example/tools/link @@ -1 +1 @@ -Subproject commit 861de29b1d553bb9377dcbaf451af605b28b57bd +Subproject commit aaa69d5408bad34027d6252a3892d40f9fa121be diff --git a/example/tools/list b/example/tools/list index f0e9f0110..a6dc6a692 160000 --- a/example/tools/list +++ b/example/tools/list @@ -1 +1 @@ -Subproject commit f0e9f0110983cd973a1345f2885b18db4fd54636 +Subproject commit a6dc6a692b88c9eff3d87223b239e7517b160c67 diff --git a/example/tools/marker b/example/tools/marker index 13e0b1cf7..8d6897fca 160000 --- a/example/tools/marker +++ b/example/tools/marker @@ -1 +1 @@ -Subproject commit 13e0b1cf72cfa706dc236e617683a5e349a021f5 +Subproject commit 8d6897fca43e387bcdf4a681380be975fe8f2a07 diff --git a/example/tools/nested-list b/example/tools/nested-list index c5c473955..95b37462d 160000 --- a/example/tools/nested-list +++ b/example/tools/nested-list @@ -1 +1 @@ -Subproject commit c5c47395516cae0e456881a67a84fd69fec06c47 +Subproject commit 95b37462dc93c19b83f0481f509034a40d436cf2 diff --git a/example/tools/quote b/example/tools/quote index 02e0db32a..9377ca713 160000 --- a/example/tools/quote +++ b/example/tools/quote @@ -1 +1 @@ -Subproject commit 02e0db32a101ec5cfa61210de45be7de647c40c6 +Subproject commit 9377ca713f552576b8b11f77cf371b67261ec00b diff --git a/example/tools/raw b/example/tools/raw index b4164eac4..cae470fde 160000 --- a/example/tools/raw +++ b/example/tools/raw @@ -1 +1 @@ -Subproject commit b4164eac4d81259a15368d7681884e3554554662 +Subproject commit cae470fded570ef9a82a45734526ccf45959e204 diff --git a/example/tools/simple-image b/example/tools/simple-image index 2d411a650..963883520 160000 --- a/example/tools/simple-image +++ b/example/tools/simple-image @@ -1 +1 @@ -Subproject commit 2d411a650afa04f0468f7648ee0b5a765362161c +Subproject commit 963883520c7bbe5040366335c9a37bbdc7cf60fd diff --git a/example/tools/table b/example/tools/table index 605a73d2b..2948cd759 160000 --- a/example/tools/table +++ b/example/tools/table @@ -1 +1 @@ -Subproject commit 605a73d2b7bec6438c7c0d5ab09eae86b5e9212e +Subproject commit 2948cd7595e632f7555e2dc09e6bac050a2b87ea diff --git a/example/tools/warning b/example/tools/warning index 7e706b1cb..e63e91aa8 160000 --- a/example/tools/warning +++ b/example/tools/warning @@ -1 +1 @@ -Subproject commit 7e706b1cb67655db75d3a154038e4f11e2d00128 +Subproject commit e63e91aa833d774be9bf4a76013b1025a009989d From 5125f015dc87424d137ce4824863f0f64baf8eff Mon Sep 17 00:00:00 2001 From: Tatiana Fomina Date: Sat, 13 Apr 2024 20:34:26 +0300 Subject: [PATCH 11/61] feat: nested popover (#2649) * Move popover types to separate file * tmp * open top * Fix bug with keyboard navigation * Fix bug with scroll * Fix mobile * Add popover header class * Display nested items on mobile * Refactor history * Fix positioning on desktop * Fix tests * Fix child popover indent left * Fix ts errors in popover files * Move files * Rename cn to bem * Clarify comments and rename method * Refactor popover css classes * Rename cls to css * Split popover desktop and mobile classes * Add ability to open popover to the left if not enough space to open to the right * Add nested popover test * Add popover test for mobile screens * Fix tests * Add union type for both popovers * Add global window resize event * Multiple fixes * Move nodes initialization to constructor * Rename handleShowingNestedItems to showNestedItems * Replace WindowResize with EditorMobileLayoutToggled * New doze of fixes * Review fixes * Fixes * Fixes * Make each nested popover decide itself if it should open top * Update changelog * Update changelog * Update changelog --- docs/CHANGELOG.md | 4 + src/components/dom.ts | 6 +- .../events/EditorMobileLayoutToggled.ts | 15 + src/components/events/index.ts | 5 +- src/components/flipper.ts | 9 +- .../modules/toolbar/blockSettings.ts | 36 +- src/components/modules/toolbar/index.ts | 4 +- src/components/modules/ui.ts | 22 +- src/components/ui/toolbox.ts | 107 +++- src/components/utils/bem.ts | 25 + .../components/popover-header/index.ts | 2 + .../popover-header/popover-header.const.ts | 15 + .../popover-header/popover-header.ts | 71 +++ .../popover-header/popover-header.types.ts | 14 + .../popover/components/popover-item/index.ts | 2 + .../popover-item/popover-item.const.ts | 26 + .../popover-item}/popover-item.ts | 120 ++-- .../popover/components/search-input/index.ts | 2 + .../search-input/search-input.const.ts | 15 + .../search-input}/search-input.ts | 97 ++-- .../search-input/search-input.types.ts | 9 + src/components/utils/popover/index.ts | 527 +----------------- .../utils/popover/popover-abstract.ts | 291 ++++++++++ .../utils/popover/popover-desktop.ts | 356 ++++++++++++ .../utils/popover/popover-mobile.ts | 142 +++++ src/components/utils/popover/popover.const.ts | 27 + src/components/utils/popover/popover.types.ts | 109 ++++ .../popover/utils/popover-states-history.ts | 73 +++ src/styles/popover.css | 184 ++++-- .../tests/modules/BlockEvents/Slash.cy.ts | 8 +- test/cypress/tests/utils/popover.cy.ts | 186 ++++++- types/configs/popover.d.ts | 21 +- 32 files changed, 1770 insertions(+), 760 deletions(-) create mode 100644 src/components/events/EditorMobileLayoutToggled.ts create mode 100644 src/components/utils/bem.ts create mode 100644 src/components/utils/popover/components/popover-header/index.ts create mode 100644 src/components/utils/popover/components/popover-header/popover-header.const.ts create mode 100644 src/components/utils/popover/components/popover-header/popover-header.ts create mode 100644 src/components/utils/popover/components/popover-header/popover-header.types.ts create mode 100644 src/components/utils/popover/components/popover-item/index.ts create mode 100644 src/components/utils/popover/components/popover-item/popover-item.const.ts rename src/components/utils/popover/{ => components/popover-item}/popover-item.ts (66%) create mode 100644 src/components/utils/popover/components/search-input/index.ts create mode 100644 src/components/utils/popover/components/search-input/search-input.const.ts rename src/components/utils/popover/{ => components/search-input}/search-input.ts (72%) create mode 100644 src/components/utils/popover/components/search-input/search-input.types.ts create mode 100644 src/components/utils/popover/popover-abstract.ts create mode 100644 src/components/utils/popover/popover-desktop.ts create mode 100644 src/components/utils/popover/popover-mobile.ts create mode 100644 src/components/utils/popover/popover.const.ts create mode 100644 src/components/utils/popover/popover.types.ts create mode 100644 src/components/utils/popover/utils/popover-states-history.ts diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index c98cd547c..0d19eae39 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +### 2.30.1 + +– `New` – Block Tunes now supports nesting items + ### 2.30.0 - `Improvement` — The ability to merge blocks of different types (if both tools provide the conversionConfig) diff --git a/src/components/dom.ts b/src/components/dom.ts index f7b653cca..1bf2b1c47 100644 --- a/src/components/dom.ts +++ b/src/components/dom.ts @@ -52,11 +52,13 @@ export default class Dom { * @param {object} [attributes] - any attributes * @returns {HTMLElement} */ - public static make(tagName: string, classNames: string | string[] | null = null, attributes: object = {}): HTMLElement { + public static make(tagName: string, classNames: string | (string | undefined)[] | null = null, attributes: object = {}): HTMLElement { const el = document.createElement(tagName); if (Array.isArray(classNames)) { - el.classList.add(...classNames); + const validClassnames = classNames.filter(className => className !== undefined) as string[]; + + el.classList.add(...validClassnames); } else if (classNames) { el.classList.add(classNames); } diff --git a/src/components/events/EditorMobileLayoutToggled.ts b/src/components/events/EditorMobileLayoutToggled.ts new file mode 100644 index 000000000..cd4f953ef --- /dev/null +++ b/src/components/events/EditorMobileLayoutToggled.ts @@ -0,0 +1,15 @@ +/** + * Fired when editor mobile layout toggled + */ +export const EditorMobileLayoutToggled = 'editor mobile layout toggled'; + +/** + * Payload that will be passed with the event + */ +export interface EditorMobileLayoutToggledPayload { + /** + * True, if mobile layout enabled + */ + isEnabled: boolean; +} + diff --git a/src/components/events/index.ts b/src/components/events/index.ts index 8d93d97de..15aac17da 100644 --- a/src/components/events/index.ts +++ b/src/components/events/index.ts @@ -3,6 +3,7 @@ import { BlockChanged, BlockChangedPayload } from './BlockChanged'; import { BlockHovered, BlockHoveredPayload } from './BlockHovered'; import { FakeCursorAboutToBeToggled, FakeCursorAboutToBeToggledPayload } from './FakeCursorAboutToBeToggled'; import { FakeCursorHaveBeenSet, FakeCursorHaveBeenSetPayload } from './FakeCursorHaveBeenSet'; +import { EditorMobileLayoutToggled, EditorMobileLayoutToggledPayload } from './EditorMobileLayoutToggled'; /** * Events fired by Editor Event Dispatcher @@ -11,7 +12,8 @@ export { RedactorDomChanged, BlockChanged, FakeCursorAboutToBeToggled, - FakeCursorHaveBeenSet + FakeCursorHaveBeenSet, + EditorMobileLayoutToggled }; /** @@ -23,4 +25,5 @@ export interface EditorEventMap { [BlockChanged]: BlockChangedPayload; [FakeCursorAboutToBeToggled]: FakeCursorAboutToBeToggledPayload; [FakeCursorHaveBeenSet]: FakeCursorHaveBeenSetPayload; + [EditorMobileLayoutToggled]: EditorMobileLayoutToggledPayload } diff --git a/src/components/flipper.ts b/src/components/flipper.ts index 289984605..516e2b620 100644 --- a/src/components/flipper.ts +++ b/src/components/flipper.ts @@ -49,15 +49,11 @@ export default class Flipper { /** * Instance of flipper iterator - * - * @type {DomIterator|null} */ - private readonly iterator: DomIterator = null; + private readonly iterator: DomIterator | null = null; /** * Flag that defines activation status - * - * @type {boolean} */ private activated = false; @@ -77,7 +73,7 @@ export default class Flipper { private flipCallbacks: Array<() => void> = []; /** - * @param {FlipperOptions} options - different constructing settings + * @param options - different constructing settings */ constructor(options: FlipperOptions) { this.iterator = new DomIterator(options.items, options.focusedItemClass); @@ -110,7 +106,6 @@ export default class Flipper { */ public activate(items?: HTMLElement[], cursorPosition?: number): void { this.activated = true; - if (items) { this.iterator.setItems(items); } diff --git a/src/components/modules/toolbar/blockSettings.ts b/src/components/modules/toolbar/blockSettings.ts index 24df44475..e43a072e2 100644 --- a/src/components/modules/toolbar/blockSettings.ts +++ b/src/components/modules/toolbar/blockSettings.ts @@ -7,7 +7,10 @@ import { I18nInternalNS } from '../../i18n/namespace-internal'; import Flipper from '../../flipper'; import { TunesMenuConfigItem } from '../../../../types/tools'; import { resolveAliases } from '../../utils/resolve-aliases'; -import Popover, { PopoverEvent } from '../../utils/popover'; +import { type Popover, PopoverDesktop, PopoverMobile } from '../../utils/popover'; +import { PopoverEvent } from '../../utils/popover/popover.types'; +import { isMobileScreen } from '../../utils'; +import { EditorMobileLayoutToggled } from '../../events'; /** * HTML Elements that used for BlockSettings @@ -27,8 +30,6 @@ interface BlockSettingsNodes { export default class BlockSettings extends Module { /** * Module Events - * - * @returns {{opened: string, closed: string}} */ public get events(): { opened: string; closed: string } { return { @@ -56,8 +57,12 @@ export default class BlockSettings extends Module { * * @todo remove once BlockSettings becomes standalone non-module class */ - public get flipper(): Flipper { - return this.popover?.flipper; + public get flipper(): Flipper | undefined { + if (this.popover === null) { + return; + } + + return 'flipper' in this.popover ? this.popover?.flipper : undefined; } /** @@ -67,9 +72,9 @@ export default class BlockSettings extends Module { /** * Popover instance. There is a util for vertical lists. + * Null until popover is not initialized */ - private popover: Popover | undefined; - + private popover: Popover | null = null; /** * Panel with block settings with 2 sections: @@ -82,6 +87,8 @@ export default class BlockSettings extends Module { if (import.meta.env.MODE === 'test') { this.nodes.wrapper.setAttribute('data-cy', 'block-tunes'); } + + this.eventsDispatcher.on(EditorMobileLayoutToggled, this.close); } /** @@ -89,6 +96,8 @@ export default class BlockSettings extends Module { */ public destroy(): void { this.removeAllNodes(); + this.listeners.destroy(); + this.eventsDispatcher.off(EditorMobileLayoutToggled, this.close); } /** @@ -118,7 +127,10 @@ export default class BlockSettings extends Module { /** Tell to subscribers that block settings is opened */ this.eventsDispatcher.emit(this.events.opened); - this.popover = new Popover({ + + const PopoverClass = isMobileScreen() ? PopoverMobile : PopoverDesktop; + + this.popover = new PopoverClass({ searchable: true, items: tunesItems.map(tune => this.resolveTuneAliases(tune)), customContent: customHtmlTunesContainer, @@ -132,7 +144,7 @@ export default class BlockSettings extends Module { this.popover.on(PopoverEvent.Close, this.onPopoverClose); - this.nodes.wrapper.append(this.popover.getElement()); + this.nodes.wrapper?.append(this.popover.getElement()); this.popover.show(); } @@ -140,14 +152,14 @@ export default class BlockSettings extends Module { /** * Returns root block settings element */ - public getElement(): HTMLElement { + public getElement(): HTMLElement | undefined { return this.nodes.wrapper; } /** * Close Block Settings pane */ - public close(): void { + public close = (): void => { if (!this.opened) { return; } @@ -183,7 +195,7 @@ export default class BlockSettings extends Module { this.popover.getElement().remove(); this.popover = null; } - } + }; /** * Handles popover close event diff --git a/src/components/modules/toolbar/index.ts b/src/components/modules/toolbar/index.ts index aff4dc4fb..aaeceba87 100644 --- a/src/components/modules/toolbar/index.ts +++ b/src/components/modules/toolbar/index.ts @@ -220,6 +220,7 @@ export default class Toolbar extends Module { }; } + /** * Toggles read-only mode * @@ -479,9 +480,10 @@ export default class Toolbar extends Module { } }); - return this.toolboxInstance.make(); + return this.toolboxInstance.getElement(); } + /** * Handler for Plus Button */ diff --git a/src/components/modules/ui.ts b/src/components/modules/ui.ts index 70e6f2db7..d9b96e746 100644 --- a/src/components/modules/ui.ts +++ b/src/components/modules/ui.ts @@ -16,6 +16,7 @@ import { mobileScreenBreakpoint } from '../utils'; import styles from '../../styles/main.css?inline'; import { BlockHovered } from '../events/BlockHovered'; import { selectionChangeDebounceTimeout } from '../constants'; +import { EditorMobileLayoutToggled } from '../events'; /** * HTML Elements used for UI */ @@ -121,7 +122,7 @@ export default class UI extends Module { /** * Detect mobile version */ - this.checkIsMobile(); + this.setIsMobile(); /** * Make main UI elements @@ -234,10 +235,21 @@ export default class UI extends Module { } /** - * Check for mobile mode and cache a result + * Check for mobile mode and save the result */ - private checkIsMobile(): void { - this.isMobile = window.innerWidth < mobileScreenBreakpoint; + private setIsMobile(): void { + const isMobile = window.innerWidth < mobileScreenBreakpoint; + + if (isMobile !== this.isMobile) { + /** + * Dispatch global event + */ + this.eventsDispatcher.emit(EditorMobileLayoutToggled, { + isEnabled: this.isMobile, + }); + } + + this.isMobile = isMobile; } /** @@ -426,7 +438,7 @@ export default class UI extends Module { /** * Detect mobile version */ - this.checkIsMobile(); + this.setIsMobile(); } /** diff --git a/src/components/ui/toolbox.ts b/src/components/ui/toolbox.ts index 3984e36ce..60b25bf85 100644 --- a/src/components/ui/toolbox.ts +++ b/src/components/ui/toolbox.ts @@ -5,9 +5,13 @@ import BlockTool from '../tools/block'; import ToolsCollection from '../tools/collection'; import { API, BlockToolData, ToolboxConfigEntry, PopoverItem, BlockAPI } from '../../../types'; import EventsDispatcher from '../utils/events'; -import Popover, { PopoverEvent } from '../utils/popover'; import I18n from '../i18n'; import { I18nInternalNS } from '../i18n/namespace-internal'; +import { PopoverEvent } from '../utils/popover/popover.types'; +import Listeners from '../utils/listeners'; +import Dom from '../dom'; +import { Popover, PopoverDesktop, PopoverMobile } from '../utils/popover'; +import { EditorMobileLayoutToggled } from '../events'; /** * @todo the first Tab on the Block — focus Plus Button, the second — focus Block Tunes Toggler, the third — focus next Block @@ -75,6 +79,11 @@ export default class Toolbox extends EventsDispatcher { */ public opened = false; + /** + * Listeners util instance + */ + protected listeners: Listeners = new Listeners(); + /** * Editor API */ @@ -82,8 +91,9 @@ export default class Toolbox extends EventsDispatcher { /** * Popover instance. There is a util for vertical lists. + * Null until initialized */ - private popover: Popover | undefined; + private popover: Popover | null = null; /** * List of Tools available. Some of them will be shown in the Toolbox @@ -99,10 +109,8 @@ export default class Toolbox extends EventsDispatcher { * Current module HTML Elements */ private nodes: { - toolbox: HTMLElement | null; - } = { - toolbox: null, - }; + toolbox: HTMLElement; + } ; /** * CSS styles @@ -128,36 +136,26 @@ export default class Toolbox extends EventsDispatcher { this.api = api; this.tools = tools; this.i18nLabels = i18nLabels; - } - - /** - * Makes the Toolbox - */ - public make(): Element { - this.popover = new Popover({ - scopeElement: this.api.ui.nodes.redactor, - searchable: true, - messages: { - nothingFound: this.i18nLabels.nothingFound, - search: this.i18nLabels.filter, - }, - items: this.toolboxItemsToBeDisplayed, - }); - - this.popover.on(PopoverEvent.Close, this.onPopoverClose); - /** - * Enable tools shortcuts - */ this.enableShortcuts(); - this.nodes.toolbox = this.popover.getElement(); - this.nodes.toolbox.classList.add(Toolbox.CSS.toolbox); + this.nodes = { + toolbox: Dom.make('div', Toolbox.CSS.toolbox), + }; + + this.initPopover(); if (import.meta.env.MODE === 'test') { this.nodes.toolbox.setAttribute('data-cy', 'toolbox'); } + this.api.events.on(EditorMobileLayoutToggled, this.handleMobileLayoutToggle); + } + + /** + * Returns root block settings element + */ + public getElement(): HTMLElement | null { return this.nodes.toolbox; } @@ -165,7 +163,11 @@ export default class Toolbox extends EventsDispatcher { * Returns true if the Toolbox has the Flipper activated and the Flipper has selected button */ public hasFocus(): boolean | undefined { - return this.popover?.hasFocus(); + if (this.popover === null) { + return; + } + + return 'hasFocus' in this.popover ? this.popover.hasFocus() : undefined; } /** @@ -176,11 +178,12 @@ export default class Toolbox extends EventsDispatcher { if (this.nodes && this.nodes.toolbox) { this.nodes.toolbox.remove(); - this.nodes.toolbox = null; } this.removeAllShortcuts(); this.popover?.off(PopoverEvent.Close, this.onPopoverClose); + this.listeners.destroy(); + this.api.events.off(EditorMobileLayoutToggled, this.handleMobileLayoutToggle); } /** @@ -226,6 +229,50 @@ export default class Toolbox extends EventsDispatcher { } } + /** + * Destroys existing popover instance and contructs the new one. + */ + public handleMobileLayoutToggle = (): void => { + this.destroyPopover(); + this.initPopover(); + }; + + /** + * Creates toolbox popover and appends it inside wrapper element + */ + private initPopover(): void { + const PopoverClass = _.isMobileScreen() ? PopoverMobile : PopoverDesktop; + + this.popover = new PopoverClass({ + scopeElement: this.api.ui.nodes.redactor, + searchable: true, + messages: { + nothingFound: this.i18nLabels.nothingFound, + search: this.i18nLabels.filter, + }, + items: this.toolboxItemsToBeDisplayed, + }); + + this.popover.on(PopoverEvent.Close, this.onPopoverClose); + this.nodes.toolbox?.append(this.popover.getElement()); + } + + /** + * Destroys popover instance and removes it from DOM + */ + private destroyPopover(): void { + if (this.popover !== null) { + this.popover.hide(); + this.popover.off(PopoverEvent.Close, this.onPopoverClose); + this.popover.destroy(); + this.popover = null; + } + + if (this.nodes.toolbox !== null) { + this.nodes.toolbox.innerHTML = ''; + } + } + /** * Handles popover close event */ diff --git a/src/components/utils/bem.ts b/src/components/utils/bem.ts new file mode 100644 index 000000000..eea146d71 --- /dev/null +++ b/src/components/utils/bem.ts @@ -0,0 +1,25 @@ +const ELEMENT_DELIMITER = '__'; +const MODIFIER_DELIMITER = '--'; + +/** + * Utility function that allows to construct class names from block and element names + * + * @example bem('ce-popover)() -> 'ce-popover' + * @example bem('ce-popover)('container') -> 'ce-popover__container' + * @example bem('ce-popover)('container', 'hidden') -> 'ce-popover__container--hidden' + * @example bem('ce-popover)(null, 'hidden') -> 'ce-popover--hidden' + * @param blockName - string with block name + * @param elementName - string with element name + * @param modifier - modifier to be appended + */ +export function bem(blockName: string) { + return (elementName?: string, modifier?: string) => { + const className = [blockName, elementName] + .filter(x => !!x) + .join(ELEMENT_DELIMITER); + + return [className, modifier] + .filter(x => !!x) + .join(MODIFIER_DELIMITER); + }; +} diff --git a/src/components/utils/popover/components/popover-header/index.ts b/src/components/utils/popover/components/popover-header/index.ts new file mode 100644 index 000000000..49c0175a3 --- /dev/null +++ b/src/components/utils/popover/components/popover-header/index.ts @@ -0,0 +1,2 @@ +export * from './popover-header'; +export * from './popover-header.types'; diff --git a/src/components/utils/popover/components/popover-header/popover-header.const.ts b/src/components/utils/popover/components/popover-header/popover-header.const.ts new file mode 100644 index 000000000..f39097abc --- /dev/null +++ b/src/components/utils/popover/components/popover-header/popover-header.const.ts @@ -0,0 +1,15 @@ +import { bem } from '../../../bem'; + +/** + * Popover header block CSS class constructor + */ +const className = bem('ce-popover-header'); + +/** + * CSS class names to be used in popover header class + */ +export const css = { + root: className(), + text: className('text'), + backButton: className('back-button'), +}; diff --git a/src/components/utils/popover/components/popover-header/popover-header.ts b/src/components/utils/popover/components/popover-header/popover-header.ts new file mode 100644 index 000000000..edfe4e412 --- /dev/null +++ b/src/components/utils/popover/components/popover-header/popover-header.ts @@ -0,0 +1,71 @@ +import { PopoverHeaderParams } from './popover-header.types'; +import Dom from '../../../../dom'; +import { css } from './popover-header.const'; +import { IconChevronLeft } from '@codexteam/icons'; +import Listeners from '../../../listeners'; + +/** + * Represents popover header ui element + */ +export class PopoverHeader { + /** + * Listeners util instance + */ + private listeners = new Listeners(); + + /** + * Header html elements + */ + private nodes: { + root: HTMLElement, + text: HTMLElement, + backButton: HTMLElement + }; + + /** + * Text displayed inside header + */ + private readonly text: string; + + /** + * Back button click handler + */ + private readonly onBackButtonClick: () => void; + + /** + * Constructs the instance + * + * @param params - popover header params + */ + constructor({ text, onBackButtonClick }: PopoverHeaderParams) { + this.text = text; + this.onBackButtonClick = onBackButtonClick; + + this.nodes = { + root: Dom.make('div', [ css.root ]), + backButton: Dom.make('button', [ css.backButton ]), + text: Dom.make('div', [ css.text ]), + }; + this.nodes.backButton.innerHTML = IconChevronLeft; + this.nodes.root.appendChild(this.nodes.backButton); + this.listeners.on(this.nodes.backButton, 'click', this.onBackButtonClick); + + this.nodes.text.innerText = this.text; + this.nodes.root.appendChild(this.nodes.text); + } + + /** + * Returns popover header root html element + */ + public getElement(): HTMLElement | null { + return this.nodes.root; + } + + /** + * Destroys the instance + */ + public destroy(): void { + this.nodes.root.remove(); + this.listeners.destroy(); + } +} diff --git a/src/components/utils/popover/components/popover-header/popover-header.types.ts b/src/components/utils/popover/components/popover-header/popover-header.types.ts new file mode 100644 index 000000000..38697f5aa --- /dev/null +++ b/src/components/utils/popover/components/popover-header/popover-header.types.ts @@ -0,0 +1,14 @@ +/** + * Popover header params + */ +export interface PopoverHeaderParams { + /** + * Text to be displayed inside header + */ + text: string; + + /** + * Back button click handler + */ + onBackButtonClick: () => void; +} diff --git a/src/components/utils/popover/components/popover-item/index.ts b/src/components/utils/popover/components/popover-item/index.ts new file mode 100644 index 000000000..09b97e0d7 --- /dev/null +++ b/src/components/utils/popover/components/popover-item/index.ts @@ -0,0 +1,2 @@ +export * from './popover-item'; +export * from './popover-item.const'; diff --git a/src/components/utils/popover/components/popover-item/popover-item.const.ts b/src/components/utils/popover/components/popover-item/popover-item.const.ts new file mode 100644 index 000000000..515e0428c --- /dev/null +++ b/src/components/utils/popover/components/popover-item/popover-item.const.ts @@ -0,0 +1,26 @@ +import { bem } from '../../../bem'; + +/** + * Popover item block CSS class constructor + */ +const className = bem('ce-popover-item'); + +/** + * CSS class names to be used in popover item class + */ +export const css = { + container: className(), + active: className(null, 'active'), + disabled: className(null, 'disabled'), + focused: className(null, 'focused'), + hidden: className(null, 'hidden'), + confirmationState: className(null, 'confirmation'), + noHover: className(null, 'no-hover'), + noFocus: className(null, 'no-focus'), + title: className('title'), + secondaryTitle: className('secondary-title'), + icon: className('icon'), + iconTool: className('icon', 'tool'), + iconChevronRight: className('icon', 'chevron-right'), + wobbleAnimation: bem('wobble')(), +}; diff --git a/src/components/utils/popover/popover-item.ts b/src/components/utils/popover/components/popover-item/popover-item.ts similarity index 66% rename from src/components/utils/popover/popover-item.ts rename to src/components/utils/popover/components/popover-item/popover-item.ts index 9513a2c28..5c72669b8 100644 --- a/src/components/utils/popover/popover-item.ts +++ b/src/components/utils/popover/components/popover-item/popover-item.ts @@ -1,16 +1,21 @@ -import Dom from '../../dom'; -import { IconDotCircle } from '@codexteam/icons'; -import { PopoverItem as PopoverItemParams } from '../../../../types'; +import Dom from '../../../../dom'; +import { IconDotCircle, IconChevronRight } from '@codexteam/icons'; +import { PopoverItem as PopoverItemParams } from '../../../../../../types'; +import { css } from './popover-item.const'; /** * Represents sigle popover item node + * + * @todo move nodes initialization to constructor + * @todo replace multiple make() usages with constructing separate instaces + * @todo split regular popover item and popover item with confirmation to separate classes */ export class PopoverItem { /** * True if item is disabled and hence not clickable */ public get isDisabled(): boolean { - return this.params.isDisabled; + return this.params.isDisabled === true; } /** @@ -45,7 +50,11 @@ export class PopoverItem { * True if item is focused in keyboard navigation process */ public get isFocused(): boolean { - return this.nodes.root.classList.contains(PopoverItem.CSS.focused); + if (this.nodes.root === null) { + return false; + } + + return this.nodes.root.classList.contains(css.focused); } /** @@ -69,39 +78,6 @@ export class PopoverItem { */ private confirmationState: PopoverItemParams | null = null; - /** - * Popover item CSS classes - */ - public static get CSS(): { - container: string, - title: string, - secondaryTitle: string, - icon: string, - active: string, - disabled: string, - focused: string, - hidden: string, - confirmationState: string, - noHover: string, - noFocus: string, - wobbleAnimation: string - } { - return { - container: 'ce-popover-item', - title: 'ce-popover-item__title', - secondaryTitle: 'ce-popover-item__secondary-title', - icon: 'ce-popover-item__icon', - active: 'ce-popover-item--active', - disabled: 'ce-popover-item--disabled', - focused: 'ce-popover-item--focused', - hidden: 'ce-popover-item--hidden', - confirmationState: 'ce-popover-item--confirmation', - noHover: 'ce-popover-item--no-hover', - noFocus: 'ce-popover-item--no-focus', - wobbleAnimation: 'wobble', - }; - } - /** * Constructs popover item instance * @@ -115,7 +91,7 @@ export class PopoverItem { /** * Returns popover item root element */ - public getElement(): HTMLElement { + public getElement(): HTMLElement | null { return this.nodes.root; } @@ -123,7 +99,7 @@ export class PopoverItem { * Called on popover item click */ public handleClick(): void { - if (this.isConfirmationStateEnabled) { + if (this.isConfirmationStateEnabled && this.confirmationState !== null) { this.activateOrEnableConfirmationMode(this.confirmationState); return; @@ -138,7 +114,7 @@ export class PopoverItem { * @param isActive - true if item should strictly should become active */ public toggleActive(isActive?: boolean): void { - this.nodes.root.classList.toggle(PopoverItem.CSS.active, isActive); + this.nodes.root?.classList.toggle(css.active, isActive); } /** @@ -147,7 +123,7 @@ export class PopoverItem { * @param isHidden - true if item should be hidden */ public toggleHidden(isHidden: boolean): void { - this.nodes.root.classList.toggle(PopoverItem.CSS.hidden, isHidden); + this.nodes.root?.classList.toggle(css.hidden, isHidden); } /** @@ -166,40 +142,53 @@ export class PopoverItem { this.disableSpecialHoverAndFocusBehavior(); } + /** + * Returns list of item children + */ + public get children(): PopoverItemParams[] { + return 'children' in this.params && this.params.children?.items !== undefined ? this.params.children.items : []; + } + /** * Constructs HTML element corresponding to popover item params * * @param params - item construction params */ private make(params: PopoverItemParams): HTMLElement { - const el = Dom.make('div', PopoverItem.CSS.container); + const el = Dom.make('div', css.container); if (params.name) { el.dataset.itemName = params.name; } - this.nodes.icon = Dom.make('div', PopoverItem.CSS.icon, { + this.nodes.icon = Dom.make('div', [css.icon, css.iconTool], { innerHTML: params.icon || IconDotCircle, }); el.appendChild(this.nodes.icon); - el.appendChild(Dom.make('div', PopoverItem.CSS.title, { + el.appendChild(Dom.make('div', css.title, { innerHTML: params.title || '', })); if (params.secondaryLabel) { - el.appendChild(Dom.make('div', PopoverItem.CSS.secondaryTitle, { + el.appendChild(Dom.make('div', css.secondaryTitle, { textContent: params.secondaryLabel, })); } + if (this.children.length > 0) { + el.appendChild(Dom.make('div', [css.icon, css.iconChevronRight], { + innerHTML: IconChevronRight, + })); + } + if (params.isActive) { - el.classList.add(PopoverItem.CSS.active); + el.classList.add(css.active); } if (params.isDisabled) { - el.classList.add(PopoverItem.CSS.disabled); + el.classList.add(css.disabled); } return el; @@ -211,6 +200,10 @@ export class PopoverItem { * @param newState - new popover item params that should be applied */ private enableConfirmationMode(newState: PopoverItemParams): void { + if (this.nodes.root === null) { + return; + } + const params = { ...this.params, ...newState, @@ -219,7 +212,7 @@ export class PopoverItem { const confirmationEl = this.make(params); this.nodes.root.innerHTML = confirmationEl.innerHTML; - this.nodes.root.classList.add(PopoverItem.CSS.confirmationState); + this.nodes.root.classList.add(css.confirmationState); this.confirmationState = newState; @@ -230,10 +223,13 @@ export class PopoverItem { * Returns item to its original state */ private disableConfirmationMode(): void { + if (this.nodes.root === null) { + return; + } const itemWithOriginalParams = this.make(this.params); this.nodes.root.innerHTML = itemWithOriginalParams.innerHTML; - this.nodes.root.classList.remove(PopoverItem.CSS.confirmationState); + this.nodes.root.classList.remove(css.confirmationState); this.confirmationState = null; @@ -245,10 +241,10 @@ export class PopoverItem { * This is needed to prevent item from being highlighted as hovered/focused just after click. */ private enableSpecialHoverAndFocusBehavior(): void { - this.nodes.root.classList.add(PopoverItem.CSS.noHover); - this.nodes.root.classList.add(PopoverItem.CSS.noFocus); + this.nodes.root?.classList.add(css.noHover); + this.nodes.root?.classList.add(css.noFocus); - this.nodes.root.addEventListener('mouseleave', this.removeSpecialHoverBehavior, { once: true }); + this.nodes.root?.addEventListener('mouseleave', this.removeSpecialHoverBehavior, { once: true }); } /** @@ -258,21 +254,21 @@ export class PopoverItem { this.removeSpecialFocusBehavior(); this.removeSpecialHoverBehavior(); - this.nodes.root.removeEventListener('mouseleave', this.removeSpecialHoverBehavior); + this.nodes.root?.removeEventListener('mouseleave', this.removeSpecialHoverBehavior); } /** * Removes class responsible for special focus behavior on an item */ private removeSpecialFocusBehavior = (): void => { - this.nodes.root.classList.remove(PopoverItem.CSS.noFocus); + this.nodes.root?.classList.remove(css.noFocus); }; /** * Removes class responsible for special hover behavior on an item */ private removeSpecialHoverBehavior = (): void => { - this.nodes.root.classList.remove(PopoverItem.CSS.noHover); + this.nodes.root?.classList.remove(css.noHover); }; /** @@ -283,7 +279,7 @@ export class PopoverItem { private activateOrEnableConfirmationMode(item: PopoverItemParams): void { if (item.confirmation === undefined) { try { - item.onActivate(item); + item.onActivate?.(item); this.disableConfirmationMode(); } catch { this.animateError(); @@ -297,20 +293,20 @@ export class PopoverItem { * Animates item which symbolizes that error occured while executing 'onActivate()' callback */ private animateError(): void { - if (this.nodes.icon.classList.contains(PopoverItem.CSS.wobbleAnimation)) { + if (this.nodes.icon?.classList.contains(css.wobbleAnimation)) { return; } - this.nodes.icon.classList.add(PopoverItem.CSS.wobbleAnimation); + this.nodes.icon?.classList.add(css.wobbleAnimation); - this.nodes.icon.addEventListener('animationend', this.onErrorAnimationEnd); + this.nodes.icon?.addEventListener('animationend', this.onErrorAnimationEnd); } /** * Handles finish of error animation */ private onErrorAnimationEnd = (): void => { - this.nodes.icon.classList.remove(PopoverItem.CSS.wobbleAnimation); - this.nodes.icon.removeEventListener('animationend', this.onErrorAnimationEnd); + this.nodes.icon?.classList.remove(css.wobbleAnimation); + this.nodes.icon?.removeEventListener('animationend', this.onErrorAnimationEnd); }; } diff --git a/src/components/utils/popover/components/search-input/index.ts b/src/components/utils/popover/components/search-input/index.ts new file mode 100644 index 000000000..0b04671f7 --- /dev/null +++ b/src/components/utils/popover/components/search-input/index.ts @@ -0,0 +1,2 @@ +export * from './search-input'; +export * from './search-input.types'; diff --git a/src/components/utils/popover/components/search-input/search-input.const.ts b/src/components/utils/popover/components/search-input/search-input.const.ts new file mode 100644 index 000000000..531412c68 --- /dev/null +++ b/src/components/utils/popover/components/search-input/search-input.const.ts @@ -0,0 +1,15 @@ +import { bem } from '../../../bem'; + +/** + * Popover search input block CSS class constructor + */ +const className = bem('cdx-search-field'); + +/** + * CSS class names to be used in popover search input class + */ +export const css = { + wrapper: className(), + icon: className('icon'), + input: className('input'), +}; diff --git a/src/components/utils/popover/search-input.ts b/src/components/utils/popover/components/search-input/search-input.ts similarity index 72% rename from src/components/utils/popover/search-input.ts rename to src/components/utils/popover/components/search-input/search-input.ts index 6cf381bb3..49db1061a 100644 --- a/src/components/utils/popover/search-input.ts +++ b/src/components/utils/popover/components/search-input/search-input.ts @@ -1,18 +1,13 @@ -import Dom from '../../dom'; -import Listeners from '../listeners'; +import Dom from '../../../../dom'; +import Listeners from '../../../listeners'; import { IconSearch } from '@codexteam/icons'; - -/** - * Item that could be searched - */ -interface SearchableItem { - title?: string; -} +import { SearchableItem } from './search-input.types'; +import { css } from './search-input.const'; /** * Provides search input element and search logic */ -export default class SearchInput { +export class SearchInput { /** * Input wrapper element */ @@ -36,28 +31,13 @@ export default class SearchInput { /** * Current search query */ - private searchQuery: string; + private searchQuery: string | undefined; /** * Externally passed callback for the search */ private readonly onSearch: (query: string, items: SearchableItem[]) => void; - /** - * Styles - */ - private static get CSS(): { - input: string; - icon: string; - wrapper: string; - } { - return { - wrapper: 'cdx-search-field', - icon: 'cdx-search-field__icon', - input: 'cdx-search-field__input', - }; - } - /** * @param options - available config * @param options.items - searchable items list @@ -67,13 +47,37 @@ export default class SearchInput { constructor({ items, onSearch, placeholder }: { items: SearchableItem[]; onSearch: (query: string, items: SearchableItem[]) => void; - placeholder: string; + placeholder?: string; }) { this.listeners = new Listeners(); this.items = items; this.onSearch = onSearch; - this.render(placeholder); + /** Build ui */ + this.wrapper = Dom.make('div', css.wrapper); + + const iconWrapper = Dom.make('div', css.icon, { + innerHTML: IconSearch, + }); + + this.input = Dom.make('input', css.input, { + placeholder, + /** + * Used to prevent focusing on the input by Tab key + * (Popover in the Toolbar lays below the blocks, + * so Tab in the last block will focus this hidden input if this property is not set) + */ + tabIndex: -1, + }) as HTMLInputElement; + + this.wrapper.appendChild(iconWrapper); + this.wrapper.appendChild(this.input); + + this.listeners.on(this.input, 'input', () => { + this.searchQuery = this.input.value; + + this.onSearch(this.searchQuery, this.foundItems); + }); } /** @@ -96,6 +100,7 @@ export default class SearchInput { public clear(): void { this.input.value = ''; this.searchQuery = ''; + this.onSearch('', this.foundItems); } @@ -106,38 +111,6 @@ export default class SearchInput { this.listeners.removeAll(); } - /** - * Creates the search field - * - * @param placeholder - input placeholder - */ - private render(placeholder: string): void { - this.wrapper = Dom.make('div', SearchInput.CSS.wrapper); - - const iconWrapper = Dom.make('div', SearchInput.CSS.icon, { - innerHTML: IconSearch, - }); - - this.input = Dom.make('input', SearchInput.CSS.input, { - placeholder, - /** - * Used to prevent focusing on the input by Tab key - * (Popover in the Toolbar lays below the blocks, - * so Tab in the last block will focus this hidden input if this property is not set) - */ - tabIndex: -1, - }) as HTMLInputElement; - - this.wrapper.appendChild(iconWrapper); - this.wrapper.appendChild(this.input); - - this.listeners.on(this.input, 'input', () => { - this.searchQuery = this.input.value; - - this.onSearch(this.searchQuery, this.foundItems); - }); - } - /** * Returns list of found items for the current search query */ @@ -152,8 +125,8 @@ export default class SearchInput { */ private checkItem(item: SearchableItem): boolean { const text = item.title?.toLowerCase() || ''; - const query = this.searchQuery.toLowerCase(); + const query = this.searchQuery?.toLowerCase(); - return text.includes(query); + return query !== undefined ? text.includes(query) : false; } } diff --git a/src/components/utils/popover/components/search-input/search-input.types.ts b/src/components/utils/popover/components/search-input/search-input.types.ts new file mode 100644 index 000000000..bbe78f8f5 --- /dev/null +++ b/src/components/utils/popover/components/search-input/search-input.types.ts @@ -0,0 +1,9 @@ +/** + * Item that could be searched + */ +export interface SearchableItem { + /** + * Items title + */ + title?: string; +} diff --git a/src/components/utils/popover/index.ts b/src/components/utils/popover/index.ts index 34b483b3d..6299dee92 100644 --- a/src/components/utils/popover/index.ts +++ b/src/components/utils/popover/index.ts @@ -1,525 +1,10 @@ -import { PopoverItem } from './popover-item'; -import Dom from '../../dom'; -import { cacheable, keyCodes, isMobileScreen } from '../../utils'; -import Flipper from '../../flipper'; -import { PopoverItem as PopoverItemParams } from '../../../../types'; -import SearchInput from './search-input'; -import EventsDispatcher from '../events'; -import Listeners from '../listeners'; -import ScrollLocker from '../scroll-locker'; +import { PopoverDesktop } from './popover-desktop'; +import { PopoverMobile } from './popover-mobile'; +export * from './popover.types'; /** - * Params required to render popover + * Union type for all popovers */ -interface PopoverParams { - /** - * Popover items config - */ - items: PopoverItemParams[]; +export type Popover = PopoverDesktop | PopoverMobile; - /** - * Element of the page that creates 'scope' of the popover - */ - scopeElement?: HTMLElement; - - /** - * Arbitrary html element to be inserted before items list - */ - customContent?: HTMLElement; - - /** - * List of html elements inside custom content area that should be available for keyboard navigation - */ - customContentFlippableItems?: HTMLElement[]; - - /** - * True if popover should contain search field - */ - searchable?: boolean; - - /** - * Popover texts overrides - */ - messages?: PopoverMessages -} - -/** - * Texts used inside popover - */ -interface PopoverMessages { - /** Text displayed when search has no results */ - nothingFound?: string; - - /** Search input label */ - search?: string -} - -/** - * Event that can be triggered by the Popover - */ -export enum PopoverEvent { - /** - * When popover closes - */ - Close = 'close' -} - -/** - * Events fired by the Popover - */ -interface PopoverEventMap { - [PopoverEvent.Close]: undefined; -} - - -/** - * Class responsible for rendering popover and handling its behaviour - */ -export default class Popover extends EventsDispatcher { - /** - * Flipper - module for keyboard iteration between elements - */ - public flipper: Flipper; - - /** - * List of popover items - */ - private items: PopoverItem[]; - - /** - * Element of the page that creates 'scope' of the popover. - * If possible, popover will not cross specified element's borders when opening. - */ - private scopeElement: HTMLElement = document.body; - - /** - * List of html elements inside custom content area that should be available for keyboard navigation - */ - private customContentFlippableItems: HTMLElement[] | undefined; - - /** - * Instance of the Search Input - */ - private search: SearchInput | undefined; - - /** - * Listeners util instance - */ - private listeners: Listeners = new Listeners(); - - /** - * ScrollLocker instance - */ - private scrollLocker = new ScrollLocker(); - - /** - * Popover CSS classes - */ - private static get CSS(): { - popover: string; - popoverOpenTop: string; - popoverOpened: string; - search: string; - nothingFoundMessage: string; - nothingFoundMessageDisplayed: string; - customContent: string; - customContentHidden: string; - items: string; - overlay: string; - overlayHidden: string; - } { - return { - popover: 'ce-popover', - popoverOpenTop: 'ce-popover--open-top', - popoverOpened: 'ce-popover--opened', - search: 'ce-popover__search', - nothingFoundMessage: 'ce-popover__nothing-found-message', - nothingFoundMessageDisplayed: 'ce-popover__nothing-found-message--displayed', - customContent: 'ce-popover__custom-content', - customContentHidden: 'ce-popover__custom-content--hidden', - items: 'ce-popover__items', - overlay: 'ce-popover__overlay', - overlayHidden: 'ce-popover__overlay--hidden', - }; - } - - /** - * Refs to created HTML elements - */ - private nodes: { - wrapper: HTMLElement | null; - popover: HTMLElement | null; - nothingFoundMessage: HTMLElement | null; - customContent: HTMLElement | null; - items: HTMLElement | null; - overlay: HTMLElement | null; - } = { - wrapper: null, - popover: null, - nothingFoundMessage: null, - customContent: null, - items: null, - overlay: null, - }; - - /** - * Messages that will be displayed in popover - */ - private messages: PopoverMessages = { - nothingFound: 'Nothing found', - search: 'Search', - }; - - /** - * Constructs the instance - * - * @param params - popover construction params - */ - constructor(params: PopoverParams) { - super(); - - this.items = params.items.map(item => new PopoverItem(item)); - - if (params.scopeElement !== undefined) { - this.scopeElement = params.scopeElement; - } - - if (params.messages) { - this.messages = { - ...this.messages, - ...params.messages, - }; - } - - if (params.customContentFlippableItems) { - this.customContentFlippableItems = params.customContentFlippableItems; - } - - this.make(); - - if (params.customContent) { - this.addCustomContent(params.customContent); - } - - if (params.searchable) { - this.addSearch(); - } - - - this.initializeFlipper(); - } - - /** - * Returns HTML element corresponding to the popover - */ - public getElement(): HTMLElement { - return this.nodes.wrapper as HTMLElement; - } - - /** - * Returns true if some item inside popover is focused - */ - public hasFocus(): boolean { - return this.flipper.hasFocus(); - } - - /** - * Open popover - */ - public show(): void { - if (!this.shouldOpenBottom) { - this.nodes.popover.style.setProperty('--popover-height', this.height + 'px'); - this.nodes.popover.classList.add(Popover.CSS.popoverOpenTop); - } - - this.nodes.overlay.classList.remove(Popover.CSS.overlayHidden); - this.nodes.popover.classList.add(Popover.CSS.popoverOpened); - this.flipper.activate(this.flippableElements); - - if (this.search !== undefined) { - this.search?.focus(); - } - - if (isMobileScreen()) { - this.scrollLocker.lock(); - } - } - - /** - * Closes popover - */ - public hide(): void { - this.nodes.popover.classList.remove(Popover.CSS.popoverOpened); - this.nodes.popover.classList.remove(Popover.CSS.popoverOpenTop); - this.nodes.overlay.classList.add(Popover.CSS.overlayHidden); - this.flipper.deactivate(); - this.items.forEach(item => item.reset()); - - if (this.search !== undefined) { - this.search.clear(); - } - - if (isMobileScreen()) { - this.scrollLocker.unlock(); - } - - this.emit(PopoverEvent.Close); - } - - /** - * Clears memory - */ - public destroy(): void { - this.flipper.deactivate(); - this.listeners.removeAll(); - - if (isMobileScreen()) { - this.scrollLocker.unlock(); - } - } - - /** - * Constructs HTML element corresponding to popover - */ - private make(): void { - this.nodes.popover = Dom.make('div', [ Popover.CSS.popover ]); - - this.nodes.nothingFoundMessage = Dom.make('div', [ Popover.CSS.nothingFoundMessage ], { - textContent: this.messages.nothingFound, - }); - - this.nodes.popover.appendChild(this.nodes.nothingFoundMessage); - this.nodes.items = Dom.make('div', [ Popover.CSS.items ]); - - this.items.forEach(item => { - this.nodes.items.appendChild(item.getElement()); - }); - - this.nodes.popover.appendChild(this.nodes.items); - - this.listeners.on(this.nodes.popover, 'click', (event: PointerEvent) => { - const item = this.getTargetItem(event); - - if (item === undefined) { - return; - } - - this.handleItemClick(item); - }); - - this.nodes.wrapper = Dom.make('div'); - this.nodes.overlay = Dom.make('div', [Popover.CSS.overlay, Popover.CSS.overlayHidden]); - - this.listeners.on(this.nodes.overlay, 'click', () => { - this.hide(); - }); - - this.nodes.wrapper.appendChild(this.nodes.overlay); - this.nodes.wrapper.appendChild(this.nodes.popover); - } - - /** - * Adds search to the popover - */ - private addSearch(): void { - this.search = new SearchInput({ - items: this.items, - placeholder: this.messages.search, - onSearch: (query: string, result: PopoverItem[]): void => { - this.items.forEach(item => { - const isHidden = !result.includes(item); - - item.toggleHidden(isHidden); - }); - this.toggleNothingFoundMessage(result.length === 0); - this.toggleCustomContent(query !== ''); - - /** List of elements available for keyboard navigation considering search query applied */ - const flippableElements = query === '' ? this.flippableElements : result.map(item => item.getElement()); - - if (this.flipper.isActivated) { - /** Update flipper items with only visible */ - this.flipper.deactivate(); - this.flipper.activate(flippableElements); - } - }, - }); - - const searchElement = this.search.getElement(); - - searchElement.classList.add(Popover.CSS.search); - - this.nodes.popover.insertBefore(searchElement, this.nodes.popover.firstChild); - } - - /** - * Adds custom html content to the popover - * - * @param content - html content to append - */ - private addCustomContent(content: HTMLElement): void { - this.nodes.customContent = content; - this.nodes.customContent.classList.add(Popover.CSS.customContent); - this.nodes.popover.insertBefore(content, this.nodes.popover.firstChild); - } - - /** - * Retrieves popover item that is the target of the specified event - * - * @param event - event to retrieve popover item from - */ - private getTargetItem(event: PointerEvent): PopoverItem | undefined { - return this.items.find(el => event.composedPath().includes(el.getElement())); - } - - /** - * Handles item clicks - * - * @param item - item to handle click of - */ - private handleItemClick(item: PopoverItem): void { - if (item.isDisabled) { - return; - } - - /** Cleanup other items state */ - this.items.filter(x => x !== item).forEach(x => x.reset()); - - item.handleClick(); - - this.toggleItemActivenessIfNeeded(item); - - if (item.closeOnActivate) { - this.hide(); - } - } - - /** - * Creates Flipper instance which allows to navigate between popover items via keyboard - */ - private initializeFlipper(): void { - this.flipper = new Flipper({ - items: this.flippableElements, - focusedItemClass: PopoverItem.CSS.focused, - allowedKeys: [ - keyCodes.TAB, - keyCodes.UP, - keyCodes.DOWN, - keyCodes.ENTER, - ], - }); - - this.flipper.onFlip(this.onFlip); - } - - /** - * Returns list of elements available for keyboard navigation. - * Contains both usual popover items elements and custom html content. - */ - private get flippableElements(): HTMLElement[] { - const popoverItemsElements = this.items.map(item => item.getElement()); - const customContentControlsElements = this.customContentFlippableItems || []; - - /** - * Combine elements inside custom content area with popover items elements - */ - return customContentControlsElements.concat(popoverItemsElements); - } - - /** - * Helps to calculate height of popover while it is not displayed on screen. - * Renders invisible clone of popover to get actual height. - */ - @cacheable - private get height(): number { - let height = 0; - - if (this.nodes.popover === null) { - return height; - } - - const popoverClone = this.nodes.popover.cloneNode(true) as HTMLElement; - - popoverClone.style.visibility = 'hidden'; - popoverClone.style.position = 'absolute'; - popoverClone.style.top = '-1000px'; - popoverClone.classList.add(Popover.CSS.popoverOpened); - document.body.appendChild(popoverClone); - height = popoverClone.offsetHeight; - popoverClone.remove(); - - return height; - } - - /** - * Checks if popover should be opened bottom. - * It should happen when there is enough space below or not enough space above - */ - private get shouldOpenBottom(): boolean { - const popoverRect = this.nodes.popover.getBoundingClientRect(); - const scopeElementRect = this.scopeElement.getBoundingClientRect(); - const popoverHeight = this.height; - const popoverPotentialBottomEdge = popoverRect.top + popoverHeight; - const popoverPotentialTopEdge = popoverRect.top - popoverHeight; - const bottomEdgeForComparison = Math.min(window.innerHeight, scopeElementRect.bottom); - - return popoverPotentialTopEdge < scopeElementRect.top || popoverPotentialBottomEdge <= bottomEdgeForComparison; - } - - /** - * Called on flipper navigation - */ - private onFlip = (): void => { - const focusedItem = this.items.find(item => item.isFocused); - - focusedItem.onFocus(); - }; - - /** - * Toggles nothing found message visibility - * - * @param isDisplayed - true if the message should be displayed - */ - private toggleNothingFoundMessage(isDisplayed: boolean): void { - this.nodes.nothingFoundMessage.classList.toggle(Popover.CSS.nothingFoundMessageDisplayed, isDisplayed); - } - - /** - * Toggles custom content visibility - * - * @param isDisplayed - true if custom content should be displayed - */ - private toggleCustomContent(isDisplayed: boolean): void { - this.nodes.customContent?.classList.toggle(Popover.CSS.customContentHidden, isDisplayed); - } - - /** - * - Toggles item active state, if clicked popover item has property 'toggle' set to true. - * - * - Performs radiobutton-like behavior if the item has property 'toggle' set to string key. - * (All the other items with the same key get inactive, and the item gets active) - * - * @param clickedItem - popover item that was clicked - */ - private toggleItemActivenessIfNeeded(clickedItem: PopoverItem): void { - if (clickedItem.toggle === true) { - clickedItem.toggleActive(); - } - - if (typeof clickedItem.toggle === 'string') { - const itemsInToggleGroup = this.items.filter(item => item.toggle === clickedItem.toggle); - - /** If there's only one item in toggle group, toggle it */ - if (itemsInToggleGroup.length === 1) { - clickedItem.toggleActive(); - - return; - } - - /** Set clicked item as active and the rest items with same toggle key value as inactive */ - itemsInToggleGroup.forEach(item => { - item.toggleActive(item === clickedItem); - }); - } - } -} +export { PopoverDesktop, PopoverMobile }; diff --git a/src/components/utils/popover/popover-abstract.ts b/src/components/utils/popover/popover-abstract.ts new file mode 100644 index 000000000..c97b08d2e --- /dev/null +++ b/src/components/utils/popover/popover-abstract.ts @@ -0,0 +1,291 @@ +import { PopoverItem } from './components/popover-item'; +import Dom from '../../dom'; +import { SearchInput, SearchableItem } from './components/search-input'; +import EventsDispatcher from '../events'; +import Listeners from '../listeners'; +import { PopoverEventMap, PopoverMessages, PopoverParams, PopoverEvent, PopoverNodes } from './popover.types'; +import { css } from './popover.const'; + +/** + * Class responsible for rendering popover and handling its behaviour + */ +export abstract class PopoverAbstract extends EventsDispatcher { + /** + * List of popover items + */ + protected items: PopoverItem[]; + + /** + * Listeners util instance + */ + protected listeners: Listeners = new Listeners(); + + /** + * Refs to created HTML elements + */ + protected nodes: Nodes; + + /** + * Instance of the Search Input + */ + private search: SearchInput | undefined; + + /** + * Messages that will be displayed in popover + */ + private messages: PopoverMessages = { + nothingFound: 'Nothing found', + search: 'Search', + }; + + /** + * Constructs the instance + * + * @param params - popover construction params + */ + constructor(protected readonly params: PopoverParams) { + super(); + + this.items = params.items.map(item => new PopoverItem(item)); + + if (params.messages) { + this.messages = { + ...this.messages, + ...params.messages, + }; + } + + /** Build html elements */ + this.nodes = {} as Nodes; + + this.nodes.popoverContainer = Dom.make('div', [ css.popoverContainer ]); + + this.nodes.nothingFoundMessage = Dom.make('div', [ css.nothingFoundMessage ], { + textContent: this.messages.nothingFound, + }); + + this.nodes.popoverContainer.appendChild(this.nodes.nothingFoundMessage); + this.nodes.items = Dom.make('div', [ css.items ]); + + this.items.forEach(item => { + const itemEl = item.getElement(); + + if (itemEl === null) { + return; + } + + this.nodes.items.appendChild(itemEl); + }); + + this.nodes.popoverContainer.appendChild(this.nodes.items); + + this.listeners.on(this.nodes.popoverContainer, 'click', (event: Event) => this.handleClick(event)); + + this.nodes.popover = Dom.make('div', [ + css.popover, + this.params.class, + ]); + + this.nodes.popover.appendChild(this.nodes.popoverContainer); + + if (params.customContent) { + this.addCustomContent(params.customContent); + } + + if (params.searchable) { + this.addSearch(); + } + } + + /** + * Returns HTML element corresponding to the popover + */ + public getElement(): HTMLElement { + return this.nodes.popover as HTMLElement; + } + + /** + * Open popover + */ + public show(): void { + this.nodes.popover.classList.add(css.popoverOpened); + + if (this.search !== undefined) { + this.search.focus(); + } + } + + /** + * Closes popover + */ + public hide(): void { + this.nodes.popover.classList.remove(css.popoverOpened); + this.nodes.popover.classList.remove(css.popoverOpenTop); + + this.items.forEach(item => item.reset()); + + if (this.search !== undefined) { + this.search.clear(); + } + + this.emit(PopoverEvent.Close); + } + + /** + * Clears memory + */ + public destroy(): void { + this.listeners.removeAll(); + } + + /** + * Handles input inside search field + * + * @param query - search query text + * @param result - search results + */ + protected onSearch = (query: string, result: SearchableItem[]): void => { + this.items.forEach(item => { + const isHidden = !result.includes(item); + + item.toggleHidden(isHidden); + }); + this.toggleNothingFoundMessage(result.length === 0); + this.toggleCustomContent(query !== ''); + }; + + + /** + * Retrieves popover item that is the target of the specified event + * + * @param event - event to retrieve popover item from + */ + protected getTargetItem(event: Event): PopoverItem | undefined { + return this.items.find(el => { + const itemEl = el.getElement(); + + if (itemEl === null) { + return false; + } + + return event.composedPath().includes(itemEl); + }); + } + + /** + * Adds search to the popover + */ + private addSearch(): void { + this.search = new SearchInput({ + items: this.items, + placeholder: this.messages.search, + onSearch: this.onSearch, + }); + + const searchElement = this.search.getElement(); + + searchElement.classList.add(css.search); + + this.nodes.popoverContainer.insertBefore(searchElement, this.nodes.popoverContainer.firstChild); + } + + /** + * Adds custom html content to the popover + * + * @param content - html content to append + */ + private addCustomContent(content: HTMLElement): void { + this.nodes.customContent = content; + this.nodes.customContent.classList.add(css.customContent); + this.nodes.popoverContainer.insertBefore(content, this.nodes.popoverContainer.firstChild); + } + + /** + * Handles clicks inside popover + * + * @param event - item to handle click of + */ + private handleClick(event: Event): void { + const item = this.getTargetItem(event); + + if (item === undefined) { + return; + } + + if (item.isDisabled) { + return; + } + + if (item.children.length > 0) { + this.showNestedItems(item); + + return; + } + + /** Cleanup other items state */ + this.items.filter(x => x !== item).forEach(x => x.reset()); + + item.handleClick(); + + this.toggleItemActivenessIfNeeded(item); + + if (item.closeOnActivate) { + this.hide(); + } + } + + /** + * Toggles nothing found message visibility + * + * @param isDisplayed - true if the message should be displayed + */ + private toggleNothingFoundMessage(isDisplayed: boolean): void { + this.nodes.nothingFoundMessage.classList.toggle(css.nothingFoundMessageDisplayed, isDisplayed); + } + + /** + * Toggles custom content visibility + * + * @param isDisplayed - true if custom content should be displayed + */ + private toggleCustomContent(isDisplayed: boolean): void { + this.nodes.customContent?.classList.toggle(css.customContentHidden, isDisplayed); + } + + /** + * - Toggles item active state, if clicked popover item has property 'toggle' set to true. + * + * - Performs radiobutton-like behavior if the item has property 'toggle' set to string key. + * (All the other items with the same key get inactive, and the item gets active) + * + * @param clickedItem - popover item that was clicked + */ + private toggleItemActivenessIfNeeded(clickedItem: PopoverItem): void { + if (clickedItem.toggle === true) { + clickedItem.toggleActive(); + } + + if (typeof clickedItem.toggle === 'string') { + const itemsInToggleGroup = this.items.filter(item => item.toggle === clickedItem.toggle); + + /** If there's only one item in toggle group, toggle it */ + if (itemsInToggleGroup.length === 1) { + clickedItem.toggleActive(); + + return; + } + + /** Set clicked item as active and the rest items with same toggle key value as inactive */ + itemsInToggleGroup.forEach(item => { + item.toggleActive(item === clickedItem); + }); + } + } + + /** + * Handles displaying nested items for the item. Behaviour differs depending on platform. + * + * @param item – item to show nested popover for + */ + protected abstract showNestedItems(item: PopoverItem): void; +} diff --git a/src/components/utils/popover/popover-desktop.ts b/src/components/utils/popover/popover-desktop.ts new file mode 100644 index 000000000..df3373494 --- /dev/null +++ b/src/components/utils/popover/popover-desktop.ts @@ -0,0 +1,356 @@ +import Flipper from '../../flipper'; +import { PopoverAbstract } from './popover-abstract'; +import { PopoverItem, css as popoverItemCls } from './components/popover-item'; +import { PopoverParams } from './popover.types'; +import { keyCodes } from '../../utils'; +import { css } from './popover.const'; +import { SearchableItem } from './components/search-input'; +import { cacheable } from '../../utils'; + +/** + * Desktop popover. + * On desktop devices popover behaves like a floating element. Nested popover appears at right or left side. + */ +export class PopoverDesktop extends PopoverAbstract { + /** + * Flipper - module for keyboard iteration between elements + */ + public flipper: Flipper; + + /** + * List of html elements inside custom content area that should be available for keyboard navigation + */ + private customContentFlippableItems: HTMLElement[] | undefined; + + /** + * Reference to nested popover if exists. + * Undefined by default, PopoverDesktop when exists and null after destroyed. + */ + private nestedPopover: PopoverDesktop | undefined | null; + + /** + * Last hovered item inside popover. + * Is used to determine if cursor is moving inside one item or already moved away to another one. + * Helps prevent reopening nested popover while cursor is moving inside one item area. + */ + private previouslyHoveredItem: PopoverItem | null = null; + + /** + * Popover nesting level. 0 value means that it is a root popover + */ + private nestingLevel = 0; + + /** + * Element of the page that creates 'scope' of the popover. + * If possible, popover will not cross specified element's borders when opening. + */ + private scopeElement: HTMLElement = document.body; + + /** + * Construct the instance + * + * @param params - popover params + */ + constructor(params: PopoverParams) { + super(params); + + if (params.nestingLevel !== undefined) { + this.nestingLevel = params.nestingLevel; + } + + if (this.nestingLevel > 0) { + this.nodes.popover.classList.add(css.popoverNested); + } + + if (params.customContentFlippableItems) { + this.customContentFlippableItems = params.customContentFlippableItems; + } + + if (params.scopeElement !== undefined) { + this.scopeElement = params.scopeElement; + } + + if (this.nodes.popoverContainer !== null) { + this.listeners.on(this.nodes.popoverContainer, 'mouseover', (event: Event) => this.handleHover(event)); + } + + this.flipper = new Flipper({ + items: this.flippableElements, + focusedItemClass: popoverItemCls.focused, + allowedKeys: [ + keyCodes.TAB, + keyCodes.UP, + keyCodes.DOWN, + keyCodes.ENTER, + ], + }); + + this.flipper.onFlip(this.onFlip); + } + + /** + * Returns true if some item inside popover is focused + */ + public hasFocus(): boolean { + if (this.flipper === undefined) { + return false; + } + + return this.flipper.hasFocus(); + } + + /** + * Scroll position inside items container of the popover + */ + public get scrollTop(): number { + if (this.nodes.items === null) { + return 0; + } + + return this.nodes.items.scrollTop; + } + + /** + * Returns visible element offset top + */ + public get offsetTop(): number { + if (this.nodes.popoverContainer === null) { + return 0; + } + + return this.nodes.popoverContainer.offsetTop; + } + + /** + * Open popover + */ + public show(): void { + this.nodes.popover.style.setProperty('--popover-height', this.size.height + 'px'); + + if (!this.shouldOpenBottom) { + this.nodes.popover.classList.add(css.popoverOpenTop); + } + + if (!this.shouldOpenRight) { + this.nodes.popover.classList.add(css.popoverOpenLeft); + } + + super.show(); + this.flipper.activate(this.flippableElements); + } + + /** + * Closes popover + */ + public hide(): void { + super.hide(); + + this.flipper.deactivate(); + + this.destroyNestedPopoverIfExists(); + + this.previouslyHoveredItem = null; + } + + /** + * Clears memory + */ + public destroy(): void { + this.hide(); + super.destroy(); + } + + /** + * Handles input inside search field + * + * @param query - search query text + * @param result - search results + */ + protected override onSearch = (query: string, result: SearchableItem[]): void => { + super.onSearch(query, result); + + /** List of elements available for keyboard navigation considering search query applied */ + const flippableElements = query === '' ? this.flippableElements : result.map(item => (item as PopoverItem).getElement()); + + if (this.flipper.isActivated) { + /** Update flipper items with only visible */ + this.flipper.deactivate(); + this.flipper.activate(flippableElements as HTMLElement[]); + } + }; + + /** + * Handles displaying nested items for the item. + * + * @param item – item to show nested popover for + */ + protected override showNestedItems(item: PopoverItem): void { + if (this.nestedPopover !== null && this.nestedPopover !== undefined) { + return; + } + this.showNestedPopoverForItem(item); + } + + /** + * Checks if popover should be opened bottom. + * It should happen when there is enough space below or not enough space above + */ + private get shouldOpenBottom(): boolean { + if (this.nodes.popover === undefined || this.nodes.popover === null) { + return false; + } + const popoverRect = this.nodes.popoverContainer.getBoundingClientRect(); + const scopeElementRect = this.scopeElement.getBoundingClientRect(); + const popoverHeight = this.size.height; + const popoverPotentialBottomEdge = popoverRect.top + popoverHeight; + const popoverPotentialTopEdge = popoverRect.top - popoverHeight; + const bottomEdgeForComparison = Math.min(window.innerHeight, scopeElementRect.bottom); + + return popoverPotentialTopEdge < scopeElementRect.top || popoverPotentialBottomEdge <= bottomEdgeForComparison; + } + + /** + * Checks if popover should be opened left. + * It should happen when there is enough space in the right or not enough space in the left + */ + private get shouldOpenRight(): boolean { + if (this.nodes.popover === undefined || this.nodes.popover === null) { + return false; + } + + const popoverRect = this.nodes.popover.getBoundingClientRect(); + const scopeElementRect = this.scopeElement.getBoundingClientRect(); + const popoverWidth = this.size.width; + const popoverPotentialRightEdge = popoverRect.right + popoverWidth; + const popoverPotentialLeftEdge = popoverRect.left - popoverWidth; + const rightEdgeForComparison = Math.min(window.innerWidth, scopeElementRect.right); + + return popoverPotentialLeftEdge < scopeElementRect.left || popoverPotentialRightEdge <= rightEdgeForComparison; + } + + /** + * Helps to calculate size of popover while it is not displayed on screen. + * Renders invisible clone of popover to get actual size. + */ + @cacheable + private get size(): {height: number; width: number} { + const size = { + height: 0, + width: 0, + }; + + if (this.nodes.popover === null) { + return size; + } + + const popoverClone = this.nodes.popover.cloneNode(true) as HTMLElement; + + popoverClone.style.visibility = 'hidden'; + popoverClone.style.position = 'absolute'; + popoverClone.style.top = '-1000px'; + + popoverClone.classList.add(css.popoverOpened); + popoverClone.querySelector('.' + css.popoverNested)?.remove(); + document.body.appendChild(popoverClone); + + const container = popoverClone.querySelector('.' + css.popoverContainer) as HTMLElement; + + size.height = container.offsetHeight; + size.width = container.offsetWidth; + + popoverClone.remove(); + + return size; + } + + /** + * Destroys existing nested popover + */ + private destroyNestedPopoverIfExists(): void { + if (this.nestedPopover === undefined || this.nestedPopover === null) { + return; + } + + this.nestedPopover.hide(); + this.nestedPopover.destroy(); + this.nestedPopover.getElement().remove(); + this.nestedPopover = null; + this.flipper.activate(this.flippableElements); + } + + /** + * Returns list of elements available for keyboard navigation. + * Contains both usual popover items elements and custom html content. + */ + private get flippableElements(): HTMLElement[] { + const popoverItemsElements = this.items.map(item => item.getElement()); + const customContentControlsElements = this.customContentFlippableItems || []; + + /** + * Combine elements inside custom content area with popover items elements + */ + return customContentControlsElements.concat(popoverItemsElements as HTMLElement[]); + } + + /** + * Called on flipper navigation + */ + private onFlip = (): void => { + const focusedItem = this.items.find(item => item.isFocused); + + focusedItem?.onFocus(); + }; + + /** + * Creates and displays nested popover for specified item. + * Is used only on desktop + * + * @param item - item to display nested popover by + */ + private showNestedPopoverForItem(item: PopoverItem): void { + this.nestedPopover = new PopoverDesktop({ + items: item.children, + nestingLevel: this.nestingLevel + 1, + }); + + const nestedPopoverEl = this.nestedPopover.getElement(); + + this.nodes.popover.appendChild(nestedPopoverEl); + const itemEl = item.getElement(); + const itemOffsetTop = (itemEl ? itemEl.offsetTop : 0) - this.scrollTop; + const topOffset = this.offsetTop + itemOffsetTop; + + nestedPopoverEl.style.setProperty('--trigger-item-top', topOffset + 'px'); + nestedPopoverEl.style.setProperty('--nesting-level', this.nestedPopover.nestingLevel.toString()); + + this.nestedPopover.show(); + this.flipper.deactivate(); + } + + /** + * Handles hover events inside popover items container + * + * @param event - hover event data + */ + private handleHover(event: Event): void { + const item = this.getTargetItem(event); + + if (item === undefined) { + return; + } + + if (this.previouslyHoveredItem === item) { + return; + } + + this.destroyNestedPopoverIfExists(); + + this.previouslyHoveredItem = item; + + if (item.children.length === 0) { + return; + } + + this.showNestedPopoverForItem(item); + } +} diff --git a/src/components/utils/popover/popover-mobile.ts b/src/components/utils/popover/popover-mobile.ts new file mode 100644 index 000000000..ac0e7ae1d --- /dev/null +++ b/src/components/utils/popover/popover-mobile.ts @@ -0,0 +1,142 @@ +import { PopoverAbstract } from './popover-abstract'; +import ScrollLocker from '../scroll-locker'; +import { PopoverHeader } from './components/popover-header'; +import { PopoverStatesHistory } from './utils/popover-states-history'; +import { PopoverMobileNodes, PopoverParams } from './popover.types'; +import { PopoverItem } from './components/popover-item'; +import { PopoverItem as PopoverItemParams } from '../../../../types'; +import { css } from './popover.const'; +import Dom from '../../dom'; + +/** + * Mobile Popover. + * On mobile devices Popover behaves like a fixed panel at the bottom of screen. Nested item appears like "pages" with the "back" button + */ +export class PopoverMobile extends PopoverAbstract { + /** + * ScrollLocker instance + */ + private scrollLocker = new ScrollLocker(); + + /** + * Reference to popover header if exists + */ + private header: PopoverHeader | undefined | null; + + /** + * History of popover states for back navigation. + * Is used for mobile version of popover, + * where we can not display nested popover of the screen and + * have to render nested items in the same popover switching to new state + */ + private history = new PopoverStatesHistory(); + + /** + * Construct the instance + * + * @param params - popover params + */ + constructor(params: PopoverParams) { + super(params); + + this.nodes.overlay = Dom.make('div', [css.overlay, css.overlayHidden]); + this.nodes.popover.insertBefore(this.nodes.overlay, this.nodes.popover.firstChild); + + this.listeners.on(this.nodes.overlay, 'click', () => { + this.hide(); + }); + + /* Save state to history for proper navigation between nested and parent popovers */ + this.history.push({ items: params.items }); + } + + /** + * Open popover + */ + public show(): void { + this.nodes.overlay.classList.remove(css.overlayHidden); + + super.show(); + + this.scrollLocker.lock(); + } + + /** + * Closes popover + */ + public hide(): void { + super.hide(); + this.nodes.overlay.classList.add(css.overlayHidden); + + this.scrollLocker.unlock(); + + this.history.reset(); + } + + /** + * Clears memory + */ + public destroy(): void { + super.destroy(); + + this.scrollLocker.unlock(); + } + + /** + * Handles displaying nested items for the item + * + * @param item – item to show nested popover for + */ + protected override showNestedItems(item: PopoverItem): void { + /** Show nested items */ + this.updateItemsAndHeader(item.children, item.title); + + this.history.push({ + title: item.title, + items: item.children, + }); + } + + /** + * Removes rendered popover items and header and displays new ones + * + * @param title - new popover header text + * @param items - new popover items + */ + private updateItemsAndHeader(items: PopoverItemParams[], title?: string ): void { + /** Re-render header */ + if (this.header !== null && this.header !== undefined) { + this.header.destroy(); + this.header = null; + } + if (title !== undefined) { + this.header = new PopoverHeader({ + text: title, + onBackButtonClick: () => { + this.history.pop(); + + this.updateItemsAndHeader(this.history.currentItems, this.history.currentTitle); + }, + }); + const headerEl = this.header.getElement(); + + if (headerEl !== null) { + this.nodes.popoverContainer.insertBefore(headerEl, this.nodes.popoverContainer.firstChild); + } + } + + /** Re-render items */ + this.items.forEach(item => item.getElement()?.remove()); + + this.items = items.map(params => new PopoverItem(params)); + + this.items.forEach(item => { + const itemEl = item.getElement(); + + if (itemEl === null) { + return; + } + this.nodes.items?.appendChild(itemEl); + }); + } +} diff --git a/src/components/utils/popover/popover.const.ts b/src/components/utils/popover/popover.const.ts new file mode 100644 index 000000000..4fc693a7a --- /dev/null +++ b/src/components/utils/popover/popover.const.ts @@ -0,0 +1,27 @@ +import { bem } from '../bem'; + +/** + * Popover block CSS class constructor + */ +const className = bem('ce-popover'); + +/** + * CSS class names to be used in popover + */ +export const css = { + popover: className(), + popoverContainer: className('container'), + popoverOpenTop: className(null, 'open-top'), + popoverOpenLeft: className(null, 'open-left'), + popoverOpened: className(null, 'opened'), + search: className('search'), + nothingFoundMessage: className('nothing-found-message'), + nothingFoundMessageDisplayed: className('nothing-found-message', 'displayed'), + customContent: className('custom-content'), + customContentHidden: className('custom-content', 'hidden'), + items: className('items'), + overlay: className('overlay'), + overlayHidden: className('overlay', 'hidden'), + popoverNested: className(null, 'nested'), + popoverHeader: className('header'), +}; diff --git a/src/components/utils/popover/popover.types.ts b/src/components/utils/popover/popover.types.ts new file mode 100644 index 000000000..515ec4363 --- /dev/null +++ b/src/components/utils/popover/popover.types.ts @@ -0,0 +1,109 @@ +import { PopoverItem as PopoverItemParams } from '../../../../types'; + +/** + * Params required to render popover + */ +export interface PopoverParams { + /** + * Popover items config + */ + items: PopoverItemParams[]; + + /** + * Element of the page that creates 'scope' of the popover. + * Depending on its size popover position will be calculated + */ + scopeElement?: HTMLElement; + + /** + * Arbitrary html element to be inserted before items list + */ + customContent?: HTMLElement; + + /** + * List of html elements inside custom content area that should be available for keyboard navigation + */ + customContentFlippableItems?: HTMLElement[]; + + /** + * True if popover should contain search field + */ + searchable?: boolean; + + /** + * Popover texts overrides + */ + messages?: PopoverMessages + + /** + * CSS class name for popover root element + */ + class?: string; + + /** + * Popover nesting level. 0 value means that it is a root popover + */ + nestingLevel?: number; +} + +/** + * Texts used inside popover + */ +export interface PopoverMessages { + /** Text displayed when search has no results */ + nothingFound?: string; + + /** Search input label */ + search?: string +} + +/** + * Event that can be triggered by the Popover + */ +export enum PopoverEvent { + /** + * When popover closes + */ + Close = 'close' +} + +/** + * Events fired by the Popover + */ +export interface PopoverEventMap { + /** + * Fired when popover closes + */ + [PopoverEvent.Close]: undefined; +} + +/** + * HTML elements required to display popover + */ +export interface PopoverNodes { + /** Root popover element */ + popover: HTMLElement; + + /** Wraps all the visible popover elements, has background and rounded corners */ + popoverContainer: HTMLElement; + + /** Message displayed when no items found while searching */ + nothingFoundMessage: HTMLElement; + + /** Popover items wrapper */ + items: HTMLElement; + + /** Custom html content area */ + customContent: HTMLElement | undefined; +} + +/** + * HTML elements required to display mobile popover + */ +export interface PopoverMobileNodes extends PopoverNodes { + /** Popover header element */ + header: HTMLElement; + + /** Overlay, displayed under popover on mobile */ + overlay: HTMLElement; +} diff --git a/src/components/utils/popover/utils/popover-states-history.ts b/src/components/utils/popover/utils/popover-states-history.ts new file mode 100644 index 000000000..92975468c --- /dev/null +++ b/src/components/utils/popover/utils/popover-states-history.ts @@ -0,0 +1,73 @@ +import { PopoverItem } from '../../../../../types'; + +/** + * Represents single states history item + */ +interface PopoverStatesHistoryItem { + /** + * Popover title + */ + title?: string; + + /** + * Popover items + */ + items: PopoverItem[] +} + +/** + * Manages items history inside popover. Allows to navigate back in history + */ +export class PopoverStatesHistory { + /** + * Previous items states + */ + private history: PopoverStatesHistoryItem[] = []; + + /** + * Push new popover state + * + * @param state - new state + */ + public push(state: PopoverStatesHistoryItem): void { + this.history.push(state); + } + + /** + * Pop last popover state + */ + public pop(): PopoverStatesHistoryItem | undefined { + return this.history.pop(); + } + + /** + * Title retrieved from the current state + */ + public get currentTitle(): string | undefined { + if (this.history.length === 0) { + return ''; + } + + return this.history[this.history.length - 1].title; + } + + /** + * Items list retrieved from the current state + */ + public get currentItems(): PopoverItem[] { + if (this.history.length === 0) { + return []; + } + + return this.history[this.history.length - 1].items; + } + + /** + * Returns history to initial popover state + */ + public reset(): void { + while (this.history.length > 1) { + this.pop(); + } + } +} diff --git a/src/styles/popover.css b/src/styles/popover.css index 702d9f735..a59826384 100644 --- a/src/styles/popover.css +++ b/src/styles/popover.css @@ -1,5 +1,8 @@ /** * Popover styles + * + * @todo split into separate files popover styles + * @todo make css variables work */ .ce-popover { --border-radius: 6px; @@ -21,38 +24,63 @@ --color-background-item-hover: #eff2f5; --color-background-item-confirm: #E24A4A; --color-background-item-confirm-hover: #CE4343; + --popover-top: calc(100% + var(--offset-from-target)); + --popover-left: 0; + --nested-popover-overlap: 4px; - min-width: var(--width); - width: var(--width); - max-height: var(--max-height); - border-radius: var(--border-radius); - overflow: hidden; - box-sizing: border-box; - box-shadow: 0 3px 15px -3px var(--color-shadow); - position: absolute; - left: 0; - top: calc(100% + var(--offset-from-target)); - background: var(--color-background); - display: flex; - flex-direction: column; - z-index: 4; + --icon-size: 20px; + --item-padding: 3px; + --item-height: calc(var(--icon-size) + 2 * var(--item-padding)); + + &__container { + min-width: var(--width); + width: var(--width); + max-height: var(--max-height); + border-radius: var(--border-radius); + overflow: hidden; + box-sizing: border-box; + box-shadow: 0 3px 15px -3px var(--color-shadow); + position: absolute; + left: var(--popover-left); + top: var(--popover-top); - opacity: 0; - max-height: 0; - pointer-events: none; - padding: 0; - border: none; + background: var(--color-background); + display: flex; + flex-direction: column; + z-index: 4; + + opacity: 0; + max-height: 0; + pointer-events: none; + padding: 0; + border: none; + } &--opened { - opacity: 1; - padding: var(--padding); - max-height: var(--max-height); - pointer-events: auto; - animation: panelShowing 100ms ease; - border: 1px solid var(--color-border); + .ce-popover__container { + opacity: 1; + padding: var(--padding); + max-height: var(--max-height); + pointer-events: auto; + animation: panelShowing 100ms ease; + border: 1px solid var(--color-border); + + @media (--mobile) { + animation: panelShowingMobile 250ms ease; + } + } - @media (--mobile) { - animation: panelShowingMobile 250ms ease; + } + + &--open-top { + .ce-popover__container { + --popover-top: calc(-1 * (var(--offset-from-target) + var(--popover-height))); + } + } + + &--open-left { + .ce-popover__container { + --popover-left: calc(-1 * var(--width) + 100%); } } @@ -81,21 +109,21 @@ } } - &--open-top { - top: calc(-1 * (var(--offset-from-target) + var(--popover-height))); - } @media (--mobile) { - --offset: 5px; - position: fixed; - max-width: none; - min-width: calc(100% - var(--offset) * 2); - left: var(--offset); - right: var(--offset); - bottom: calc(var(--offset) + env(safe-area-inset-bottom)); - top: auto; - border-radius: 10px; + .ce-popover__container { + --offset: 5px; + + position: fixed; + max-width: none; + min-width: calc(100% - var(--offset) * 2); + left: var(--offset); + right: var(--offset); + bottom: calc(var(--offset) + env(safe-area-inset-bottom)); + top: auto; + border-radius: 10px; + } .ce-popover__search { display: none; @@ -134,6 +162,32 @@ &__custom-content--hidden { display: none; } + + &--nested { + .ce-popover__container { + /* Variable --nesting-level is set via js in showNestedPopoverForItem() method */ + --popover-left: calc(var(--nesting-level) * (var(--width) - var(--nested-popover-overlap))); + /* Variable --trigger-item-top is set via js in showNestedPopoverForItem() method */ + top: calc(var(--trigger-item-top) - var(--nested-popover-overlap)); + position: absolute; + } + } + + &--open-top.ce-popover--nested { + .ce-popover__container { + /** Bottom edge of nested popover should not be lower than bottom edge of parent popover when opened upwards */ + top: calc(var(--trigger-item-top) - var(--popover-height) + var(--item-height) + var(--offset-from-target) + var(--nested-popover-overlap)); + + } + } + + &--open-left { + .ce-popover--nested { + .ce-popover__container { + --popover-left: calc(-1 * (var(--nesting-level) + 1) * var(--width) + 100%); + } + } + } } @@ -142,13 +196,10 @@ */ .ce-popover-item { --border-radius: 6px; - --icon-size: 20px; - --icon-size-mobile: 28px; - border-radius: var(--border-radius); display: flex; align-items: center; - padding: 3px; + padding: var(--item-padding); color: var(--color-text-primary); user-select: none; @@ -161,15 +212,11 @@ } &__icon { - border-radius: 5px; width: 26px; height: 26px; - box-shadow: 0 0 0 1px var(--color-border-icon); - background: #fff; display: flex; align-items: center; justify-content: center; - margin-right: 10px; svg { width: var(--icon-size); @@ -182,12 +229,19 @@ border-radius: 8px; svg { - width: var(--icon-size-mobile); - height: var(--icon-size-mobile); + width: 28px; + height: 28px; } } } + &__icon--tool { + border-radius: 5px; + box-shadow: 0 0 0 1px var(--color-border-icon); + background: #fff; + margin-right: 10px; + } + &__title { font-size: 14px; line-height: 20px; @@ -197,6 +251,8 @@ white-space: nowrap; text-overflow: ellipsis; + margin-right: auto; + @media (--mobile) { font-size: 16px; } @@ -205,7 +261,6 @@ &__secondary-title { color: var(--color-text-secondary); font-size: 12px; - margin-left: auto; white-space: nowrap; letter-spacing: -0.1em; padding-right: 5px; @@ -373,3 +428,32 @@ transform: translate3d(0, 0, 0); } } + +/** + * Popover header styles + */ +.ce-popover-header { + margin-bottom: 8px; + margin-top: 4px; + display: flex; + align-items: center; + + &__text { + font-size: 18px; + font-weight: 600; + } + + &__back-button { + border: 0; + background: transparent; + width: 36px; + height: 36px; + color: var(--color-text-primary); + + svg { + display: block; + width: 28px; + height: 28px; + } + } +} diff --git a/test/cypress/tests/modules/BlockEvents/Slash.cy.ts b/test/cypress/tests/modules/BlockEvents/Slash.cy.ts index adf9a2078..aca84cd1c 100644 --- a/test/cypress/tests/modules/BlockEvents/Slash.cy.ts +++ b/test/cypress/tests/modules/BlockEvents/Slash.cy.ts @@ -19,7 +19,7 @@ describe('Slash keydown', function () { .click() .type('/'); - cy.get('[data-cy="toolbox"] .ce-popover') + cy.get('[data-cy="toolbox"] .ce-popover__container') .should('be.visible'); }); @@ -46,7 +46,7 @@ describe('Slash keydown', function () { .click() .type(`{${key}}/`); - cy.get('[data-cy="toolbox"] .ce-popover') + cy.get('[data-cy="toolbox"] .ce-popover__container') .should('not.be.visible'); }); }); @@ -72,7 +72,7 @@ describe('Slash keydown', function () { .click() .type('/'); - cy.get('[data-cy="toolbox"] .ce-popover') + cy.get('[data-cy="toolbox"] .ce-popover__container') .should('not.be.visible'); /** @@ -106,7 +106,7 @@ describe('CMD+Slash keydown', function () { .click() .type('{cmd}/'); - cy.get('[data-cy="block-tunes"] .ce-popover') + cy.get('[data-cy="block-tunes"] .ce-popover__container') .should('be.visible'); }); }); diff --git a/test/cypress/tests/utils/popover.cy.ts b/test/cypress/tests/utils/popover.cy.ts index 3eeeb2ddb..1e5f20325 100644 --- a/test/cypress/tests/utils/popover.cy.ts +++ b/test/cypress/tests/utils/popover.cy.ts @@ -1,5 +1,6 @@ -import Popover from '../../../../src/components/utils/popover'; +import { PopoverDesktop as Popover } from '../../../../src/components/utils/popover'; import { PopoverItem } from '../../../../types'; +import { TunesMenuConfig } from '../../../../types/tools'; /* eslint-disable @typescript-eslint/no-empty-function */ @@ -257,4 +258,187 @@ describe('Popover', () => { cy.get('[data-cy-name=customContent]'); }); }); + + it('should display nested popover (desktop)', () => { + /** Tool class to test how it is displayed inside block tunes popover */ + class TestTune { + public static isTune = true; + + /** Tool data displayed in block tunes popover */ + public render(): TunesMenuConfig { + return { + icon: 'Icon', + title: 'Title', + toggle: 'key', + name: 'test-item', + children: { + items: [ + { + icon: 'Icon', + title: 'Title', + name: 'nested-test-item', + }, + ], + }, + }; + } + } + + /** Create editor instance */ + cy.createEditor({ + tools: { + testTool: TestTune, + }, + tunes: [ 'testTool' ], + data: { + blocks: [ + { + type: 'paragraph', + data: { + text: 'Hello', + }, + }, + ], + }, + }); + + /** Open block tunes menu */ + cy.get('[data-cy=editorjs]') + .get('.cdx-block') + .click(); + + cy.get('[data-cy=editorjs]') + .get('.ce-toolbar__settings-btn') + .click(); + + /** Check item with children has arrow icon */ + cy.get('[data-cy=editorjs]') + .get('[data-item-name="test-item"]') + .get('.ce-popover-item__icon--chevron-right') + .should('be.visible'); + + /** Click the item */ + cy.get('[data-cy=editorjs]') + .get('[data-item-name="test-item"]') + .click(); + + /** Check nested popover opened */ + cy.get('[data-cy=editorjs]') + .get('.ce-popover--nested .ce-popover__container') + .should('be.visible'); + + /** Check child item displayed */ + cy.get('[data-cy=editorjs]') + .get('.ce-popover--nested .ce-popover__container') + .get('[data-item-name="nested-test-item"]') + .should('be.visible'); + }); + + + it('should display children items, back button and item header and correctly switch between parent and child states (mobile)', () => { + /** Tool class to test how it is displayed inside block tunes popover */ + class TestTune { + public static isTune = true; + + /** Tool data displayed in block tunes popover */ + public render(): TunesMenuConfig { + return { + icon: 'Icon', + title: 'Tune', + toggle: 'key', + name: 'test-item', + children: { + items: [ + { + icon: 'Icon', + title: 'Title', + name: 'nested-test-item', + }, + ], + }, + }; + } + } + + cy.viewport('iphone-6+'); + + + /** Create editor instance */ + cy.createEditor({ + tools: { + testTool: TestTune, + }, + tunes: [ 'testTool' ], + data: { + blocks: [ + { + type: 'paragraph', + data: { + text: 'Hello', + }, + }, + ], + }, + }); + + /** Open block tunes menu */ + cy.get('[data-cy=editorjs]') + .get('.cdx-block') + .click(); + + cy.get('[data-cy=editorjs]') + .get('.ce-toolbar__settings-btn') + .click(); + + /** Check item with children has arrow icon */ + cy.get('[data-cy=editorjs]') + .get('[data-item-name="test-item"]') + .get('.ce-popover-item__icon--chevron-right') + .should('be.visible'); + + /** Click the item */ + cy.get('[data-cy=editorjs]') + .get('[data-item-name="test-item"]') + .click(); + + /** Check child item displayed */ + cy.get('[data-cy=editorjs]') + .get('.ce-popover__container') + .get('[data-item-name="nested-test-item"]') + .should('be.visible'); + + /** Check header displayed */ + cy.get('[data-cy=editorjs]') + .get('.ce-popover-header') + .should('have.text', 'Tune'); + + /** Check back button displayed */ + cy.get('[data-cy=editorjs]') + .get('.ce-popover__container') + .get('.ce-popover-header__back-button') + .should('be.visible'); + + /** Click back button */ + cy.get('[data-cy=editorjs]') + .get('.ce-popover__container') + .get('.ce-popover-header__back-button') + .click(); + + /** Check child item is not displayed */ + cy.get('[data-cy=editorjs]') + .get('.ce-popover__container') + .get('[data-item-name="nested-test-item"]') + .should('not.exist'); + + /** Check back button is not displayed */ + cy.get('[data-cy=editorjs]') + .get('.ce-popover__container') + .get('.ce-popover-header__back-button') + .should('not.exist'); + + /** Check header is not displayed */ + cy.get('[data-cy=editorjs]') + .get('.ce-popover-header') + .should('not.exist'); + }); }); diff --git a/types/configs/popover.d.ts b/types/configs/popover.d.ts index 6689fce2f..ab53e521f 100644 --- a/types/configs/popover.d.ts +++ b/types/configs/popover.d.ts @@ -60,7 +60,7 @@ export interface PopoverItemWithConfirmation extends PopoverItemBase { } /** - * Represents default popover item without confirmation state configuration + * Represents popover item without confirmation state configuration */ export interface PopoverItemWithoutConfirmation extends PopoverItemBase { confirmation?: never; @@ -72,10 +72,27 @@ export interface PopoverItemWithoutConfirmation extends PopoverItemBase { * @param event - event that initiated item activation */ onActivate: (item: PopoverItem, event?: PointerEvent) => void; + +} + + +/** + * Represents popover item with children (nested popover items) + */ +export interface PopoverItemWithChildren extends PopoverItemBase { + confirmation?: never; + onActivate?: never; + + /** + * Items of nested popover that should be open on the current item hover/click (depending on platform) + */ + children?: { + items: PopoverItem[] + } } /** * Represents single popover item */ -export type PopoverItem = PopoverItemWithConfirmation | PopoverItemWithoutConfirmation +export type PopoverItem = PopoverItemWithConfirmation | PopoverItemWithoutConfirmation | PopoverItemWithChildren From 54c4c234a5e73d8aa64356bafbec8df78904c76e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 13 Apr 2024 23:07:20 +0300 Subject: [PATCH 12/61] Bump version (#2659) Co-authored-by: github-actions --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 16a857fab..4859556a5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@editorjs/editorjs", - "version": "2.30.0-rc.2", + "version": "2.30.0-rc.3", "description": "Editor.js — Native JS, based on API and Open Source", "main": "dist/editorjs.umd.js", "module": "dist/editorjs.mjs", From e1c70b4fb830d1177b8c6cca069e95928056e0ce Mon Sep 17 00:00:00 2001 From: Tatiana Fomina Date: Mon, 22 Apr 2024 22:38:20 +0300 Subject: [PATCH 13/61] feat(popover): separator (#2690) * Support delimiter * Rename types, move types to popover-item folder * Fix ts errors * Add tests * Review fixes * Review fixes 2 * Fix delimiter while search * Fix flipper issue * Fix block tunes types * Fix types * Fixes * Make search input emit event * Fix types * Rename delimiter to separator * Update chengelog --- docs/CHANGELOG.md | 1 + src/components/block/index.ts | 4 +- src/components/utils/bem.ts | 2 +- src/components/utils/events.ts | 2 +- .../popover/components/popover-item/index.ts | 14 +- .../popover-item-default.const.ts} | 2 +- .../popover-item-default.ts | 318 ++++++++++++++++ .../popover-item-separator.const.ts | 15 + .../popover-item-separator.ts | 43 +++ .../components/popover-item/popover-item.ts | 304 +-------------- .../popover-item/popover-item.types.ts | 47 ++- .../components/search-input/search-input.ts | 27 +- .../search-input/search-input.types.ts | 21 ++ src/components/utils/popover/index.ts | 2 + .../utils/popover/popover-abstract.ts | 88 +++-- .../utils/popover/popover-desktop.ts | 47 +-- .../utils/popover/popover-mobile.ts | 7 +- src/components/utils/popover/popover.types.ts | 2 +- src/styles/popover.css | 18 +- test/cypress/tests/utils/popover.cy.ts | 345 +++++++++++++++++- types/configs/index.d.ts | 2 +- types/index.d.ts | 11 +- types/tools/tool-settings.d.ts | 19 +- 23 files changed, 938 insertions(+), 403 deletions(-) rename src/components/utils/popover/components/popover-item/{popover-item.const.ts => popover-item-default/popover-item-default.const.ts} (94%) create mode 100644 src/components/utils/popover/components/popover-item/popover-item-default/popover-item-default.ts create mode 100644 src/components/utils/popover/components/popover-item/popover-item-separator/popover-item-separator.const.ts create mode 100644 src/components/utils/popover/components/popover-item/popover-item-separator/popover-item-separator.ts rename types/configs/popover.d.ts => src/components/utils/popover/components/popover-item/popover-item.types.ts (56%) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 0d19eae39..bb2414430 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -3,6 +3,7 @@ ### 2.30.1 – `New` – Block Tunes now supports nesting items +– `New` – Block Tunes now supports separator items ### 2.30.0 diff --git a/src/components/block/index.ts b/src/components/block/index.ts index a9977f2a6..576314718 100644 --- a/src/components/block/index.ts +++ b/src/components/block/index.ts @@ -6,7 +6,7 @@ import { SanitizerConfig, ToolConfig, ToolboxConfigEntry, - PopoverItem + PopoverItemParams } from '../../../types'; import { SavedData } from '../../../types/data-formats'; @@ -614,7 +614,7 @@ export default class Block extends EventsDispatcher { * Returns data to render in tunes menu. * Splits block tunes settings into 2 groups: popover items and custom html. */ - public getTunes(): [PopoverItem[], HTMLElement] { + public getTunes(): [PopoverItemParams[], HTMLElement] { const customHtmlTunesContainer = document.createElement('div'); const tunesItems: TunesMenuConfigItem[] = []; diff --git a/src/components/utils/bem.ts b/src/components/utils/bem.ts index eea146d71..264c2bf53 100644 --- a/src/components/utils/bem.ts +++ b/src/components/utils/bem.ts @@ -13,7 +13,7 @@ const MODIFIER_DELIMITER = '--'; * @param modifier - modifier to be appended */ export function bem(blockName: string) { - return (elementName?: string, modifier?: string) => { + return (elementName?: string | null, modifier?: string) => { const className = [blockName, elementName] .filter(x => !!x) .join(ELEMENT_DELIMITER); diff --git a/src/components/utils/events.ts b/src/components/utils/events.ts index 2599f0b74..295474da0 100644 --- a/src/components/utils/events.ts +++ b/src/components/utils/events.ts @@ -3,7 +3,7 @@ import { isEmpty } from '../utils'; /** * Event Dispatcher event listener */ -type Listener = (data?: Data) => void; +type Listener = (data: Data) => void; /** * Mapped type with subscriptions list diff --git a/src/components/utils/popover/components/popover-item/index.ts b/src/components/utils/popover/components/popover-item/index.ts index 09b97e0d7..12c91d40a 100644 --- a/src/components/utils/popover/components/popover-item/index.ts +++ b/src/components/utils/popover/components/popover-item/index.ts @@ -1,2 +1,12 @@ -export * from './popover-item'; -export * from './popover-item.const'; +import { PopoverItemDefault } from './popover-item-default/popover-item-default'; +import { PopoverItemSeparator } from './popover-item-separator/popover-item-separator'; +import { PopoverItem } from './popover-item'; + +export * from './popover-item-default/popover-item-default.const'; +export * from './popover-item.types'; + +export { + PopoverItemDefault, + PopoverItemSeparator, + PopoverItem +}; diff --git a/src/components/utils/popover/components/popover-item/popover-item.const.ts b/src/components/utils/popover/components/popover-item/popover-item-default/popover-item-default.const.ts similarity index 94% rename from src/components/utils/popover/components/popover-item/popover-item.const.ts rename to src/components/utils/popover/components/popover-item/popover-item-default/popover-item-default.const.ts index 515e0428c..e5929b78b 100644 --- a/src/components/utils/popover/components/popover-item/popover-item.const.ts +++ b/src/components/utils/popover/components/popover-item/popover-item-default/popover-item-default.const.ts @@ -1,4 +1,4 @@ -import { bem } from '../../../bem'; +import { bem } from '../../../../bem'; /** * Popover item block CSS class constructor diff --git a/src/components/utils/popover/components/popover-item/popover-item-default/popover-item-default.ts b/src/components/utils/popover/components/popover-item/popover-item-default/popover-item-default.ts new file mode 100644 index 000000000..71cdb7b37 --- /dev/null +++ b/src/components/utils/popover/components/popover-item/popover-item-default/popover-item-default.ts @@ -0,0 +1,318 @@ +import Dom from '../../../../../dom'; +import { IconDotCircle, IconChevronRight } from '@codexteam/icons'; +import { + PopoverItemDefaultParams as PopoverItemDefaultParams, + PopoverItemParams as PopoverItemParams +} from '../popover-item.types'; +import { PopoverItem } from '../popover-item'; +import { css } from './popover-item-default.const'; + +/** + * Represents sigle popover item node + * + * @todo move nodes initialization to constructor + * @todo replace multiple make() usages with constructing separate instaces + * @todo split regular popover item and popover item with confirmation to separate classes + */ +export class PopoverItemDefault extends PopoverItem { + /** + * True if item is disabled and hence not clickable + */ + public get isDisabled(): boolean { + return this.params.isDisabled === true; + } + + /** + * Exposes popover item toggle parameter + */ + public get toggle(): boolean | string | undefined { + return this.params.toggle; + } + + /** + * Item title + */ + public get title(): string | undefined { + return this.params.title; + } + + /** + * True if popover should close once item is activated + */ + public get closeOnActivate(): boolean | undefined { + return this.params.closeOnActivate; + } + + /** + * True if confirmation state is enabled for popover item + */ + public get isConfirmationStateEnabled(): boolean { + return this.confirmationState !== null; + } + + /** + * True if item is focused in keyboard navigation process + */ + public get isFocused(): boolean { + if (this.nodes.root === null) { + return false; + } + + return this.nodes.root.classList.contains(css.focused); + } + + /** + * Item html elements + */ + private nodes: { + root: null | HTMLElement, + icon: null | HTMLElement + } = { + root: null, + icon: null, + }; + + /** + * Popover item params + */ + private params: PopoverItemDefaultParams; + + /** + * If item is in confirmation state, stores confirmation params such as icon, label, onActivate callback and so on + */ + private confirmationState: PopoverItemDefaultParams | null = null; + + /** + * Constructs popover item instance + * + * @param params - popover item construction params + */ + constructor(params: PopoverItemDefaultParams) { + super(); + + this.params = params; + this.nodes.root = this.make(params); + } + + /** + * Returns popover item root element + */ + public getElement(): HTMLElement | null { + return this.nodes.root; + } + + /** + * Called on popover item click + */ + public handleClick(): void { + if (this.isConfirmationStateEnabled && this.confirmationState !== null) { + this.activateOrEnableConfirmationMode(this.confirmationState); + + return; + } + + this.activateOrEnableConfirmationMode(this.params); + } + + /** + * Toggles item active state + * + * @param isActive - true if item should strictly should become active + */ + public toggleActive(isActive?: boolean): void { + this.nodes.root?.classList.toggle(css.active, isActive); + } + + /** + * Toggles item hidden state + * + * @param isHidden - true if item should be hidden + */ + public override toggleHidden(isHidden: boolean): void { + this.nodes.root?.classList.toggle(css.hidden, isHidden); + } + + /** + * Resets popover item to its original state + */ + public reset(): void { + if (this.isConfirmationStateEnabled) { + this.disableConfirmationMode(); + } + } + + /** + * Method called once item becomes focused during keyboard navigation + */ + public onFocus(): void { + this.disableSpecialHoverAndFocusBehavior(); + } + + /** + * Returns list of item children + */ + public get children(): PopoverItemParams[] { + return 'children' in this.params && this.params.children?.items !== undefined ? this.params.children.items : []; + } + + /** + * Constructs HTML element corresponding to popover item params + * + * @param params - item construction params + */ + private make(params: PopoverItemDefaultParams): HTMLElement { + const el = Dom.make('div', css.container); + + if (params.name) { + el.dataset.itemName = params.name; + } + + this.nodes.icon = Dom.make('div', [css.icon, css.iconTool], { + innerHTML: params.icon || IconDotCircle, + }); + + el.appendChild(this.nodes.icon); + + el.appendChild(Dom.make('div', css.title, { + innerHTML: params.title || '', + })); + + if (params.secondaryLabel) { + el.appendChild(Dom.make('div', css.secondaryTitle, { + textContent: params.secondaryLabel, + })); + } + + if (this.children.length > 0) { + el.appendChild(Dom.make('div', [css.icon, css.iconChevronRight], { + innerHTML: IconChevronRight, + })); + } + + if (params.isActive) { + el.classList.add(css.active); + } + + if (params.isDisabled) { + el.classList.add(css.disabled); + } + + return el; + } + + /** + * Activates confirmation mode for the item. + * + * @param newState - new popover item params that should be applied + */ + private enableConfirmationMode(newState: PopoverItemDefaultParams): void { + if (this.nodes.root === null) { + return; + } + + const params = { + ...this.params, + ...newState, + confirmation: newState.confirmation, + } as PopoverItemDefaultParams; + const confirmationEl = this.make(params); + + this.nodes.root.innerHTML = confirmationEl.innerHTML; + this.nodes.root.classList.add(css.confirmationState); + + this.confirmationState = newState; + + this.enableSpecialHoverAndFocusBehavior(); + } + + /** + * Returns item to its original state + */ + private disableConfirmationMode(): void { + if (this.nodes.root === null) { + return; + } + const itemWithOriginalParams = this.make(this.params); + + this.nodes.root.innerHTML = itemWithOriginalParams.innerHTML; + this.nodes.root.classList.remove(css.confirmationState); + + this.confirmationState = null; + + this.disableSpecialHoverAndFocusBehavior(); + } + + /** + * Enables special focus and hover behavior for item in confirmation state. + * This is needed to prevent item from being highlighted as hovered/focused just after click. + */ + private enableSpecialHoverAndFocusBehavior(): void { + this.nodes.root?.classList.add(css.noHover); + this.nodes.root?.classList.add(css.noFocus); + + this.nodes.root?.addEventListener('mouseleave', this.removeSpecialHoverBehavior, { once: true }); + } + + /** + * Disables special focus and hover behavior + */ + private disableSpecialHoverAndFocusBehavior(): void { + this.removeSpecialFocusBehavior(); + this.removeSpecialHoverBehavior(); + + this.nodes.root?.removeEventListener('mouseleave', this.removeSpecialHoverBehavior); + } + + /** + * Removes class responsible for special focus behavior on an item + */ + private removeSpecialFocusBehavior = (): void => { + this.nodes.root?.classList.remove(css.noFocus); + }; + + /** + * Removes class responsible for special hover behavior on an item + */ + private removeSpecialHoverBehavior = (): void => { + this.nodes.root?.classList.remove(css.noHover); + }; + + /** + * Executes item's onActivate callback if the item has no confirmation configured + * + * @param item - item to activate or bring to confirmation mode + */ + private activateOrEnableConfirmationMode(item: PopoverItemDefaultParams): void { + if (item.confirmation === undefined) { + try { + item.onActivate?.(item); + this.disableConfirmationMode(); + } catch { + this.animateError(); + } + } else { + this.enableConfirmationMode(item.confirmation); + } + } + + /** + * Animates item which symbolizes that error occured while executing 'onActivate()' callback + */ + private animateError(): void { + if (this.nodes.icon?.classList.contains(css.wobbleAnimation)) { + return; + } + + this.nodes.icon?.classList.add(css.wobbleAnimation); + + this.nodes.icon?.addEventListener('animationend', this.onErrorAnimationEnd); + } + + /** + * Handles finish of error animation + */ + private onErrorAnimationEnd = (): void => { + this.nodes.icon?.classList.remove(css.wobbleAnimation); + this.nodes.icon?.removeEventListener('animationend', this.onErrorAnimationEnd); + }; +} diff --git a/src/components/utils/popover/components/popover-item/popover-item-separator/popover-item-separator.const.ts b/src/components/utils/popover/components/popover-item/popover-item-separator/popover-item-separator.const.ts new file mode 100644 index 000000000..386f686a4 --- /dev/null +++ b/src/components/utils/popover/components/popover-item/popover-item-separator/popover-item-separator.const.ts @@ -0,0 +1,15 @@ +import { bem } from '../../../../bem'; + +/** + * Popover separator block CSS class constructor + */ +const className = bem('ce-popover-item-separator'); + +/** + * CSS class names to be used in popover separator class + */ +export const css = { + container: className(), + line: className('line'), + hidden: className(null, 'hidden'), +}; diff --git a/src/components/utils/popover/components/popover-item/popover-item-separator/popover-item-separator.ts b/src/components/utils/popover/components/popover-item/popover-item-separator/popover-item-separator.ts new file mode 100644 index 000000000..4e091c1af --- /dev/null +++ b/src/components/utils/popover/components/popover-item/popover-item-separator/popover-item-separator.ts @@ -0,0 +1,43 @@ +import Dom from '../../../../../dom'; +import { PopoverItem } from '../popover-item'; +import { css } from './popover-item-separator.const'; + +/** + * Represents popover separator node + */ +export class PopoverItemSeparator extends PopoverItem { + /** + * Html elements + */ + private nodes: { root: HTMLElement; line: HTMLElement }; + + /** + * Constructs the instance + */ + constructor() { + super(); + + this.nodes = { + root: Dom.make('div', css.container), + line: Dom.make('div', css.line), + }; + + this.nodes.root.appendChild(this.nodes.line); + } + + /** + * Returns popover separator root element + */ + public getElement(): HTMLElement { + return this.nodes.root; + } + + /** + * Toggles item hidden state + * + * @param isHidden - true if item should be hidden + */ + public toggleHidden(isHidden: boolean): void { + this.nodes.root?.classList.toggle(css.hidden, isHidden); + } +} diff --git a/src/components/utils/popover/components/popover-item/popover-item.ts b/src/components/utils/popover/components/popover-item/popover-item.ts index 5c72669b8..b0eb95d7a 100644 --- a/src/components/utils/popover/components/popover-item/popover-item.ts +++ b/src/components/utils/popover/components/popover-item/popover-item.ts @@ -1,312 +1,16 @@ -import Dom from '../../../../dom'; -import { IconDotCircle, IconChevronRight } from '@codexteam/icons'; -import { PopoverItem as PopoverItemParams } from '../../../../../../types'; -import { css } from './popover-item.const'; - /** - * Represents sigle popover item node - * - * @todo move nodes initialization to constructor - * @todo replace multiple make() usages with constructing separate instaces - * @todo split regular popover item and popover item with confirmation to separate classes + * Popover item abstract class */ -export class PopoverItem { - /** - * True if item is disabled and hence not clickable - */ - public get isDisabled(): boolean { - return this.params.isDisabled === true; - } - - /** - * Exposes popover item toggle parameter - */ - public get toggle(): boolean | string | undefined { - return this.params.toggle; - } - - /** - * Item title - */ - public get title(): string | undefined { - return this.params.title; - } - - /** - * True if popover should close once item is activated - */ - public get closeOnActivate(): boolean | undefined { - return this.params.closeOnActivate; - } - - /** - * True if confirmation state is enabled for popover item - */ - public get isConfirmationStateEnabled(): boolean { - return this.confirmationState !== null; - } - - /** - * True if item is focused in keyboard navigation process - */ - public get isFocused(): boolean { - if (this.nodes.root === null) { - return false; - } - - return this.nodes.root.classList.contains(css.focused); - } - - /** - * Item html elements - */ - private nodes: { - root: null | HTMLElement, - icon: null | HTMLElement - } = { - root: null, - icon: null, - }; - - /** - * Popover item params - */ - private params: PopoverItemParams; - - /** - * If item is in confirmation state, stores confirmation params such as icon, label, onActivate callback and so on - */ - private confirmationState: PopoverItemParams | null = null; - - /** - * Constructs popover item instance - * - * @param params - popover item construction params - */ - constructor(params: PopoverItemParams) { - this.params = params; - this.nodes.root = this.make(params); - } - +export abstract class PopoverItem { /** * Returns popover item root element */ - public getElement(): HTMLElement | null { - return this.nodes.root; - } - - /** - * Called on popover item click - */ - public handleClick(): void { - if (this.isConfirmationStateEnabled && this.confirmationState !== null) { - this.activateOrEnableConfirmationMode(this.confirmationState); - - return; - } - - this.activateOrEnableConfirmationMode(this.params); - } - - /** - * Toggles item active state - * - * @param isActive - true if item should strictly should become active - */ - public toggleActive(isActive?: boolean): void { - this.nodes.root?.classList.toggle(css.active, isActive); - } + public abstract getElement(): HTMLElement | null; /** * Toggles item hidden state * * @param isHidden - true if item should be hidden */ - public toggleHidden(isHidden: boolean): void { - this.nodes.root?.classList.toggle(css.hidden, isHidden); - } - - /** - * Resets popover item to its original state - */ - public reset(): void { - if (this.isConfirmationStateEnabled) { - this.disableConfirmationMode(); - } - } - - /** - * Method called once item becomes focused during keyboard navigation - */ - public onFocus(): void { - this.disableSpecialHoverAndFocusBehavior(); - } - - /** - * Returns list of item children - */ - public get children(): PopoverItemParams[] { - return 'children' in this.params && this.params.children?.items !== undefined ? this.params.children.items : []; - } - - /** - * Constructs HTML element corresponding to popover item params - * - * @param params - item construction params - */ - private make(params: PopoverItemParams): HTMLElement { - const el = Dom.make('div', css.container); - - if (params.name) { - el.dataset.itemName = params.name; - } - - this.nodes.icon = Dom.make('div', [css.icon, css.iconTool], { - innerHTML: params.icon || IconDotCircle, - }); - - el.appendChild(this.nodes.icon); - - el.appendChild(Dom.make('div', css.title, { - innerHTML: params.title || '', - })); - - if (params.secondaryLabel) { - el.appendChild(Dom.make('div', css.secondaryTitle, { - textContent: params.secondaryLabel, - })); - } - - if (this.children.length > 0) { - el.appendChild(Dom.make('div', [css.icon, css.iconChevronRight], { - innerHTML: IconChevronRight, - })); - } - - if (params.isActive) { - el.classList.add(css.active); - } - - if (params.isDisabled) { - el.classList.add(css.disabled); - } - - return el; - } - - /** - * Activates confirmation mode for the item. - * - * @param newState - new popover item params that should be applied - */ - private enableConfirmationMode(newState: PopoverItemParams): void { - if (this.nodes.root === null) { - return; - } - - const params = { - ...this.params, - ...newState, - confirmation: newState.confirmation, - } as PopoverItemParams; - const confirmationEl = this.make(params); - - this.nodes.root.innerHTML = confirmationEl.innerHTML; - this.nodes.root.classList.add(css.confirmationState); - - this.confirmationState = newState; - - this.enableSpecialHoverAndFocusBehavior(); - } - - /** - * Returns item to its original state - */ - private disableConfirmationMode(): void { - if (this.nodes.root === null) { - return; - } - const itemWithOriginalParams = this.make(this.params); - - this.nodes.root.innerHTML = itemWithOriginalParams.innerHTML; - this.nodes.root.classList.remove(css.confirmationState); - - this.confirmationState = null; - - this.disableSpecialHoverAndFocusBehavior(); - } - - /** - * Enables special focus and hover behavior for item in confirmation state. - * This is needed to prevent item from being highlighted as hovered/focused just after click. - */ - private enableSpecialHoverAndFocusBehavior(): void { - this.nodes.root?.classList.add(css.noHover); - this.nodes.root?.classList.add(css.noFocus); - - this.nodes.root?.addEventListener('mouseleave', this.removeSpecialHoverBehavior, { once: true }); - } - - /** - * Disables special focus and hover behavior - */ - private disableSpecialHoverAndFocusBehavior(): void { - this.removeSpecialFocusBehavior(); - this.removeSpecialHoverBehavior(); - - this.nodes.root?.removeEventListener('mouseleave', this.removeSpecialHoverBehavior); - } - - /** - * Removes class responsible for special focus behavior on an item - */ - private removeSpecialFocusBehavior = (): void => { - this.nodes.root?.classList.remove(css.noFocus); - }; - - /** - * Removes class responsible for special hover behavior on an item - */ - private removeSpecialHoverBehavior = (): void => { - this.nodes.root?.classList.remove(css.noHover); - }; - - /** - * Executes item's onActivate callback if the item has no confirmation configured - * - * @param item - item to activate or bring to confirmation mode - */ - private activateOrEnableConfirmationMode(item: PopoverItemParams): void { - if (item.confirmation === undefined) { - try { - item.onActivate?.(item); - this.disableConfirmationMode(); - } catch { - this.animateError(); - } - } else { - this.enableConfirmationMode(item.confirmation); - } - } - - /** - * Animates item which symbolizes that error occured while executing 'onActivate()' callback - */ - private animateError(): void { - if (this.nodes.icon?.classList.contains(css.wobbleAnimation)) { - return; - } - - this.nodes.icon?.classList.add(css.wobbleAnimation); - - this.nodes.icon?.addEventListener('animationend', this.onErrorAnimationEnd); - } - - /** - * Handles finish of error animation - */ - private onErrorAnimationEnd = (): void => { - this.nodes.icon?.classList.remove(css.wobbleAnimation); - this.nodes.icon?.removeEventListener('animationend', this.onErrorAnimationEnd); - }; + public abstract toggleHidden(isHidden: boolean): void; } diff --git a/types/configs/popover.d.ts b/src/components/utils/popover/components/popover-item/popover-item.types.ts similarity index 56% rename from types/configs/popover.d.ts rename to src/components/utils/popover/components/popover-item/popover-item.types.ts index ab53e521f..15ea856b6 100644 --- a/types/configs/popover.d.ts +++ b/src/components/utils/popover/components/popover-item/popover-item.types.ts @@ -1,7 +1,24 @@ + +/** + * Represents popover item separator. + * Special item type that is used to separate items in the popover. + */ +export interface PopoverItemSeparatorParams { + /** + * Item type + */ + type: 'separator' +} + /** - * Common parameters for both types of popover items: with or without confirmation + * Common parameters for all kinds of default popover items: with or without confirmation */ -interface PopoverItemBase { +interface PopoverItemDefaultBaseParams { + /** + * Item type + */ + type: 'default'; + /** * Displayed text */ @@ -39,8 +56,8 @@ interface PopoverItemBase { name?: string; /** - * Defines whether item should toggle on click. - * Can be represented as boolean value or a string key. + * Defines whether item should toggle on click. + * Can be represented as boolean value or a string key. * In case of string, works like radio buttons group and highlights as inactive any other item that has same toggle key value. */ toggle?: boolean | string; @@ -49,12 +66,12 @@ interface PopoverItemBase { /** * Represents popover item with confirmation state configuration */ -export interface PopoverItemWithConfirmation extends PopoverItemBase { +export interface PopoverItemWithConfirmationParams extends PopoverItemDefaultBaseParams { /** * Popover item parameters that should be applied on item activation. * May be used to ask user for confirmation before executing popover item activation handler. */ - confirmation: PopoverItem; + confirmation: PopoverItemDefaultParams; onActivate?: never; } @@ -62,7 +79,7 @@ export interface PopoverItemWithConfirmation extends PopoverItemBase { /** * Represents popover item without confirmation state configuration */ -export interface PopoverItemWithoutConfirmation extends PopoverItemBase { +export interface PopoverItemWithoutConfirmationParams extends PopoverItemDefaultBaseParams { confirmation?: never; /** @@ -71,7 +88,7 @@ export interface PopoverItemWithoutConfirmation extends PopoverItemBase { * @param item - activated item * @param event - event that initiated item activation */ - onActivate: (item: PopoverItem, event?: PointerEvent) => void; + onActivate: (item: PopoverItemParams, event?: PointerEvent) => void; } @@ -79,7 +96,7 @@ export interface PopoverItemWithoutConfirmation extends PopoverItemBase { /** * Represents popover item with children (nested popover items) */ -export interface PopoverItemWithChildren extends PopoverItemBase { +export interface PopoverItemWithChildrenParams extends PopoverItemDefaultBaseParams { confirmation?: never; onActivate?: never; @@ -87,12 +104,20 @@ export interface PopoverItemWithChildren extends PopoverItemBase { * Items of nested popover that should be open on the current item hover/click (depending on platform) */ children?: { - items: PopoverItem[] + items: PopoverItemParams[] } } +/** + * Default, non-separator popover item type + */ +export type PopoverItemDefaultParams = + PopoverItemWithConfirmationParams | + PopoverItemWithoutConfirmationParams | + PopoverItemWithChildrenParams; + /** * Represents single popover item */ -export type PopoverItem = PopoverItemWithConfirmation | PopoverItemWithoutConfirmation | PopoverItemWithChildren +export type PopoverItemParams = PopoverItemDefaultParams | PopoverItemSeparatorParams; diff --git a/src/components/utils/popover/components/search-input/search-input.ts b/src/components/utils/popover/components/search-input/search-input.ts index 49db1061a..b726ce5a5 100644 --- a/src/components/utils/popover/components/search-input/search-input.ts +++ b/src/components/utils/popover/components/search-input/search-input.ts @@ -1,13 +1,14 @@ import Dom from '../../../../dom'; import Listeners from '../../../listeners'; import { IconSearch } from '@codexteam/icons'; -import { SearchableItem } from './search-input.types'; +import { SearchInputEvent, SearchInputEventMap, SearchableItem } from './search-input.types'; import { css } from './search-input.const'; +import EventsDispatcher from '../../../events'; /** * Provides search input element and search logic */ -export class SearchInput { +export class SearchInput extends EventsDispatcher { /** * Input wrapper element */ @@ -33,25 +34,19 @@ export class SearchInput { */ private searchQuery: string | undefined; - /** - * Externally passed callback for the search - */ - private readonly onSearch: (query: string, items: SearchableItem[]) => void; - /** * @param options - available config * @param options.items - searchable items list - * @param options.onSearch - search callback * @param options.placeholder - input placeholder */ - constructor({ items, onSearch, placeholder }: { + constructor({ items, placeholder }: { items: SearchableItem[]; - onSearch: (query: string, items: SearchableItem[]) => void; placeholder?: string; }) { + super(); + this.listeners = new Listeners(); this.items = items; - this.onSearch = onSearch; /** Build ui */ this.wrapper = Dom.make('div', css.wrapper); @@ -76,7 +71,10 @@ export class SearchInput { this.listeners.on(this.input, 'input', () => { this.searchQuery = this.input.value; - this.onSearch(this.searchQuery, this.foundItems); + this.emit(SearchInputEvent.Search, { + query: this.searchQuery, + items: this.foundItems, + }); }); } @@ -101,7 +99,10 @@ export class SearchInput { this.input.value = ''; this.searchQuery = ''; - this.onSearch('', this.foundItems); + this.emit(SearchInputEvent.Search, { + query: '', + items: this.foundItems, + }); } /** diff --git a/src/components/utils/popover/components/search-input/search-input.types.ts b/src/components/utils/popover/components/search-input/search-input.types.ts index bbe78f8f5..ecddc47b8 100644 --- a/src/components/utils/popover/components/search-input/search-input.types.ts +++ b/src/components/utils/popover/components/search-input/search-input.types.ts @@ -7,3 +7,24 @@ export interface SearchableItem { */ title?: string; } + + +/** + * Event that can be triggered by the Search Input + */ +export enum SearchInputEvent { + /** + * When search quert applied + */ + Search = 'search' +} + +/** + * Events fired by the Search Input + */ +export interface SearchInputEventMap { + /** + * Fired when search quert applied + */ + [SearchInputEvent.Search]: { query: string; items: SearchableItem[]}; +} diff --git a/src/components/utils/popover/index.ts b/src/components/utils/popover/index.ts index 6299dee92..6c2cbb265 100644 --- a/src/components/utils/popover/index.ts +++ b/src/components/utils/popover/index.ts @@ -1,6 +1,8 @@ import { PopoverDesktop } from './popover-desktop'; import { PopoverMobile } from './popover-mobile'; + export * from './popover.types'; +export * from './components/popover-item/popover-item.types'; /** * Union type for all popovers diff --git a/src/components/utils/popover/popover-abstract.ts b/src/components/utils/popover/popover-abstract.ts index c97b08d2e..0191dcd64 100644 --- a/src/components/utils/popover/popover-abstract.ts +++ b/src/components/utils/popover/popover-abstract.ts @@ -1,10 +1,11 @@ -import { PopoverItem } from './components/popover-item'; +import { PopoverItem, PopoverItemDefault, PopoverItemSeparator } from './components/popover-item'; import Dom from '../../dom'; -import { SearchInput, SearchableItem } from './components/search-input'; +import { SearchInput, SearchInputEvent, SearchableItem } from './components/search-input'; import EventsDispatcher from '../events'; import Listeners from '../listeners'; import { PopoverEventMap, PopoverMessages, PopoverParams, PopoverEvent, PopoverNodes } from './popover.types'; import { css } from './popover.const'; +import { PopoverItemParams } from './components/popover-item'; /** * Class responsible for rendering popover and handling its behaviour @@ -13,7 +14,7 @@ export abstract class PopoverAbstract /** * List of popover items */ - protected items: PopoverItem[]; + protected items: Array; /** * Listeners util instance @@ -25,10 +26,18 @@ export abstract class PopoverAbstract */ protected nodes: Nodes; + /** + * List of usual interactive popover items that can be clicked, hovered, etc. + * (excluding separators) + */ + protected get itemsInteractive(): PopoverItemDefault[] { + return this.items.filter(item => item instanceof PopoverItemDefault) as PopoverItemDefault[]; + } + /** * Instance of the Search Input */ - private search: SearchInput | undefined; + protected search: SearchInput | undefined; /** * Messages that will be displayed in popover @@ -46,7 +55,7 @@ export abstract class PopoverAbstract constructor(protected readonly params: PopoverParams) { super(); - this.items = params.items.map(item => new PopoverItem(item)); + this.items = this.buildItems(params.items); if (params.messages) { this.messages = { @@ -122,7 +131,7 @@ export abstract class PopoverAbstract this.nodes.popover.classList.remove(css.popoverOpened); this.nodes.popover.classList.remove(css.popoverOpenTop); - this.items.forEach(item => item.reset()); + this.itemsInteractive.forEach(item => item.reset()); if (this.search !== undefined) { this.search.clear(); @@ -139,29 +148,28 @@ export abstract class PopoverAbstract } /** - * Handles input inside search field + * Factory method for creating popover items * - * @param query - search query text - * @param result - search results + * @param items - list of items params */ - protected onSearch = (query: string, result: SearchableItem[]): void => { - this.items.forEach(item => { - const isHidden = !result.includes(item); - - item.toggleHidden(isHidden); + protected buildItems(items: PopoverItemParams[]): Array { + return items.map(item => { + switch (item.type) { + case 'separator': + return new PopoverItemSeparator(); + default: + return new PopoverItemDefault(item); + } }); - this.toggleNothingFoundMessage(result.length === 0); - this.toggleCustomContent(query !== ''); - }; - + } /** * Retrieves popover item that is the target of the specified event * * @param event - event to retrieve popover item from */ - protected getTargetItem(event: Event): PopoverItem | undefined { - return this.items.find(el => { + protected getTargetItem(event: Event): PopoverItemDefault | undefined { + return this.itemsInteractive.find(el => { const itemEl = el.getElement(); if (itemEl === null) { @@ -172,16 +180,44 @@ export abstract class PopoverAbstract }); } + /** + * Handles input inside search field + * + * @param data - search input event data + * @param data.query - search query text + * @param data.result - search results + */ + private onSearch = (data: { query: string, items: SearchableItem[] }): void => { + const isEmptyQuery = data.query === ''; + const isNothingFound = data.items.length === 0; + + this.items + .forEach((item) => { + let isHidden = false; + + if (item instanceof PopoverItemDefault) { + isHidden = !data.items.includes(item); + } else if (item instanceof PopoverItemSeparator) { + /** Should hide separators if nothing found message displayed or if there is some search query applied */ + isHidden = isNothingFound || !isEmptyQuery; + } + item.toggleHidden(isHidden); + }); + this.toggleNothingFoundMessage(isNothingFound); + this.toggleCustomContent(isEmptyQuery); + }; + /** * Adds search to the popover */ private addSearch(): void { this.search = new SearchInput({ - items: this.items, + items: this.itemsInteractive, placeholder: this.messages.search, - onSearch: this.onSearch, }); + this.search.on(SearchInputEvent.Search, this.onSearch); + const searchElement = this.search.getElement(); searchElement.classList.add(css.search); @@ -223,7 +259,7 @@ export abstract class PopoverAbstract } /** Cleanup other items state */ - this.items.filter(x => x !== item).forEach(x => x.reset()); + this.itemsInteractive.filter(x => x !== item).forEach(x => x.reset()); item.handleClick(); @@ -260,13 +296,13 @@ export abstract class PopoverAbstract * * @param clickedItem - popover item that was clicked */ - private toggleItemActivenessIfNeeded(clickedItem: PopoverItem): void { + private toggleItemActivenessIfNeeded(clickedItem: PopoverItemDefault): void { if (clickedItem.toggle === true) { clickedItem.toggleActive(); } if (typeof clickedItem.toggle === 'string') { - const itemsInToggleGroup = this.items.filter(item => item.toggle === clickedItem.toggle); + const itemsInToggleGroup = this.itemsInteractive.filter(item => item.toggle === clickedItem.toggle); /** If there's only one item in toggle group, toggle it */ if (itemsInToggleGroup.length === 1) { @@ -287,5 +323,5 @@ export abstract class PopoverAbstract * * @param item – item to show nested popover for */ - protected abstract showNestedItems(item: PopoverItem): void; + protected abstract showNestedItems(item: PopoverItemDefault): void; } diff --git a/src/components/utils/popover/popover-desktop.ts b/src/components/utils/popover/popover-desktop.ts index df3373494..8e056eaa7 100644 --- a/src/components/utils/popover/popover-desktop.ts +++ b/src/components/utils/popover/popover-desktop.ts @@ -4,8 +4,9 @@ import { PopoverItem, css as popoverItemCls } from './components/popover-item'; import { PopoverParams } from './popover.types'; import { keyCodes } from '../../utils'; import { css } from './popover.const'; -import { SearchableItem } from './components/search-input'; +import { SearchInputEvent, SearchableItem } from './components/search-input'; import { cacheable } from '../../utils'; +import { PopoverItemDefault } from './components/popover-item'; /** * Desktop popover. @@ -86,6 +87,8 @@ export class PopoverDesktop extends PopoverAbstract { }); this.flipper.onFlip(this.onFlip); + + this.search?.on(SearchInputEvent.Search, this.handleSearch); } /** @@ -161,16 +164,28 @@ export class PopoverDesktop extends PopoverAbstract { } /** - * Handles input inside search field + * Handles displaying nested items for the item. * - * @param query - search query text - * @param result - search results + * @param item – item to show nested popover for */ - protected override onSearch = (query: string, result: SearchableItem[]): void => { - super.onSearch(query, result); + protected override showNestedItems(item: PopoverItemDefault): void { + if (this.nestedPopover !== null && this.nestedPopover !== undefined) { + return; + } + this.showNestedPopoverForItem(item); + } + /** + * Additionaly handles input inside search field. + * Updates flipper items considering search query applied. + * + * @param data - search event data + * @param data.query - search query text + * @param data.result - search results + */ + private handleSearch = (data: { query: string, items: SearchableItem[] }): void => { /** List of elements available for keyboard navigation considering search query applied */ - const flippableElements = query === '' ? this.flippableElements : result.map(item => (item as PopoverItem).getElement()); + const flippableElements = data.query === '' ? this.flippableElements : data.items.map(item => (item as PopoverItem).getElement()); if (this.flipper.isActivated) { /** Update flipper items with only visible */ @@ -179,18 +194,6 @@ export class PopoverDesktop extends PopoverAbstract { } }; - /** - * Handles displaying nested items for the item. - * - * @param item – item to show nested popover for - */ - protected override showNestedItems(item: PopoverItem): void { - if (this.nestedPopover !== null && this.nestedPopover !== undefined) { - return; - } - this.showNestedPopoverForItem(item); - } - /** * Checks if popover should be opened bottom. * It should happen when there is enough space below or not enough space above @@ -283,7 +286,7 @@ export class PopoverDesktop extends PopoverAbstract { * Contains both usual popover items elements and custom html content. */ private get flippableElements(): HTMLElement[] { - const popoverItemsElements = this.items.map(item => item.getElement()); + const popoverItemsElements = this.itemsInteractive.map(item => item.getElement()); const customContentControlsElements = this.customContentFlippableItems || []; /** @@ -296,7 +299,7 @@ export class PopoverDesktop extends PopoverAbstract { * Called on flipper navigation */ private onFlip = (): void => { - const focusedItem = this.items.find(item => item.isFocused); + const focusedItem = this.itemsInteractive.find(item => item.isFocused); focusedItem?.onFocus(); }; @@ -307,7 +310,7 @@ export class PopoverDesktop extends PopoverAbstract { * * @param item - item to display nested popover by */ - private showNestedPopoverForItem(item: PopoverItem): void { + private showNestedPopoverForItem(item: PopoverItemDefault): void { this.nestedPopover = new PopoverDesktop({ items: item.children, nestingLevel: this.nestingLevel + 1, diff --git a/src/components/utils/popover/popover-mobile.ts b/src/components/utils/popover/popover-mobile.ts index ac0e7ae1d..5dd324d85 100644 --- a/src/components/utils/popover/popover-mobile.ts +++ b/src/components/utils/popover/popover-mobile.ts @@ -3,8 +3,7 @@ import ScrollLocker from '../scroll-locker'; import { PopoverHeader } from './components/popover-header'; import { PopoverStatesHistory } from './utils/popover-states-history'; import { PopoverMobileNodes, PopoverParams } from './popover.types'; -import { PopoverItem } from './components/popover-item'; -import { PopoverItem as PopoverItemParams } from '../../../../types'; +import { PopoverItemDefault, PopoverItemParams } from './components/popover-item'; import { css } from './popover.const'; import Dom from '../../dom'; @@ -87,7 +86,7 @@ export class PopoverMobile extends PopoverAbstract { * * @param item – item to show nested popover for */ - protected override showNestedItems(item: PopoverItem): void { + protected override showNestedItems(item: PopoverItemDefault): void { /** Show nested items */ this.updateItemsAndHeader(item.children, item.title); @@ -128,7 +127,7 @@ export class PopoverMobile extends PopoverAbstract { /** Re-render items */ this.items.forEach(item => item.getElement()?.remove()); - this.items = items.map(params => new PopoverItem(params)); + this.items = this.buildItems(items); this.items.forEach(item => { const itemEl = item.getElement(); diff --git a/src/components/utils/popover/popover.types.ts b/src/components/utils/popover/popover.types.ts index 515ec4363..8b52c54e4 100644 --- a/src/components/utils/popover/popover.types.ts +++ b/src/components/utils/popover/popover.types.ts @@ -1,4 +1,4 @@ -import { PopoverItem as PopoverItemParams } from '../../../../types'; +import { PopoverItemParams } from '../../../../types'; /** * Params required to render popover diff --git a/src/styles/popover.css b/src/styles/popover.css index a59826384..3a99fe165 100644 --- a/src/styles/popover.css +++ b/src/styles/popover.css @@ -194,7 +194,23 @@ /** * Popover item styles */ -.ce-popover-item { + + + .ce-popover-item-separator { + padding: 4px 3px; + + &--hidden { + display: none; + } + + &__line { + height: 1px; + background: var(--color-border); + width: 100%; + } + } + + .ce-popover-item { --border-radius: 6px; border-radius: var(--border-radius); display: flex; diff --git a/test/cypress/tests/utils/popover.cy.ts b/test/cypress/tests/utils/popover.cy.ts index 1e5f20325..7103ec713 100644 --- a/test/cypress/tests/utils/popover.cy.ts +++ b/test/cypress/tests/utils/popover.cy.ts @@ -1,5 +1,5 @@ import { PopoverDesktop as Popover } from '../../../../src/components/utils/popover'; -import { PopoverItem } from '../../../../types'; +import { PopoverItemParams } from '../../../../types'; import { TunesMenuConfig } from '../../../../types/tools'; /* eslint-disable @typescript-eslint/no-empty-function */ @@ -15,14 +15,16 @@ describe('Popover', () => { * Confirmation is moved to separate variable to be able to test it's callback execution. * (Inside popover null value is set to confirmation property, so, object becomes unavailable otherwise) */ - const confirmation = { + const confirmation: PopoverItemParams = { + type: 'default', icon: confirmActionIcon, title: confirmActionTitle, onActivate: cy.stub(), }; - const items: PopoverItem[] = [ + const items: PopoverItemParams[] = [ { + type: 'default', icon: actionIcon, title: actionTitle, name: 'testItem', @@ -69,8 +71,9 @@ describe('Popover', () => { }); it('should render the items with true isActive property value as active', () => { - const items: PopoverItem[] = [ + const items = [ { + type: 'default', icon: 'Icon', title: 'Title', isActive: true, @@ -93,8 +96,9 @@ describe('Popover', () => { }); it('should not execute item\'s onActivate callback if the item is disabled', () => { - const items: PopoverItem[] = [ + const items: PopoverItemParams[] = [ { + type: 'default', icon: 'Icon', title: 'Title', isDisabled: true, @@ -115,6 +119,9 @@ describe('Popover', () => { .should('have.class', 'ce-popover-item--disabled') .click() .then(() => { + if (items[0].type !== 'default') { + return; + } // Check onActivate callback has never been called expect(items[0].onActivate).to.have.not.been.called; }); @@ -122,8 +129,9 @@ describe('Popover', () => { }); it('should close once item with closeOnActivate property set to true is activated', () => { - const items: PopoverItem[] = [ + const items = [ { + type: 'default', icon: 'Icon', title: 'Title', closeOnActivate: true, @@ -149,8 +157,9 @@ describe('Popover', () => { }); it('should highlight as active the item with toggle property set to true once activated', () => { - const items: PopoverItem[] = [ + const items = [ { + type: 'default', icon: 'Icon', title: 'Title', toggle: true, @@ -173,8 +182,9 @@ describe('Popover', () => { }); it('should perform radiobutton-like behavior among the items that have toggle property value set to the same string value', () => { - const items: PopoverItem[] = [ + const items = [ { + type: 'default', icon: 'Icon 1', title: 'Title 1', toggle: 'group-name', @@ -183,6 +193,7 @@ describe('Popover', () => { onActivate: (): void => {}, }, { + type: 'default', icon: 'Icon 2', title: 'Title 2', toggle: 'group-name', @@ -218,8 +229,9 @@ describe('Popover', () => { }); it('should toggle item if it is the only item in toggle group', () => { - const items: PopoverItem[] = [ + const items = [ { + type: 'default', icon: 'Icon', title: 'Title', toggle: 'key', @@ -267,6 +279,7 @@ describe('Popover', () => { /** Tool data displayed in block tunes popover */ public render(): TunesMenuConfig { return { + type: 'default', icon: 'Icon', title: 'Title', toggle: 'key', @@ -274,6 +287,7 @@ describe('Popover', () => { children: { items: [ { + type: 'default', icon: 'Icon', title: 'Title', name: 'nested-test-item', @@ -343,6 +357,7 @@ describe('Popover', () => { /** Tool data displayed in block tunes popover */ public render(): TunesMenuConfig { return { + type: 'default', icon: 'Icon', title: 'Tune', toggle: 'key', @@ -350,6 +365,7 @@ describe('Popover', () => { children: { items: [ { + type: 'default', icon: 'Icon', title: 'Title', name: 'nested-test-item', @@ -441,4 +457,315 @@ describe('Popover', () => { .get('.ce-popover-header') .should('not.exist'); }); + + + it('should display default (non-separator) items without specifying type: default', () => { + /** Tool class to test how it is displayed inside block tunes popover */ + class TestTune { + public static isTune = true; + + /** Tool data displayed in block tunes popover */ + public render(): TunesMenuConfig { + return { + // @ts-expect-error type is not specified on purpose to test the back compatibility + onActivate: (): void => {}, + icon: 'Icon', + title: 'Tune', + toggle: 'key', + name: 'test-item', + }; + } + } + + + /** Create editor instance */ + cy.createEditor({ + tools: { + testTool: TestTune, + }, + tunes: [ 'testTool' ], + data: { + blocks: [ + { + type: 'paragraph', + data: { + text: 'Hello', + }, + }, + ], + }, + }); + + /** Open block tunes menu */ + cy.get('[data-cy=editorjs]') + .get('.cdx-block') + .click(); + + cy.get('[data-cy=editorjs]') + .get('.ce-toolbar__settings-btn') + .click(); + + /** Check item displayed */ + cy.get('[data-cy=editorjs]') + .get('.ce-popover__container') + .get('[data-item-name="test-item"]') + .should('be.visible'); + }); + + it('should display separator', () => { + /** Tool class to test how it is displayed inside block tunes popover */ + class TestTune { + public static isTune = true; + + /** Tool data displayed in block tunes popover */ + public render(): TunesMenuConfig { + return [ + { + type: 'default', + onActivate: (): void => {}, + icon: 'Icon', + title: 'Tune', + toggle: 'key', + name: 'test-item', + }, + { + type: 'separator', + }, + ]; + } + } + + + /** Create editor instance */ + cy.createEditor({ + tools: { + testTool: TestTune, + }, + tunes: [ 'testTool' ], + data: { + blocks: [ + { + type: 'paragraph', + data: { + text: 'Hello', + }, + }, + ], + }, + }); + + /** Open block tunes menu */ + cy.get('[data-cy=editorjs]') + .get('.cdx-block') + .click(); + + cy.get('[data-cy=editorjs]') + .get('.ce-toolbar__settings-btn') + .click(); + + /** Check item displayed */ + cy.get('[data-cy=editorjs]') + .get('.ce-popover__container') + .get('[data-item-name="test-item"]') + .should('be.visible'); + + /** Check separator displayed */ + cy.get('[data-cy=editorjs]') + .get('.ce-popover__container') + .get('.ce-popover-item-separator') + .should('be.visible'); + }); + + it('should perform keyboard navigation between items ignoring separators', () => { + /** Tool class to test how it is displayed inside block tunes popover */ + class TestTune { + public static isTune = true; + + /** Tool data displayed in block tunes popover */ + public render(): TunesMenuConfig { + return [ + { + type: 'default', + onActivate: (): void => {}, + icon: 'Icon', + title: 'Tune 1', + name: 'test-item-1', + }, + { + type: 'separator', + }, + { + type: 'default', + onActivate: (): void => {}, + icon: 'Icon', + title: 'Tune 2', + name: 'test-item-2', + }, + ]; + } + } + + /** Create editor instance */ + cy.createEditor({ + tools: { + testTool: TestTune, + }, + tunes: [ 'testTool' ], + data: { + blocks: [ + { + type: 'paragraph', + data: { + text: 'Hello', + }, + }, + ], + }, + }); + + /** Open block tunes menu */ + cy.get('[data-cy=editorjs]') + .get('.cdx-block') + .click(); + + cy.get('[data-cy=editorjs]') + .get('.ce-toolbar__settings-btn') + .click(); + + /** Press Tab */ + cy.tab(); + + /** Check first item is focused */ + cy.get('[data-cy=editorjs]') + .get('.ce-popover__container') + .get('[data-item-name="test-item-1"].ce-popover-item--focused') + .should('exist'); + + /** Check second item is not focused */ + cy.get('[data-cy=editorjs]') + .get('.ce-popover__container') + .get('[data-item-name="test-item-2"].ce-popover-item--focused') + .should('not.exist'); + + /** Press Tab */ + cy.tab(); + + /** Check first item is not focused */ + cy.get('[data-cy=editorjs]') + .get('.ce-popover__container') + .get('[data-item-name="test-item-1"].ce-popover-item--focused') + .should('not.exist'); + + /** Check second item is focused */ + cy.get('[data-cy=editorjs]') + .get('.ce-popover__container') + .get('[data-item-name="test-item-2"].ce-popover-item--focused') + .should('exist'); + }); + + it('should perform keyboard navigation between items ignoring separators when search query is applied', () => { + /** Tool class to test how it is displayed inside block tunes popover */ + class TestTune { + public static isTune = true; + + /** Tool data displayed in block tunes popover */ + public render(): TunesMenuConfig { + return [ + { + type: 'default', + onActivate: (): void => {}, + icon: 'Icon', + title: 'Tune 1', + name: 'test-item-1', + }, + { + type: 'separator', + }, + { + type: 'default', + onActivate: (): void => {}, + icon: 'Icon', + title: 'Tune 2', + name: 'test-item-2', + }, + ]; + } + } + + /** Create editor instance */ + cy.createEditor({ + tools: { + testTool: TestTune, + }, + tunes: [ 'testTool' ], + data: { + blocks: [ + { + type: 'paragraph', + data: { + text: 'Hello', + }, + }, + ], + }, + }); + + /** Open block tunes menu */ + cy.get('[data-cy=editorjs]') + .get('.cdx-block') + .click(); + + cy.get('[data-cy=editorjs]') + .get('.ce-toolbar__settings-btn') + .click(); + + /** Check separator displayed */ + cy.get('[data-cy=editorjs]') + .get('.ce-popover__container') + .get('.ce-popover-item-separator') + .should('be.visible'); + + /** Enter search query */ + cy.get('[data-cy=editorjs]') + .get('[data-cy=block-tunes] .cdx-search-field__input') + .type('Tune'); + + /** Check separator not displayed */ + cy.get('[data-cy=editorjs]') + .get('.ce-popover__container') + .get('.ce-popover-item-separator') + .should('not.be.visible'); + + /** Press Tab */ + // eslint-disable-next-line cypress/require-data-selectors -- cy.tab() not working here + cy.get('body').tab(); + + /** Check first item is focused */ + cy.get('[data-cy=editorjs]') + .get('.ce-popover__container') + .get('[data-item-name="test-item-1"].ce-popover-item--focused') + .should('exist'); + + /** Check second item is not focused */ + cy.get('[data-cy=editorjs]') + .get('.ce-popover__container') + .get('[data-item-name="test-item-2"].ce-popover-item--focused') + .should('not.exist'); + + /** Press Tab */ + // eslint-disable-next-line cypress/require-data-selectors -- cy.tab() not working here + cy.get('body').tab(); + + /** Check first item is not focused */ + cy.get('[data-cy=editorjs]') + .get('.ce-popover__container') + .get('[data-item-name="test-item-1"].ce-popover-item--focused') + .should('not.exist'); + + /** Check second item is focused */ + cy.get('[data-cy=editorjs]') + .get('.ce-popover__container') + .get('[data-item-name="test-item-2"].ce-popover-item--focused') + .should('exist'); + }); }); diff --git a/types/configs/index.d.ts b/types/configs/index.d.ts index 3b847a315..4468fca9a 100644 --- a/types/configs/index.d.ts +++ b/types/configs/index.d.ts @@ -5,4 +5,4 @@ export * from './conversion-config'; export * from './log-levels'; export * from './i18n-config'; export * from './i18n-dictionary'; -export * from './popover' +export * from '../../src/components/utils/popover'; diff --git a/types/index.d.ts b/types/index.d.ts index c26aa2232..fc38802b9 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -77,10 +77,15 @@ export { Dictionary, DictValue, I18nConfig, - PopoverItem, - PopoverItemWithConfirmation, - PopoverItemWithoutConfirmation } from './configs'; + +export { + PopoverItemParams, + PopoverItemDefaultParams, + PopoverItemWithConfirmationParams, + PopoverItemWithoutConfirmationParams +} from '../src/components/utils/popover'; + export { OutputData, OutputBlockData} from './data-formats/output-data'; export { BlockId } from './data-formats/block-id'; export { BlockAPI } from './api' diff --git a/types/tools/tool-settings.d.ts b/types/tools/tool-settings.d.ts index fa26c882e..799224013 100644 --- a/types/tools/tool-settings.d.ts +++ b/types/tools/tool-settings.d.ts @@ -1,6 +1,6 @@ import { ToolConfig } from './tool-config'; import { ToolConstructable, BlockToolData } from './index'; -import { PopoverItem } from '../configs'; +import { PopoverItemDefaultParams, PopoverItemSeparatorParams, PopoverItemParams } from '../configs'; /** * Tool may specify its toolbox configuration @@ -28,11 +28,10 @@ export interface ToolboxConfigEntry { data?: BlockToolData } - /** - * Represents single Tunes Menu item + * Represents single interactive (non-separator) Tunes Menu item */ -export type TunesMenuConfigItem = PopoverItem & { +export type TunesMenuConfigDefaultItem = PopoverItemDefaultParams & { /** * Tune displayed text. */ @@ -50,9 +49,19 @@ export type TunesMenuConfigItem = PopoverItem & { * Menu item parameters that should be applied on item activation. * May be used to ask user for confirmation before executing menu item activation handler. */ - confirmation?: TunesMenuConfigItem; + confirmation?: TunesMenuConfigDefaultItem; } +/** + * Represents single separator Tunes Menu item + */ +export type TunesMenuConfigSeparatorItem = PopoverItemSeparatorParams; + +/** + * Union of all Tunes Menu item types + */ +export type TunesMenuConfigItem = TunesMenuConfigDefaultItem | TunesMenuConfigSeparatorItem; + /** * Tool may specify its tunes configuration * that can contain either one or multiple entries From 4118dc3aeaa218b3ac17b8767a3702a2fd9f71b9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 23 Apr 2024 22:52:31 +0300 Subject: [PATCH 14/61] Bump version (#2693) Co-authored-by: github-actions --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 4859556a5..89372203b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@editorjs/editorjs", - "version": "2.30.0-rc.3", + "version": "2.30.0-rc.4", "description": "Editor.js — Native JS, based on API and Open Source", "main": "dist/editorjs.umd.js", "module": "dist/editorjs.mjs", From 7821e35302a6854e1921e7f9507a08c9b2980fcb Mon Sep 17 00:00:00 2001 From: Tatiana Fomina Date: Sat, 27 Apr 2024 16:57:52 +0300 Subject: [PATCH 15/61] feat(block tunes): Conversion Menu in Block Tunes (#2692) * Support delimiter * Rename types, move types to popover-item folder * Fix ts errors * Add tests * Review fixes * Review fixes 2 * Fix delimiter while search * Fix flipper issue * Fix block tunes types * Fix types * tmp * Fixes * Make search input emit event * Fix types * Rename delimiter to separator * Update chengelog * Add convert to to block tunes * i18n * Lint * Fix tests * Fix tests 2 * Tests * Add caching * Rename * Fix for miltiple toolbox entries * Update changelog * Update changelog * Fix popover test * Fix flipper tests * Fix popover tests * Remove type: 'default' * Create isSameBlockData util * Add testcase --- docs/CHANGELOG.md | 1 + src/components/block/index.ts | 81 ++++++-- src/components/i18n/locales/en/messages.json | 3 +- .../modules/toolbar/blockSettings.ts | 135 ++++++++++++- src/components/utils/blocks.ts | 15 +- .../popover-item/popover-item.types.ts | 2 +- test/cypress/tests/ui/BlockTunes.cy.ts | 184 ++++++++++++++++++ test/cypress/tests/utils/flipper.cy.ts | 20 +- test/cypress/tests/utils/popover.cy.ts | 24 +-- 9 files changed, 407 insertions(+), 58 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index bb2414430..dae1953b6 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -4,6 +4,7 @@ – `New` – Block Tunes now supports nesting items – `New` – Block Tunes now supports separator items +– `New` – "Convert to" control is now also available in Block Tunes ### 2.30.0 diff --git a/src/components/block/index.ts b/src/components/block/index.ts index 576314718..803a50449 100644 --- a/src/components/block/index.ts +++ b/src/components/block/index.ts @@ -21,11 +21,11 @@ import BlockTune from '../tools/tune'; import { BlockTuneData } from '../../../types/block-tunes/block-tune-data'; import ToolsCollection from '../tools/collection'; import EventsDispatcher from '../utils/events'; -import { TunesMenuConfigItem } from '../../../types/tools'; +import { TunesMenuConfig, TunesMenuConfigItem } from '../../../types/tools'; import { isMutationBelongsToElement } from '../utils/mutations'; import { EditorEventMap, FakeCursorAboutToBeToggled, FakeCursorHaveBeenSet, RedactorDomChanged } from '../events'; import { RedactorDomChangedPayload } from '../events/RedactorDomChanged'; -import { convertBlockDataToString } from '../utils/blocks'; +import { convertBlockDataToString, isSameBlockData } from '../utils/blocks'; /** * Interface describes Block class constructor argument @@ -229,7 +229,6 @@ export default class Block extends EventsDispatcher { tunesData, }: BlockConstructorOptions, eventBus?: EventsDispatcher) { super(); - this.name = tool.name; this.id = id; this.settings = tool.settings; @@ -612,34 +611,60 @@ export default class Block extends EventsDispatcher { /** * Returns data to render in tunes menu. - * Splits block tunes settings into 2 groups: popover items and custom html. - */ - public getTunes(): [PopoverItemParams[], HTMLElement] { + * Splits block tunes into 3 groups: block specific tunes, common tunes + * and custom html that is produced by combining tunes html from both previous groups + */ + public getTunes(): { + toolTunes: PopoverItemParams[]; + commonTunes: PopoverItemParams[]; + customHtmlTunes: HTMLElement + } { const customHtmlTunesContainer = document.createElement('div'); - const tunesItems: TunesMenuConfigItem[] = []; + const commonTunesPopoverParams: TunesMenuConfigItem[] = []; /** Tool's tunes: may be defined as return value of optional renderSettings method */ const tunesDefinedInTool = typeof this.toolInstance.renderSettings === 'function' ? this.toolInstance.renderSettings() : []; + /** Separate custom html from Popover items params for tool's tunes */ + const { + items: toolTunesPopoverParams, + htmlElement: toolTunesHtmlElement, + } = this.getTunesDataSegregated(tunesDefinedInTool); + + if (toolTunesHtmlElement !== undefined) { + customHtmlTunesContainer.appendChild(toolTunesHtmlElement); + } + /** Common tunes: combination of default tunes (move up, move down, delete) and third-party tunes connected via tunes api */ const commonTunes = [ ...this.tunesInstances.values(), ...this.defaultTunesInstances.values(), ].map(tuneInstance => tuneInstance.render()); - [tunesDefinedInTool, commonTunes].flat().forEach(rendered => { - if ($.isElement(rendered)) { - customHtmlTunesContainer.appendChild(rendered); - } else if (Array.isArray(rendered)) { - tunesItems.push(...rendered); - } else { - tunesItems.push(rendered); + /** Separate custom html from Popover items params for common tunes */ + commonTunes.forEach(tuneConfig => { + const { + items, + htmlElement, + } = this.getTunesDataSegregated(tuneConfig); + + if (htmlElement !== undefined) { + customHtmlTunesContainer.appendChild(htmlElement); + } + + if (items !== undefined) { + commonTunesPopoverParams.push(...items); } }); - return [tunesItems, customHtmlTunesContainer]; + return { + toolTunes: toolTunesPopoverParams, + commonTunes: commonTunesPopoverParams, + customHtmlTunes: customHtmlTunesContainer, + }; } + /** * Update current input index with selection anchor node */ @@ -711,11 +736,8 @@ export default class Block extends EventsDispatcher { const blockData = await this.data; const toolboxItems = toolboxSettings; - return toolboxItems.find((item) => { - return Object.entries(item.data) - .some(([propName, propValue]) => { - return blockData[propName] && _.equals(blockData[propName], propValue); - }); + return toolboxItems?.find((item) => { + return isSameBlockData(item.data, blockData); }); } @@ -728,6 +750,25 @@ export default class Block extends EventsDispatcher { return convertBlockDataToString(blockData, this.tool.conversionConfig); } + /** + * Determines if tool's tunes settings are custom html or popover params and separates one from another by putting to different object fields + * + * @param tunes - tool's tunes config + */ + private getTunesDataSegregated(tunes: HTMLElement | TunesMenuConfig): { htmlElement?: HTMLElement; items: PopoverItemParams[] } { + const result = { } as { htmlElement?: HTMLElement; items: PopoverItemParams[] }; + + if ($.isElement(tunes)) { + result.htmlElement = tunes as HTMLElement; + } else if (Array.isArray(tunes)) { + result.items = tunes as PopoverItemParams[]; + } else { + result.items = [ tunes ]; + } + + return result; + } + /** * Make default Block wrappers and put Tool`s content there * diff --git a/src/components/i18n/locales/en/messages.json b/src/components/i18n/locales/en/messages.json index 32761be68..650a8b6d0 100644 --- a/src/components/i18n/locales/en/messages.json +++ b/src/components/i18n/locales/en/messages.json @@ -18,7 +18,8 @@ }, "popover": { "Filter": "", - "Nothing found": "" + "Nothing found": "", + "Convert to": "" } }, "toolNames": { diff --git a/src/components/modules/toolbar/blockSettings.ts b/src/components/modules/toolbar/blockSettings.ts index e43a072e2..3a2b7aa38 100644 --- a/src/components/modules/toolbar/blockSettings.ts +++ b/src/components/modules/toolbar/blockSettings.ts @@ -7,10 +7,13 @@ import { I18nInternalNS } from '../../i18n/namespace-internal'; import Flipper from '../../flipper'; import { TunesMenuConfigItem } from '../../../../types/tools'; import { resolveAliases } from '../../utils/resolve-aliases'; -import { type Popover, PopoverDesktop, PopoverMobile } from '../../utils/popover'; +import { type Popover, PopoverDesktop, PopoverMobile, PopoverItemParams, PopoverItemDefaultParams } from '../../utils/popover'; import { PopoverEvent } from '../../utils/popover/popover.types'; import { isMobileScreen } from '../../utils'; import { EditorMobileLayoutToggled } from '../../events'; +import * as _ from '../../utils'; +import { IconReplace } from '@codexteam/icons'; +import { isSameBlockData } from '../../utils/blocks'; /** * HTML Elements that used for BlockSettings @@ -105,7 +108,7 @@ export default class BlockSettings extends Module { * * @param targetBlock - near which Block we should open BlockSettings */ - public open(targetBlock: Block = this.Editor.BlockManager.currentBlock): void { + public async open(targetBlock: Block = this.Editor.BlockManager.currentBlock): Promise { this.opened = true; /** @@ -120,10 +123,8 @@ export default class BlockSettings extends Module { this.Editor.BlockSelection.selectBlock(targetBlock); this.Editor.BlockSelection.clearCache(); - /** - * Fill Tool's settings - */ - const [tunesItems, customHtmlTunesContainer] = targetBlock.getTunes(); + /** Get tool's settings data */ + const { toolTunes, commonTunes, customHtmlTunes } = targetBlock.getTunes(); /** Tell to subscribers that block settings is opened */ this.eventsDispatcher.emit(this.events.opened); @@ -132,9 +133,9 @@ export default class BlockSettings extends Module { this.popover = new PopoverClass({ searchable: true, - items: tunesItems.map(tune => this.resolveTuneAliases(tune)), - customContent: customHtmlTunesContainer, - customContentFlippableItems: this.getControls(customHtmlTunesContainer), + items: await this.getTunesItems(targetBlock, commonTunes, toolTunes), + customContent: customHtmlTunes, + customContentFlippableItems: this.getControls(customHtmlTunes), scopeElement: this.Editor.API.methods.ui.nodes.redactor, messages: { nothingFound: I18n.ui(I18nInternalNS.ui.popover, 'Nothing found'), @@ -197,6 +198,117 @@ export default class BlockSettings extends Module { } }; + /** + * Returns list of items to be displayed in block tunes menu. + * Merges tool specific tunes, conversion menu and common tunes in one list in predefined order + * + * @param currentBlock – block we are about to open block tunes for + * @param commonTunes – common tunes + * @param toolTunes - tool specific tunes + */ + private async getTunesItems(currentBlock: Block, commonTunes: TunesMenuConfigItem[], toolTunes?: TunesMenuConfigItem[]): Promise { + const items = [] as TunesMenuConfigItem[]; + + if (toolTunes !== undefined && toolTunes.length > 0) { + items.push(...toolTunes); + items.push({ + type: 'separator', + }); + } + + const convertToItems = await this.getConvertToItems(currentBlock); + + if (convertToItems.length > 0) { + items.push({ + icon: IconReplace, + title: I18n.ui(I18nInternalNS.ui.popover, 'Convert to'), + children: { + items: convertToItems, + }, + }); + items.push({ + type: 'separator', + }); + } + + items.push(...commonTunes); + + return items.map(tune => this.resolveTuneAliases(tune)); + } + + /** + * Returns list of all available conversion menu items + * + * @param currentBlock - block we are about to open block tunes for + */ + private async getConvertToItems(currentBlock: Block): Promise { + const conversionEntries = Array.from(this.Editor.Tools.blockTools.entries()); + + const resultItems: PopoverItemDefaultParams[] = []; + + const blockData = await currentBlock.data; + + conversionEntries.forEach(([toolName, tool]) => { + const conversionConfig = tool.conversionConfig; + + /** + * Skip tools without «import» rule specified + */ + if (!conversionConfig || !conversionConfig.import) { + return; + } + + tool.toolbox?.forEach((toolboxItem) => { + /** + * Skip tools that don't pass 'toolbox' property + */ + if (_.isEmpty(toolboxItem) || !toolboxItem.icon) { + return; + } + + let shouldSkip = false; + + if (toolboxItem.data !== undefined) { + /** + * When a tool has several toolbox entries, we need to make sure we do not add + * toolbox item with the same data to the resulting array. This helps exclude duplicates + */ + const hasSameData = isSameBlockData(toolboxItem.data, blockData); + + shouldSkip = hasSameData; + } else { + shouldSkip = toolName === currentBlock.name; + } + + + if (shouldSkip) { + return; + } + + resultItems.push({ + icon: toolboxItem.icon, + title: toolboxItem.title, + name: toolName, + onActivate: () => { + const { BlockManager, BlockSelection, Caret } = this.Editor; + + BlockManager.convert(this.Editor.BlockManager.currentBlock, toolName, toolboxItem.data); + + BlockSelection.clearSelection(); + + this.close(); + + window.requestAnimationFrame(() => { + Caret.setToBlock(this.Editor.BlockManager.currentBlock, Caret.positions.END); + }); + }, + }); + }); + }); + + return resultItems; + } + /** * Handles popover close event */ @@ -224,7 +336,10 @@ export default class BlockSettings extends Module { * * @param item - item with resolved aliases */ - private resolveTuneAliases(item: TunesMenuConfigItem): TunesMenuConfigItem { + private resolveTuneAliases(item: TunesMenuConfigItem): PopoverItemParams { + if (item.type === 'separator') { + return item; + } const result = resolveAliases(item, { label: 'title' }); if (item.confirmation) { diff --git a/src/components/utils/blocks.ts b/src/components/utils/blocks.ts index 288e0057e..9907c1be9 100644 --- a/src/components/utils/blocks.ts +++ b/src/components/utils/blocks.ts @@ -1,7 +1,8 @@ import type { ConversionConfig } from '../../../types/configs/conversion-config'; import type { BlockToolData } from '../../../types/tools/block-tool-data'; import type Block from '../block'; -import { isFunction, isString, log } from '../utils'; +import { isFunction, isString, log, equals } from '../utils'; + /** * Check if block has valid conversion config for export or import. @@ -19,6 +20,18 @@ export function isBlockConvertable(block: Block, direction: 'export' | 'import') return isFunction(conversionProp) || isString(conversionProp); } +/** + * Checks that all the properties of the first block data exist in second block data with the same values. + * + * @param data1 – first block data + * @param data2 – second block data + */ +export function isSameBlockData(data1: BlockToolData, data2: BlockToolData): boolean { + return Object.entries(data1).some((([propName, propValue]) => { + return data2[propName] && equals(data2[propName], propValue); + })); +} + /** * Check if two blocks could be merged. * diff --git a/src/components/utils/popover/components/popover-item/popover-item.types.ts b/src/components/utils/popover/components/popover-item/popover-item.types.ts index 15ea856b6..e9e7f95c4 100644 --- a/src/components/utils/popover/components/popover-item/popover-item.types.ts +++ b/src/components/utils/popover/components/popover-item/popover-item.types.ts @@ -17,7 +17,7 @@ interface PopoverItemDefaultBaseParams { /** * Item type */ - type: 'default'; + type?: 'default'; /** * Displayed text diff --git a/test/cypress/tests/ui/BlockTunes.cy.ts b/test/cypress/tests/ui/BlockTunes.cy.ts index f652d2c71..b9acd0279 100644 --- a/test/cypress/tests/ui/BlockTunes.cy.ts +++ b/test/cypress/tests/ui/BlockTunes.cy.ts @@ -1,4 +1,7 @@ import { selectionChangeDebounceTimeout } from '../../../../src/components/constants'; +import Header from '@editorjs/header'; +import { ToolboxConfig } from '../../../../types'; + describe('BlockTunes', function () { describe('Search', () => { @@ -104,4 +107,185 @@ describe('BlockTunes', function () { .should('have.class', 'ce-block--selected'); }); }); + + describe('Convert to', () => { + it('should display Convert to inside Block Tunes', () => { + cy.createEditor({ + tools: { + header: Header, + }, + data: { + blocks: [ + { + type: 'paragraph', + data: { + text: 'Some text', + }, + }, + ], + }, + }); + + /** Open block tunes menu */ + cy.get('[data-cy=editorjs]') + .get('.cdx-block') + .click(); + + cy.get('[data-cy=editorjs]') + .get('.ce-toolbar__settings-btn') + .click(); + + /** Check "Convert to" option is present */ + cy.get('[data-cy=editorjs]') + .get('.ce-popover-item') + .contains('Convert to') + .should('exist'); + + /** Click "Convert to" option*/ + cy.get('[data-cy=editorjs]') + .get('.ce-popover-item') + .contains('Convert to') + .click(); + + /** Check nected popover with "Heading" option is present */ + cy.get('[data-cy=editorjs]') + .get('.ce-popover--nested [data-item-name=header]') + .should('exist'); + }); + + it('should not display Convert to inside Block Tunes if there is nothing to convert to', () => { + /** Editor instance with single default tool */ + cy.createEditor({ + data: { + blocks: [ + { + type: 'paragraph', + data: { + text: 'Some text', + }, + }, + ], + }, + }); + + /** Open block tunes menu */ + cy.get('[data-cy=editorjs]') + .get('.cdx-block') + .click(); + + cy.get('[data-cy=editorjs]') + .get('.ce-toolbar__settings-btn') + .click(); + + /** Check "Convert to" option is not present */ + cy.get('[data-cy=editorjs]') + .get('.ce-popover-item') + .contains('Convert to') + .should('not.exist'); + }); + + it('should not display tool with the same data in "Convert to" menu', () => { + /** + * Tool with several toolbox entries configured + */ + class TestTool { + /** + * Tool is convertable + */ + public static get conversionConfig(): { import: string } { + return { + import: 'text', + }; + } + + /** + * TestTool contains several toolbox options + */ + public static get toolbox(): ToolboxConfig { + return [ + { + title: 'Title 1', + icon: 'Icon1', + data: { + level: 1, + }, + }, + { + title: 'Title 2', + icon: 'Icon2', + data: { + level: 2, + }, + }, + ]; + } + + /** + * Tool can render itself + */ + public render(): HTMLDivElement { + const div = document.createElement('div'); + + div.innerText = 'Some text'; + + return div; + } + + /** + * Tool can save it's data + */ + public save(): { text: string; level: number } { + return { + text: 'Some text', + level: 1, + }; + } + } + + /** Editor instance with TestTool installed and one block of TestTool type */ + cy.createEditor({ + tools: { + testTool: TestTool, + }, + data: { + blocks: [ + { + type: 'testTool', + data: { + text: 'Some text', + level: 1, + }, + }, + ], + }, + }); + + /** Open block tunes menu */ + cy.get('[data-cy=editorjs]') + .get('.ce-block') + .click(); + + cy.get('[data-cy=editorjs]') + .get('.ce-toolbar__settings-btn') + .click(); + + /** Open "Convert to" menu */ + cy.get('[data-cy=editorjs]') + .get('.ce-popover-item') + .contains('Convert to') + .click(); + + /** Check TestTool option with SAME data is NOT present */ + cy.get('[data-cy=editorjs]') + .get('.ce-popover--nested [data-item-name=testTool]') + .contains('Title 1') + .should('not.exist'); + + /** Check TestTool option with DIFFERENT data IS present */ + cy.get('[data-cy=editorjs]') + .get('.ce-popover--nested [data-item-name=testTool]') + .contains('Title 2') + .should('exist'); + }); + }); }); diff --git a/test/cypress/tests/utils/flipper.cy.ts b/test/cypress/tests/utils/flipper.cy.ts index 50037c9c8..1a91d81c3 100644 --- a/test/cypress/tests/utils/flipper.cy.ts +++ b/test/cypress/tests/utils/flipper.cy.ts @@ -1,4 +1,4 @@ -import { PopoverItem } from '../../../../types/index.js'; +import { PopoverItemParams } from '../../../../types/index.js'; /** * Mock of some Block Tool @@ -26,7 +26,7 @@ class SomePlugin { /** * Used to display our tool in the Toolbox */ - public static get toolbox(): PopoverItem { + public static get toolbox(): PopoverItemParams { return { icon: '₷', title: 'Some tool', @@ -34,6 +34,15 @@ class SomePlugin { onActivate: (): void => {}, }; } + + /** + * Extracts data from the plugin's UI + */ + public save(): {data: string} { + return { + data: '123', + }; + } } describe('Flipper', () => { @@ -71,15 +80,16 @@ describe('Flipper', () => { cy.get('[data-cy=editorjs]') .get('.cdx-some-plugin') // Open tunes menu - .trigger('keydown', { code: 'Slash', ctrlKey: true }) + .trigger('keydown', { code: 'Slash', + ctrlKey: true }) // Navigate to delete button (the second button) .trigger('keydown', { keyCode: ARROW_DOWN_KEY_CODE }) .trigger('keydown', { keyCode: ARROW_DOWN_KEY_CODE }); /** - * Check whether we focus the Delete Tune or not + * Check whether we focus the Move Up Tune or not */ - cy.get('[data-item-name="delete"]') + cy.get('[data-item-name="move-up"]') .should('have.class', 'ce-popover-item--focused'); cy.get('[data-cy=editorjs]') diff --git a/test/cypress/tests/utils/popover.cy.ts b/test/cypress/tests/utils/popover.cy.ts index 7103ec713..0d89f3bac 100644 --- a/test/cypress/tests/utils/popover.cy.ts +++ b/test/cypress/tests/utils/popover.cy.ts @@ -16,7 +16,6 @@ describe('Popover', () => { * (Inside popover null value is set to confirmation property, so, object becomes unavailable otherwise) */ const confirmation: PopoverItemParams = { - type: 'default', icon: confirmActionIcon, title: confirmActionTitle, onActivate: cy.stub(), @@ -24,7 +23,6 @@ describe('Popover', () => { const items: PopoverItemParams[] = [ { - type: 'default', icon: actionIcon, title: actionTitle, name: 'testItem', @@ -73,7 +71,6 @@ describe('Popover', () => { it('should render the items with true isActive property value as active', () => { const items = [ { - type: 'default', icon: 'Icon', title: 'Title', isActive: true, @@ -98,7 +95,6 @@ describe('Popover', () => { it('should not execute item\'s onActivate callback if the item is disabled', () => { const items: PopoverItemParams[] = [ { - type: 'default', icon: 'Icon', title: 'Title', isDisabled: true, @@ -131,7 +127,6 @@ describe('Popover', () => { it('should close once item with closeOnActivate property set to true is activated', () => { const items = [ { - type: 'default', icon: 'Icon', title: 'Title', closeOnActivate: true, @@ -159,7 +154,6 @@ describe('Popover', () => { it('should highlight as active the item with toggle property set to true once activated', () => { const items = [ { - type: 'default', icon: 'Icon', title: 'Title', toggle: true, @@ -184,7 +178,6 @@ describe('Popover', () => { it('should perform radiobutton-like behavior among the items that have toggle property value set to the same string value', () => { const items = [ { - type: 'default', icon: 'Icon 1', title: 'Title 1', toggle: 'group-name', @@ -193,7 +186,6 @@ describe('Popover', () => { onActivate: (): void => {}, }, { - type: 'default', icon: 'Icon 2', title: 'Title 2', toggle: 'group-name', @@ -231,7 +223,6 @@ describe('Popover', () => { it('should toggle item if it is the only item in toggle group', () => { const items = [ { - type: 'default', icon: 'Icon', title: 'Title', toggle: 'key', @@ -279,7 +270,6 @@ describe('Popover', () => { /** Tool data displayed in block tunes popover */ public render(): TunesMenuConfig { return { - type: 'default', icon: 'Icon', title: 'Title', toggle: 'key', @@ -287,7 +277,6 @@ describe('Popover', () => { children: { items: [ { - type: 'default', icon: 'Icon', title: 'Title', name: 'nested-test-item', @@ -357,7 +346,6 @@ describe('Popover', () => { /** Tool data displayed in block tunes popover */ public render(): TunesMenuConfig { return { - type: 'default', icon: 'Icon', title: 'Tune', toggle: 'key', @@ -365,7 +353,6 @@ describe('Popover', () => { children: { items: [ { - type: 'default', icon: 'Icon', title: 'Title', name: 'nested-test-item', @@ -521,7 +508,6 @@ describe('Popover', () => { public render(): TunesMenuConfig { return [ { - type: 'default', onActivate: (): void => {}, icon: 'Icon', title: 'Tune', @@ -585,7 +571,6 @@ describe('Popover', () => { public render(): TunesMenuConfig { return [ { - type: 'default', onActivate: (): void => {}, icon: 'Icon', title: 'Tune 1', @@ -595,7 +580,6 @@ describe('Popover', () => { type: 'separator', }, { - type: 'default', onActivate: (): void => {}, icon: 'Icon', title: 'Tune 2', @@ -633,7 +617,8 @@ describe('Popover', () => { .click(); /** Press Tab */ - cy.tab(); + // eslint-disable-next-line cypress/require-data-selectors -- cy.tab() not working here + cy.get('body').tab(); /** Check first item is focused */ cy.get('[data-cy=editorjs]') @@ -648,7 +633,8 @@ describe('Popover', () => { .should('not.exist'); /** Press Tab */ - cy.tab(); + // eslint-disable-next-line cypress/require-data-selectors -- cy.tab() not working here + cy.get('body').tab(); /** Check first item is not focused */ cy.get('[data-cy=editorjs]') @@ -672,7 +658,6 @@ describe('Popover', () => { public render(): TunesMenuConfig { return [ { - type: 'default', onActivate: (): void => {}, icon: 'Icon', title: 'Tune 1', @@ -682,7 +667,6 @@ describe('Popover', () => { type: 'separator', }, { - type: 'default', onActivate: (): void => {}, icon: 'Icon', title: 'Tune 2', From 844272656e09e264fa84ced294adde8b441ee9c6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 27 Apr 2024 16:59:52 +0300 Subject: [PATCH 16/61] Bump version (#2694) Co-authored-by: github-actions --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 89372203b..6df85acf2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@editorjs/editorjs", - "version": "2.30.0-rc.4", + "version": "2.30.0-rc.5", "description": "Editor.js — Native JS, based on API and Open Source", "main": "dist/editorjs.umd.js", "module": "dist/editorjs.mjs", From 1028577521a009a9694ef87570b35118cb95ff8b Mon Sep 17 00:00:00 2001 From: Peter Savchenko Date: Sat, 27 Apr 2024 21:04:26 +0300 Subject: [PATCH 17/61] fix(scroll): acidental scroll to top on iOS devices (#2695) * fix scroll on ios typing * Update tsconfig.json * Update CHANGELOG.md * Update CHANGELOG.md * Update package.json * Fix popover hide method to use isHidden flag --- docs/CHANGELOG.md | 6 ++---- package.json | 2 +- src/components/utils/popover/popover-mobile.ts | 13 +++++++++++++ src/components/utils/scroll-locker.ts | 2 +- 4 files changed, 17 insertions(+), 6 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index dae1953b6..f2bec70cd 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,18 +1,16 @@ # Changelog -### 2.30.1 +### 2.30.0 – `New` – Block Tunes now supports nesting items – `New` – Block Tunes now supports separator items – `New` – "Convert to" control is now also available in Block Tunes - -### 2.30.0 - - `Improvement` — The ability to merge blocks of different types (if both tools provide the conversionConfig) - `Fix` — `onChange` will be called when removing the entire text within a descendant element of a block. - `Fix` - Unexpected new line on Enter press with selected block without caret - `Fix` - Search input autofocus loosing after Block Tunes opening - `Fix` - Block removing while Enter press on Block Tunes +– `Fix` – Unwanted scroll on first typing on iOS devices ### 2.29.1 diff --git a/package.json b/package.json index 6df85acf2..3da27a8c1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@editorjs/editorjs", - "version": "2.30.0-rc.5", + "version": "2.30.0-rc.6", "description": "Editor.js — Native JS, based on API and Open Source", "main": "dist/editorjs.umd.js", "module": "dist/editorjs.mjs", diff --git a/src/components/utils/popover/popover-mobile.ts b/src/components/utils/popover/popover-mobile.ts index 5dd324d85..c309b95ca 100644 --- a/src/components/utils/popover/popover-mobile.ts +++ b/src/components/utils/popover/popover-mobile.ts @@ -30,6 +30,11 @@ export class PopoverMobile extends PopoverAbstract { */ private history = new PopoverStatesHistory(); + /** + * Flag that indicates if popover is hidden + */ + private isHidden = true; + /** * Construct the instance * @@ -58,18 +63,26 @@ export class PopoverMobile extends PopoverAbstract { super.show(); this.scrollLocker.lock(); + + this.isHidden = false; } /** * Closes popover */ public hide(): void { + if (this.isHidden) { + return; + } + super.hide(); this.nodes.overlay.classList.add(css.overlayHidden); this.scrollLocker.unlock(); this.history.reset(); + + this.isHidden = true; } /** diff --git a/src/components/utils/scroll-locker.ts b/src/components/utils/scroll-locker.ts index af9a5e860..cc97775c0 100644 --- a/src/components/utils/scroll-locker.ts +++ b/src/components/utils/scroll-locker.ts @@ -15,7 +15,7 @@ export default class ScrollLocker { /** * Stores scroll position, used for hard scroll lock */ - private scrollPosition: null|number; + private scrollPosition: null | number = null; /** * Locks body element scroll From c48fca1be3eb59ecc0b35d001ca2667a7a91f2cc Mon Sep 17 00:00:00 2001 From: Peter Savchenko Date: Sat, 27 Apr 2024 21:09:16 +0300 Subject: [PATCH 18/61] fix ios shift (#2696) --- docs/CHANGELOG.md | 1 + src/components/modules/blockEvents.ts | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index f2bec70cd..222ddad0d 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -11,6 +11,7 @@ - `Fix` - Search input autofocus loosing after Block Tunes opening - `Fix` - Block removing while Enter press on Block Tunes – `Fix` – Unwanted scroll on first typing on iOS devices +- `Fix` - Unwanted soft line break on Enter press after period and space (". |") on iOS devices ### 2.29.1 diff --git a/src/components/modules/blockEvents.ts b/src/components/modules/blockEvents.ts index 88b55ccb6..78f23336e 100644 --- a/src/components/modules/blockEvents.ts +++ b/src/components/modules/blockEvents.ts @@ -279,8 +279,12 @@ export default class BlockEvents extends Module { /** * Allow to create line breaks by Shift+Enter + * + * Note. On iOS devices, Safari automatically treats enter after a period+space (". |") as Shift+Enter + * (it used for capitalizing of the first letter of the next sentence) + * We don't need to lead soft line break in this case — new block should be created */ - if (event.shiftKey) { + if (event.shiftKey && !_.isIosDevice) { return; } From efa0a34f8e66a71704bded4fae4cc84ec6149919 Mon Sep 17 00:00:00 2001 From: Peter Savchenko Date: Sat, 27 Apr 2024 21:19:12 +0300 Subject: [PATCH 19/61] fix caret loosing after caret (#2697) --- docs/CHANGELOG.md | 1 + src/components/modules/blockManager.ts | 8 ++++---- src/components/modules/toolbar/blockSettings.ts | 8 +++----- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 222ddad0d..dcbd704f5 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -12,6 +12,7 @@ - `Fix` - Block removing while Enter press on Block Tunes – `Fix` – Unwanted scroll on first typing on iOS devices - `Fix` - Unwanted soft line break on Enter press after period and space (". |") on iOS devices +- `Fix` - Caret lost after block conversion on mobile devices. ### 2.29.1 diff --git a/src/components/modules/blockManager.ts b/src/components/modules/blockManager.ts index bfc9236be..2ab4a5f82 100644 --- a/src/components/modules/blockManager.ts +++ b/src/components/modules/blockManager.ts @@ -370,10 +370,10 @@ export default class BlockManager extends Module { * @param newTool - new Tool name * @param data - new Tool data */ - public replace(block: Block, newTool: string, data: BlockToolData): void { + public replace(block: Block, newTool: string, data: BlockToolData): Block { const blockIndex = this.getBlockIndex(block); - this.insert({ + return this.insert({ tool: newTool, data, index: blockIndex, @@ -821,7 +821,7 @@ export default class BlockManager extends Module { * @param targetToolName - name of the Tool to convert to * @param blockDataOverrides - optional new Block data overrides */ - public async convert(blockToConvert: Block, targetToolName: string, blockDataOverrides?: BlockToolData): Promise { + public async convert(blockToConvert: Block, targetToolName: string, blockDataOverrides?: BlockToolData): Promise { /** * At first, we get current Block data */ @@ -866,7 +866,7 @@ export default class BlockManager extends Module { newBlockData = Object.assign(newBlockData, blockDataOverrides); } - this.replace(blockToConvert, replacingTool.name, newBlockData); + return this.replace(blockToConvert, replacingTool.name, newBlockData); } /** diff --git a/src/components/modules/toolbar/blockSettings.ts b/src/components/modules/toolbar/blockSettings.ts index 3a2b7aa38..2d8983a90 100644 --- a/src/components/modules/toolbar/blockSettings.ts +++ b/src/components/modules/toolbar/blockSettings.ts @@ -289,18 +289,16 @@ export default class BlockSettings extends Module { icon: toolboxItem.icon, title: toolboxItem.title, name: toolName, - onActivate: () => { + onActivate: async () => { const { BlockManager, BlockSelection, Caret } = this.Editor; - BlockManager.convert(this.Editor.BlockManager.currentBlock, toolName, toolboxItem.data); + const newBlock = await BlockManager.convert(this.Editor.BlockManager.currentBlock, toolName, toolboxItem.data); BlockSelection.clearSelection(); this.close(); - window.requestAnimationFrame(() => { - Caret.setToBlock(this.Editor.BlockManager.currentBlock, Caret.positions.END); - }); + Caret.setToBlock(newBlock, Caret.positions.END); }, }); }); From 5eafda5ec474ae7d1891a201018ec88ece561b4a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 27 Apr 2024 21:22:12 +0300 Subject: [PATCH 20/61] Bump version (#2698) Co-authored-by: github-actions --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 3da27a8c1..c95ce33ae 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@editorjs/editorjs", - "version": "2.30.0-rc.6", + "version": "2.30.0-rc.7", "description": "Editor.js — Native JS, based on API and Open Source", "main": "dist/editorjs.umd.js", "module": "dist/editorjs.mjs", From 23858e002542c93d8d9debbc49c4bd60a1cf9073 Mon Sep 17 00:00:00 2001 From: Peter Savchenko Date: Mon, 29 Apr 2024 22:24:31 +0300 Subject: [PATCH 21/61] fix(conversion): restore caret after conversion though the Inline Toolbar and API (#2699) * fix caret loosing after caret * Refactor convert method to return Promise in Blocks API * changelog upd * Fix missing semicolon in blocks.cy.ts and BlockTunes.cy.ts * add test for inline toolbar conversion * Fix missing semicolon in InlineToolbar.cy.ts * add test for toolbox shortcut * api caret.setToBlock now can accept block api or index or id * eslint fix * Refactor test descriptions in caret.cy.ts * rm tsconfig change * lint * lint * Update CHANGELOG.md --- docs/CHANGELOG.md | 10 +- src/components/modules/api/blocks.ts | 8 +- src/components/modules/api/caret.ts | 17 +-- src/components/modules/toolbar/conversion.ts | 6 +- src/components/modules/toolbar/inline.ts | 4 + src/components/ui/toolbox.ts | 8 +- src/components/utils/api.ts | 21 ++++ test/cypress/tests/api/blocks.cy.ts | 47 +++++--- test/cypress/tests/api/caret.cy.ts | 113 ++++++++++++++++++ .../cypress/tests/modules/InlineToolbar.cy.ts | 54 +++++++++ test/cypress/tests/ui/BlockTunes.cy.ts | 56 +++++++++ test/cypress/tests/ui/toolbox.cy.ts | 17 ++- types/api/blocks.d.ts | 2 +- types/api/caret.d.ts | 10 +- 14 files changed, 326 insertions(+), 47 deletions(-) create mode 100644 src/components/utils/api.ts create mode 100644 test/cypress/tests/api/caret.cy.ts diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index dcbd704f5..ad17b0bfe 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,17 +2,19 @@ ### 2.30.0 -– `New` – Block Tunes now supports nesting items -– `New` – Block Tunes now supports separator items -– `New` – "Convert to" control is now also available in Block Tunes +- `New` – Block Tunes now supports nesting items +- `New` – Block Tunes now supports separator items +- `New` – "Convert to" control is now also available in Block Tunes - `Improvement` — The ability to merge blocks of different types (if both tools provide the conversionConfig) - `Fix` — `onChange` will be called when removing the entire text within a descendant element of a block. - `Fix` - Unexpected new line on Enter press with selected block without caret - `Fix` - Search input autofocus loosing after Block Tunes opening - `Fix` - Block removing while Enter press on Block Tunes -– `Fix` – Unwanted scroll on first typing on iOS devices +- `Fix` – Unwanted scroll on first typing on iOS devices - `Fix` - Unwanted soft line break on Enter press after period and space (". |") on iOS devices - `Fix` - Caret lost after block conversion on mobile devices. +- `Improvement` - The API `blocks.convert()` now returns the new block API +- `Improvement` - The API `caret.setToBlock()` now can accept either BlockAPI or block index or block id ### 2.29.1 diff --git a/src/components/modules/api/blocks.ts b/src/components/modules/api/blocks.ts index 1d6a782fe..02f23ff5a 100644 --- a/src/components/modules/api/blocks.ts +++ b/src/components/modules/api/blocks.ts @@ -1,4 +1,4 @@ -import { BlockAPI as BlockAPIInterface, Blocks } from '../../../../types/api'; +import type { BlockAPI as BlockAPIInterface, Blocks } from '../../../../types/api'; import { BlockToolData, OutputBlockData, OutputData, ToolConfig } from '../../../../types'; import * as _ from './../../utils'; import BlockAPI from '../../block/api'; @@ -327,7 +327,7 @@ export default class BlocksAPI extends Module { * @param dataOverrides - optional data overrides for the new block * @throws Error if conversion is not possible */ - private convert = (id: string, newType: string, dataOverrides?: BlockToolData): void => { + private convert = async (id: string, newType: string, dataOverrides?: BlockToolData): Promise => { const { BlockManager, Tools } = this.Editor; const blockToConvert = BlockManager.getBlockById(id); @@ -346,7 +346,9 @@ export default class BlocksAPI extends Module { const targetBlockConvertable = targetBlockTool.conversionConfig?.import !== undefined; if (originalBlockConvertable && targetBlockConvertable) { - BlockManager.convert(blockToConvert, newType, dataOverrides); + const newBlock = await BlockManager.convert(blockToConvert, newType, dataOverrides); + + return new BlockAPI(newBlock); } else { const unsupportedBlockTypes = [ !originalBlockConvertable ? capitalize(blockToConvert.name) : false, diff --git a/src/components/modules/api/caret.ts b/src/components/modules/api/caret.ts index 0e1046327..e889ea5f3 100644 --- a/src/components/modules/api/caret.ts +++ b/src/components/modules/api/caret.ts @@ -1,5 +1,6 @@ -import { Caret } from '../../../../types/api'; +import { BlockAPI, Caret } from '../../../../types/api'; import Module from '../../__module'; +import { resolveBlock } from '../../utils/api'; /** * @class CaretAPI @@ -96,21 +97,23 @@ export default class CaretAPI extends Module { /** * Sets caret to the Block by passed index * - * @param {number} index - index of Block where to set caret - * @param {string} position - position where to set caret - * @param {number} offset - caret offset + * @param blockOrIdOrIndex - either BlockAPI or Block id or Block index + * @param position - position where to set caret + * @param offset - caret offset * @returns {boolean} */ private setToBlock = ( - index: number, + blockOrIdOrIndex: BlockAPI | BlockAPI['id'] | number, position: string = this.Editor.Caret.positions.DEFAULT, offset = 0 ): boolean => { - if (!this.Editor.BlockManager.blocks[index]) { + const block = resolveBlock(blockOrIdOrIndex, this.Editor); + + if (block === undefined) { return false; } - this.Editor.Caret.setToBlock(this.Editor.BlockManager.blocks[index], position, offset); + this.Editor.Caret.setToBlock(block, position, offset); return true; }; diff --git a/src/components/modules/toolbar/conversion.ts b/src/components/modules/toolbar/conversion.ts index 759e15d32..4d7206d8e 100644 --- a/src/components/modules/toolbar/conversion.ts +++ b/src/components/modules/toolbar/conversion.ts @@ -183,16 +183,14 @@ export default class ConversionToolbar extends Module { public async replaceWithBlock(replacingToolName: string, blockDataOverrides?: BlockToolData): Promise { const { BlockManager, BlockSelection, InlineToolbar, Caret } = this.Editor; - BlockManager.convert(this.Editor.BlockManager.currentBlock, replacingToolName, blockDataOverrides); + const newBlock = await BlockManager.convert(this.Editor.BlockManager.currentBlock, replacingToolName, blockDataOverrides); BlockSelection.clearSelection(); this.close(); InlineToolbar.close(); - window.requestAnimationFrame(() => { - Caret.setToBlock(this.Editor.BlockManager.currentBlock, Caret.positions.END); - }); + Caret.setToBlock(newBlock, Caret.positions.END); } /** diff --git a/src/components/modules/toolbar/inline.ts b/src/components/modules/toolbar/inline.ts index c0209fe0c..006cf66fa 100644 --- a/src/components/modules/toolbar/inline.ts +++ b/src/components/modules/toolbar/inline.ts @@ -427,6 +427,10 @@ export default class InlineToolbar extends Module { this.nodes.togglerAndButtonsWrapper.appendChild(this.nodes.conversionToggler); + if (import.meta.env.MODE === 'test') { + this.nodes.conversionToggler.setAttribute('data-cy', 'conversion-toggler'); + } + this.listeners.on(this.nodes.conversionToggler, 'click', () => { this.Editor.ConversionToolbar.toggle((conversionToolbarOpened) => { /** diff --git a/src/components/ui/toolbox.ts b/src/components/ui/toolbox.ts index 60b25bf85..c50c7d138 100644 --- a/src/components/ui/toolbox.ts +++ b/src/components/ui/toolbox.ts @@ -356,7 +356,7 @@ export default class Toolbox extends EventsDispatcher { Shortcuts.add({ name: shortcut, on: this.api.ui.nodes.redactor, - handler: (event: KeyboardEvent) => { + handler: async (event: KeyboardEvent) => { event.preventDefault(); const currentBlockIndex = this.api.blocks.getCurrentBlockIndex(); @@ -368,11 +368,9 @@ export default class Toolbox extends EventsDispatcher { */ if (currentBlock) { try { - this.api.blocks.convert(currentBlock.id, toolName); + const newBlock = await this.api.blocks.convert(currentBlock.id, toolName); - window.requestAnimationFrame(() => { - this.api.caret.setToBlock(currentBlockIndex, 'end'); - }); + this.api.caret.setToBlock(newBlock, 'end'); return; } catch (error) {} diff --git a/src/components/utils/api.ts b/src/components/utils/api.ts new file mode 100644 index 000000000..4031bf6f1 --- /dev/null +++ b/src/components/utils/api.ts @@ -0,0 +1,21 @@ +import type { BlockAPI } from '../../../types/api/block'; +import { EditorModules } from '../../types-internal/editor-modules'; +import Block from '../block'; + +/** + * Returns Block instance by passed Block index or Block id + * + * @param attribute - either BlockAPI or Block id or Block index + * @param editor - Editor instance + */ +export function resolveBlock(attribute: BlockAPI | BlockAPI['id'] | number, editor: EditorModules): Block | undefined { + if (typeof attribute === 'number') { + return editor.BlockManager.getBlockByIndex(attribute); + } + + if (typeof attribute === 'string') { + return editor.BlockManager.getBlockById(attribute); + } + + return editor.BlockManager.getBlockById(attribute.id); +} diff --git a/test/cypress/tests/api/blocks.cy.ts b/test/cypress/tests/api/blocks.cy.ts index c3d7724e1..77f25e91e 100644 --- a/test/cypress/tests/api/blocks.cy.ts +++ b/test/cypress/tests/api/blocks.cy.ts @@ -1,5 +1,5 @@ import type EditorJS from '../../../../types/index'; -import { ConversionConfig, ToolboxConfig } from '../../../../types'; +import type { ConversionConfig, ToolboxConfig } from '../../../../types'; import ToolMock from '../../fixtures/tools/ToolMock'; /** @@ -202,7 +202,7 @@ describe('api.blocks', () => { }); describe('.convert()', function () { - it('should convert a Block to another type if original Tool has "conversionConfig.export" and target Tool has "conversionConfig.import"', function () { + it('should convert a Block to another type if original Tool has "conversionConfig.export" and target Tool has "conversionConfig.import". Should return BlockAPI as well.', function () { /** * Mock of Tool with conversionConfig */ @@ -246,20 +246,28 @@ describe('api.blocks', () => { existingBlock, ], }, - }).then((editor) => { + }).then(async (editor) => { const { convert } = editor.blocks; - convert(existingBlock.id, 'convertableTool'); + const returnValue = await convert(existingBlock.id, 'convertableTool'); // wait for block to be converted - cy.wait(100).then(() => { + cy.wait(100).then(async () => { /** * Check that block was converted */ - editor.save().then(( { blocks }) => { - expect(blocks.length).to.eq(1); - expect(blocks[0].type).to.eq('convertableTool'); - expect(blocks[0].data.text).to.eq(existingBlock.data.text); + const { blocks } = await editor.save(); + + expect(blocks.length).to.eq(1); + expect(blocks[0].type).to.eq('convertableTool'); + expect(blocks[0].data.text).to.eq(existingBlock.data.text); + + /** + * Check that returned value is BlockAPI + */ + expect(returnValue).to.containSubset({ + name: 'convertableTool', + id: blocks[0].id, }); }); }); @@ -274,9 +282,10 @@ describe('api.blocks', () => { const fakeId = 'WRNG_ID'; const { convert } = editor.blocks; - const exec = (): void => convert(fakeId, 'convertableTool'); - - expect(exec).to.throw(`Block with id "${fakeId}" not found`); + return convert(fakeId, 'convertableTool') + .catch((error) => { + expect(error.message).to.be.eq(`Block with id "${fakeId}" not found`); + }); }); }); @@ -302,9 +311,10 @@ describe('api.blocks', () => { const nonexistingToolName = 'WRNG_TOOL_NAME'; const { convert } = editor.blocks; - const exec = (): void => convert(existingBlock.id, nonexistingToolName); - - expect(exec).to.throw(`Block Tool with type "${nonexistingToolName}" not found`); + return convert(existingBlock.id, nonexistingToolName) + .catch((error) => { + expect(error.message).to.be.eq(`Block Tool with type "${nonexistingToolName}" not found`); + }); }); }); @@ -340,9 +350,10 @@ describe('api.blocks', () => { */ const { convert } = editor.blocks; - const exec = (): void => convert(existingBlock.id, 'nonConvertableTool'); - - expect(exec).to.throw(`Conversion from "paragraph" to "nonConvertableTool" is not possible. NonConvertableTool tool(s) should provide a "conversionConfig"`); + return convert(existingBlock.id, 'nonConvertableTool') + .catch((error) => { + expect(error.message).to.be.eq(`Conversion from "paragraph" to "nonConvertableTool" is not possible. NonConvertableTool tool(s) should provide a "conversionConfig"`); + }); }); }); }); diff --git a/test/cypress/tests/api/caret.cy.ts b/test/cypress/tests/api/caret.cy.ts new file mode 100644 index 000000000..a50c7b276 --- /dev/null +++ b/test/cypress/tests/api/caret.cy.ts @@ -0,0 +1,113 @@ +import EditorJS from '../../../../types'; + +/** + * Test cases for Caret API + */ +describe('Caret API', () => { + const paragraphDataMock = { + id: 'bwnFX5LoX7', + type: 'paragraph', + data: { + text: 'The first block content mock.', + }, + }; + + describe('.setToBlock()', () => { + /** + * The arrange part of the following tests are the same: + * - create an editor + * - move caret out of the block by default + */ + beforeEach(() => { + cy.createEditor({ + data: { + blocks: [ + paragraphDataMock, + ], + }, + }).as('editorInstance'); + + /** + * Blur caret from the block before setting via api + */ + cy.get('[data-cy=editorjs]') + .click(); + }); + + it('should set caret to a block (and return true) if block index is passed as argument', () => { + cy.get('@editorInstance') + .then(async (editor) => { + const returnedValue = editor.caret.setToBlock(0); + + /** + * Check that caret belongs block + */ + cy.window() + .then((window) => { + const selection = window.getSelection(); + const range = selection.getRangeAt(0); + + cy.get('[data-cy=editorjs]') + .find('.ce-block') + .first() + .should(($block) => { + expect($block[0].contains(range.startContainer)).to.be.true; + }); + }); + + expect(returnedValue).to.be.true; + }); + }); + + it('should set caret to a block (and return true) if block id is passed as argument', () => { + cy.get('@editorInstance') + .then(async (editor) => { + const returnedValue = editor.caret.setToBlock(paragraphDataMock.id); + + /** + * Check that caret belongs block + */ + cy.window() + .then((window) => { + const selection = window.getSelection(); + const range = selection.getRangeAt(0); + + cy.get('[data-cy=editorjs]') + .find('.ce-block') + .first() + .should(($block) => { + expect($block[0].contains(range.startContainer)).to.be.true; + }); + }); + + expect(returnedValue).to.be.true; + }); + }); + + it('should set caret to a block (and return true) if Block API is passed as argument', () => { + cy.get('@editorInstance') + .then(async (editor) => { + const block = editor.blocks.getById(paragraphDataMock.id); + const returnedValue = editor.caret.setToBlock(block); + + /** + * Check that caret belongs block + */ + cy.window() + .then((window) => { + const selection = window.getSelection(); + const range = selection.getRangeAt(0); + + cy.get('[data-cy=editorjs]') + .find('.ce-block') + .first() + .should(($block) => { + expect($block[0].contains(range.startContainer)).to.be.true; + }); + }); + + expect(returnedValue).to.be.true; + }); + }); + }); +}); diff --git a/test/cypress/tests/modules/InlineToolbar.cy.ts b/test/cypress/tests/modules/InlineToolbar.cy.ts index f1522eda7..bc14ef5f3 100644 --- a/test/cypress/tests/modules/InlineToolbar.cy.ts +++ b/test/cypress/tests/modules/InlineToolbar.cy.ts @@ -1,3 +1,5 @@ +import Header from '@editorjs/header'; + describe('Inline Toolbar', () => { it('should appear aligned with left coord of selection rect', () => { cy.createEditor({ @@ -73,4 +75,56 @@ describe('Inline Toolbar', () => { }); }); }); + + describe('Conversion toolbar', () => { + it('should restore caret after converting of a block', () => { + cy.createEditor({ + tools: { + header: { + class: Header, + }, + }, + data: { + blocks: [ + { + type: 'paragraph', + data: { + text: 'Some text', + }, + }, + ], + }, + }); + + cy.get('[data-cy=editorjs]') + .find('.ce-paragraph') + .selectText('Some text'); + + cy.get('[data-cy=conversion-toggler]') + .click(); + + cy.get('[data-cy=editorjs]') + .find('.ce-conversion-tool[data-tool=header]') + .click(); + + cy.get('[data-cy=editorjs]') + .find('.ce-header') + .should('have.text', 'Some text'); + + cy.window() + .then((window) => { + const selection = window.getSelection(); + + expect(selection.rangeCount).to.be.equal(1); + + const range = selection.getRangeAt(0); + + cy.get('[data-cy=editorjs]') + .find('.ce-header') + .should(($block) => { + expect($block[0].contains(range.startContainer)).to.be.true; + }); + }); + }); + }); }); diff --git a/test/cypress/tests/ui/BlockTunes.cy.ts b/test/cypress/tests/ui/BlockTunes.cy.ts index b9acd0279..0cf9207a1 100644 --- a/test/cypress/tests/ui/BlockTunes.cy.ts +++ b/test/cypress/tests/ui/BlockTunes.cy.ts @@ -287,5 +287,61 @@ describe('BlockTunes', function () { .contains('Title 2') .should('exist'); }); + + it('should convert block to another type and set caret to the new block', () => { + cy.createEditor({ + tools: { + header: Header, + }, + data: { + blocks: [ + { + type: 'paragraph', + data: { + text: 'Some text', + }, + }, + ], + }, + }); + + /** Open block tunes menu */ + cy.get('[data-cy=editorjs]') + .get('.cdx-block') + .click(); + + cy.get('[data-cy=editorjs]') + .get('.ce-toolbar__settings-btn') + .click(); + + /** Click "Convert to" option*/ + cy.get('[data-cy=editorjs]') + .get('.ce-popover-item') + .contains('Convert to') + .click(); + + /** Click "Heading" option */ + cy.get('[data-cy=editorjs]') + .get('.ce-popover--nested [data-item-name=header]') + .click(); + + /** Check the block was converted to the second option */ + cy.get('[data-cy=editorjs]') + .get('.ce-header') + .should('have.text', 'Some text'); + + /** Check that caret set to the end of the new block */ + cy.window() + .then((window) => { + const selection = window.getSelection(); + const range = selection.getRangeAt(0); + + cy.get('[data-cy=editorjs]') + .find('.ce-header') + .should(($block) => { + expect($block[0].contains(range.startContainer)).to.be.true; + }); + }); + }); }); }); diff --git a/test/cypress/tests/ui/toolbox.cy.ts b/test/cypress/tests/ui/toolbox.cy.ts index 95ff5423f..ca4da3a9e 100644 --- a/test/cypress/tests/ui/toolbox.cy.ts +++ b/test/cypress/tests/ui/toolbox.cy.ts @@ -4,7 +4,7 @@ import ToolMock from '../../fixtures/tools/ToolMock'; describe('Toolbox', function () { describe('Shortcuts', function () { - it('should covert current Block to the Shortcuts\'s Block if both tools provides a "conversionConfig" ', function () { + it('should convert current Block to the Shortcuts\'s Block if both tools provides a "conversionConfig". Caret should be restored after conversion.', function () { /** * Mock of Tool with conversionConfig */ @@ -54,6 +54,21 @@ describe('Toolbox', function () { expect(blocks.length).to.eq(1); expect(blocks[0].type).to.eq('convertableTool'); expect(blocks[0].data.text).to.eq('Some text'); + + /** + * Check that caret belongs to the new block after conversion + */ + cy.window() + .then((window) => { + const selection = window.getSelection(); + const range = selection.getRangeAt(0); + + cy.get('[data-cy=editorjs]') + .find(`.ce-block[data-id=${blocks[0].id}]`) + .should(($block) => { + expect($block[0].contains(range.startContainer)).to.be.true; + }); + }); }); }); diff --git a/types/api/blocks.d.ts b/types/api/blocks.d.ts index c3bf22b13..fd05be104 100644 --- a/types/api/blocks.d.ts +++ b/types/api/blocks.d.ts @@ -147,5 +147,5 @@ export interface Blocks { * * @throws Error if conversion is not possible */ - convert(id: string, newType: string, dataOverrides?: BlockToolData): void; + convert(id: string, newType: string, dataOverrides?: BlockToolData): Promise; } diff --git a/types/api/caret.d.ts b/types/api/caret.d.ts index 91d5c9941..29790f1ea 100644 --- a/types/api/caret.d.ts +++ b/types/api/caret.d.ts @@ -1,3 +1,5 @@ +import { BlockAPI } from "./block"; + /** * Describes Editor`s caret API */ @@ -46,13 +48,13 @@ export interface Caret { /** * Sets caret to the Block by passed index * - * @param {number} index - index of Block where to set caret - * @param {string} position - position where to set caret - * @param {number} offset - caret offset + * @param blockOrIdOrIndex - BlockAPI or Block id or Block index + * @param position - position where to set caret + * @param offset - caret offset * * @return {boolean} */ - setToBlock(index: number, position?: 'end'|'start'|'default', offset?: number): boolean; + setToBlock(blockOrIdOrIndex: BlockAPI | BlockAPI['id'] | number, position?: 'end'|'start'|'default', offset?: number): boolean; /** * Sets caret to the Editor From 238c90901681614a91e3b72ecbf406a7c2a23f05 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 29 Apr 2024 22:28:45 +0300 Subject: [PATCH 22/61] Bump version (#2701) Co-authored-by: github-actions --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index c95ce33ae..8a361c9b9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@editorjs/editorjs", - "version": "2.30.0-rc.7", + "version": "2.30.0-rc.8", "description": "Editor.js — Native JS, based on API and Open Source", "main": "dist/editorjs.umd.js", "module": "dist/editorjs.mjs", From 8276daa5ca48ef98afe6d316da77eba65be1c1bc Mon Sep 17 00:00:00 2001 From: Peter Savchenko Date: Wed, 1 May 2024 20:59:33 +0300 Subject: [PATCH 23/61] fix changelog (#2704) --- src/components/modules/blockEvents.ts | 7 +++++++ test/cypress/tests/modules/BlockEvents/Slash.cy.ts | 10 +++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/components/modules/blockEvents.ts b/src/components/modules/blockEvents.ts index 78f23336e..f1e98336e 100644 --- a/src/components/modules/blockEvents.ts +++ b/src/components/modules/blockEvents.ts @@ -249,6 +249,13 @@ export default class BlockEvents extends Module { return; } + /** + * The Toolbox will be opened with immediate focus on the Search input, + * and '/' will be added in the search input by default — we need to prevent it and add '/' manually + */ + event.preventDefault(); + this.Editor.Caret.insertContentAtCaretPosition('/'); + this.activateToolbox(); } diff --git a/test/cypress/tests/modules/BlockEvents/Slash.cy.ts b/test/cypress/tests/modules/BlockEvents/Slash.cy.ts index aca84cd1c..0d9db5fc1 100644 --- a/test/cypress/tests/modules/BlockEvents/Slash.cy.ts +++ b/test/cypress/tests/modules/BlockEvents/Slash.cy.ts @@ -1,6 +1,6 @@ describe('Slash keydown', function () { describe('pressed in empty block', function () { - it('should open Toolbox', () => { + it('should add "/" in a block and open Toolbox', () => { cy.createEditor({ data: { blocks: [ @@ -19,6 +19,14 @@ describe('Slash keydown', function () { .click() .type('/'); + /** + * Block content should contain slash + */ + cy.get('[data-cy=editorjs]') + .find('.ce-paragraph') + .invoke('text') + .should('eq', '/'); + cy.get('[data-cy="toolbox"] .ce-popover__container') .should('be.visible'); }); From bd1de56ef33933c5bdff426a7dd8228b99ed4bfd Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 1 May 2024 21:00:22 +0300 Subject: [PATCH 24/61] Bump version (#2705) Co-authored-by: github-actions --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 8a361c9b9..34db1a1e9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@editorjs/editorjs", - "version": "2.30.0-rc.8", + "version": "2.30.0-rc.9", "description": "Editor.js — Native JS, based on API and Open Source", "main": "dist/editorjs.umd.js", "module": "dist/editorjs.mjs", From f78972ee09891280a18823442d46e430912c2b19 Mon Sep 17 00:00:00 2001 From: Tatiana Fomina Date: Sat, 4 May 2024 18:35:36 +0300 Subject: [PATCH 25/61] feat(popover): custom content becomes a popover item (#2707) * Add custom item * Remove customcontent parameter from popover * Tests * Cleanup * Cleanup * Lint * Cleanup * Rename custom to html, add enum with item types * Fix tests * Add order test * Update jsdoc * Update changelog * Fix issue with html item not hiding on search * Fix flipper issue * Update changelog --- docs/CHANGELOG.md | 1 + src/components/block/index.ts | 69 +++----- .../modules/toolbar/blockSettings.ts | 27 +-- src/components/ui/toolbox.ts | 8 +- .../popover-item-html.const.ts | 14 ++ .../popover-item-html/popover-item-html.ts | 57 +++++++ .../popover-item/popover-item.types.ts | 37 +++- .../utils/popover/popover-abstract.ts | 49 ++---- .../utils/popover/popover-desktop.ts | 37 ++-- src/components/utils/popover/popover.const.ts | 2 - src/components/utils/popover/popover.types.ts | 13 -- src/styles/popover.css | 20 +-- test/cypress/tests/ui/BlockTunes.cy.ts | 94 ++++++++++ test/cypress/tests/utils/popover.cy.ts | 161 ++++++++++++++++-- types/tools/tool-settings.d.ts | 9 +- 15 files changed, 419 insertions(+), 179 deletions(-) create mode 100644 src/components/utils/popover/components/popover-item/popover-item-html/popover-item-html.const.ts create mode 100644 src/components/utils/popover/components/popover-item/popover-item-html/popover-item-html.ts diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index ad17b0bfe..87b30b545 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -15,6 +15,7 @@ - `Fix` - Caret lost after block conversion on mobile devices. - `Improvement` - The API `blocks.convert()` now returns the new block API - `Improvement` - The API `caret.setToBlock()` now can accept either BlockAPI or block index or block id +- `New` – *Menu Config* – New item type – HTML ### 2.29.1 diff --git a/src/components/block/index.ts b/src/components/block/index.ts index 803a50449..1a58e6382 100644 --- a/src/components/block/index.ts +++ b/src/components/block/index.ts @@ -21,11 +21,12 @@ import BlockTune from '../tools/tune'; import { BlockTuneData } from '../../../types/block-tunes/block-tune-data'; import ToolsCollection from '../tools/collection'; import EventsDispatcher from '../utils/events'; -import { TunesMenuConfig, TunesMenuConfigItem } from '../../../types/tools'; +import { TunesMenuConfigItem } from '../../../types/tools'; import { isMutationBelongsToElement } from '../utils/mutations'; import { EditorEventMap, FakeCursorAboutToBeToggled, FakeCursorHaveBeenSet, RedactorDomChanged } from '../events'; import { RedactorDomChangedPayload } from '../events/RedactorDomChanged'; import { convertBlockDataToString, isSameBlockData } from '../utils/blocks'; +import { PopoverItemType } from '../utils/popover'; /** * Interface describes Block class constructor argument @@ -610,29 +611,28 @@ export default class Block extends EventsDispatcher { } /** - * Returns data to render in tunes menu. - * Splits block tunes into 3 groups: block specific tunes, common tunes - * and custom html that is produced by combining tunes html from both previous groups + * Returns data to render in Block Tunes menu. + * Splits block tunes into 2 groups: block specific tunes and common tunes */ public getTunes(): { toolTunes: PopoverItemParams[]; commonTunes: PopoverItemParams[]; - customHtmlTunes: HTMLElement } { - const customHtmlTunesContainer = document.createElement('div'); + const toolTunesPopoverParams: TunesMenuConfigItem[] = []; const commonTunesPopoverParams: TunesMenuConfigItem[] = []; /** Tool's tunes: may be defined as return value of optional renderSettings method */ const tunesDefinedInTool = typeof this.toolInstance.renderSettings === 'function' ? this.toolInstance.renderSettings() : []; - /** Separate custom html from Popover items params for tool's tunes */ - const { - items: toolTunesPopoverParams, - htmlElement: toolTunesHtmlElement, - } = this.getTunesDataSegregated(tunesDefinedInTool); - - if (toolTunesHtmlElement !== undefined) { - customHtmlTunesContainer.appendChild(toolTunesHtmlElement); + if ($.isElement(tunesDefinedInTool)) { + toolTunesPopoverParams.push({ + type: PopoverItemType.Html, + element: tunesDefinedInTool, + }); + } else if (Array.isArray(tunesDefinedInTool)) { + toolTunesPopoverParams.push(...tunesDefinedInTool); + } else { + toolTunesPopoverParams.push(tunesDefinedInTool); } /** Common tunes: combination of default tunes (move up, move down, delete) and third-party tunes connected via tunes api */ @@ -643,28 +643,24 @@ export default class Block extends EventsDispatcher { /** Separate custom html from Popover items params for common tunes */ commonTunes.forEach(tuneConfig => { - const { - items, - htmlElement, - } = this.getTunesDataSegregated(tuneConfig); - - if (htmlElement !== undefined) { - customHtmlTunesContainer.appendChild(htmlElement); - } - - if (items !== undefined) { - commonTunesPopoverParams.push(...items); + if ($.isElement(tuneConfig)) { + commonTunesPopoverParams.push({ + type: PopoverItemType.Html, + element: tuneConfig, + }); + } else if (Array.isArray(tuneConfig)) { + commonTunesPopoverParams.push(...tuneConfig); + } else { + commonTunesPopoverParams.push(tuneConfig); } }); return { toolTunes: toolTunesPopoverParams, commonTunes: commonTunesPopoverParams, - customHtmlTunes: customHtmlTunesContainer, }; } - /** * Update current input index with selection anchor node */ @@ -750,25 +746,6 @@ export default class Block extends EventsDispatcher { return convertBlockDataToString(blockData, this.tool.conversionConfig); } - /** - * Determines if tool's tunes settings are custom html or popover params and separates one from another by putting to different object fields - * - * @param tunes - tool's tunes config - */ - private getTunesDataSegregated(tunes: HTMLElement | TunesMenuConfig): { htmlElement?: HTMLElement; items: PopoverItemParams[] } { - const result = { } as { htmlElement?: HTMLElement; items: PopoverItemParams[] }; - - if ($.isElement(tunes)) { - result.htmlElement = tunes as HTMLElement; - } else if (Array.isArray(tunes)) { - result.items = tunes as PopoverItemParams[]; - } else { - result.items = [ tunes ]; - } - - return result; - } - /** * Make default Block wrappers and put Tool`s content there * diff --git a/src/components/modules/toolbar/blockSettings.ts b/src/components/modules/toolbar/blockSettings.ts index 2d8983a90..c415be531 100644 --- a/src/components/modules/toolbar/blockSettings.ts +++ b/src/components/modules/toolbar/blockSettings.ts @@ -7,7 +7,7 @@ import { I18nInternalNS } from '../../i18n/namespace-internal'; import Flipper from '../../flipper'; import { TunesMenuConfigItem } from '../../../../types/tools'; import { resolveAliases } from '../../utils/resolve-aliases'; -import { type Popover, PopoverDesktop, PopoverMobile, PopoverItemParams, PopoverItemDefaultParams } from '../../utils/popover'; +import { type Popover, PopoverDesktop, PopoverMobile, PopoverItemParams, PopoverItemDefaultParams, PopoverItemType } from '../../utils/popover'; import { PopoverEvent } from '../../utils/popover/popover.types'; import { isMobileScreen } from '../../utils'; import { EditorMobileLayoutToggled } from '../../events'; @@ -124,7 +124,7 @@ export default class BlockSettings extends Module { this.Editor.BlockSelection.clearCache(); /** Get tool's settings data */ - const { toolTunes, commonTunes, customHtmlTunes } = targetBlock.getTunes(); + const { toolTunes, commonTunes } = targetBlock.getTunes(); /** Tell to subscribers that block settings is opened */ this.eventsDispatcher.emit(this.events.opened); @@ -134,8 +134,6 @@ export default class BlockSettings extends Module { this.popover = new PopoverClass({ searchable: true, items: await this.getTunesItems(targetBlock, commonTunes, toolTunes), - customContent: customHtmlTunes, - customContentFlippableItems: this.getControls(customHtmlTunes), scopeElement: this.Editor.API.methods.ui.nodes.redactor, messages: { nothingFound: I18n.ui(I18nInternalNS.ui.popover, 'Nothing found'), @@ -212,7 +210,7 @@ export default class BlockSettings extends Module { if (toolTunes !== undefined && toolTunes.length > 0) { items.push(...toolTunes); items.push({ - type: 'separator', + type: PopoverItemType.Separator, }); } @@ -227,7 +225,7 @@ export default class BlockSettings extends Module { }, }); items.push({ - type: 'separator', + type: PopoverItemType.Separator, }); } @@ -314,28 +312,13 @@ export default class BlockSettings extends Module { this.close(); }; - /** - * Returns list of buttons and inputs inside specified container - * - * @param container - container to query controls inside of - */ - private getControls(container: HTMLElement): HTMLElement[] { - const { StylesAPI } = this.Editor; - /** Query buttons and inputs inside tunes html */ - const controls = container.querySelectorAll( - `.${StylesAPI.classes.settingsButton}, ${$.allInputsSelector}` - ); - - return Array.from(controls); - } - /** * Resolves aliases in tunes menu items * * @param item - item with resolved aliases */ private resolveTuneAliases(item: TunesMenuConfigItem): PopoverItemParams { - if (item.type === 'separator') { + if (item.type === PopoverItemType.Separator || item.type === PopoverItemType.Html) { return item; } const result = resolveAliases(item, { label: 'title' }); diff --git a/src/components/ui/toolbox.ts b/src/components/ui/toolbox.ts index c50c7d138..6bb941459 100644 --- a/src/components/ui/toolbox.ts +++ b/src/components/ui/toolbox.ts @@ -3,7 +3,7 @@ import { BlockToolAPI } from '../block'; import Shortcuts from '../utils/shortcuts'; import BlockTool from '../tools/block'; import ToolsCollection from '../tools/collection'; -import { API, BlockToolData, ToolboxConfigEntry, PopoverItem, BlockAPI } from '../../../types'; +import { API, BlockToolData, ToolboxConfigEntry, PopoverItemParams, BlockAPI } from '../../../types'; import EventsDispatcher from '../utils/events'; import I18n from '../i18n'; import { I18nInternalNS } from '../i18n/namespace-internal'; @@ -303,11 +303,11 @@ export default class Toolbox extends EventsDispatcher { * Returns list of items that will be displayed in toolbox */ @_.cacheable - private get toolboxItemsToBeDisplayed(): PopoverItem[] { + private get toolboxItemsToBeDisplayed(): PopoverItemParams[] { /** * Maps tool data to popover item structure */ - const toPopoverItem = (toolboxItem: ToolboxConfigEntry, tool: BlockTool): PopoverItem => { + const toPopoverItem = (toolboxItem: ToolboxConfigEntry, tool: BlockTool): PopoverItemParams => { return { icon: toolboxItem.icon, title: I18n.t(I18nInternalNS.toolNames, toolboxItem.title || _.capitalize(tool.name)), @@ -320,7 +320,7 @@ export default class Toolbox extends EventsDispatcher { }; return this.toolsToBeDisplayed - .reduce((result, tool) => { + .reduce((result, tool) => { if (Array.isArray(tool.toolbox)) { tool.toolbox.forEach(item => { result.push(toPopoverItem(item, tool)); diff --git a/src/components/utils/popover/components/popover-item/popover-item-html/popover-item-html.const.ts b/src/components/utils/popover/components/popover-item/popover-item-html/popover-item-html.const.ts new file mode 100644 index 000000000..f2f0fc440 --- /dev/null +++ b/src/components/utils/popover/components/popover-item/popover-item-html/popover-item-html.const.ts @@ -0,0 +1,14 @@ +import { bem } from '../../../../bem'; + +/** + * Popover item block CSS class constructor + */ +const className = bem('ce-popover-item-html'); + +/** + * CSS class names to be used in popover item class + */ +export const css = { + root: className(), + hidden: className(null, 'hidden'), +}; diff --git a/src/components/utils/popover/components/popover-item/popover-item-html/popover-item-html.ts b/src/components/utils/popover/components/popover-item/popover-item-html/popover-item-html.ts new file mode 100644 index 000000000..138c30326 --- /dev/null +++ b/src/components/utils/popover/components/popover-item/popover-item-html/popover-item-html.ts @@ -0,0 +1,57 @@ +import { PopoverItem } from '../popover-item'; +import { PopoverItemHtmlParams } from '../popover-item.types'; +import { css } from './popover-item-html.const'; +import Dom from '../../../../../dom'; + +/** + * Represents popover item with custom html content + */ +export class PopoverItemHtml extends PopoverItem { + /** + * Item html elements + */ + private nodes: { root: HTMLElement }; + + /** + * Constructs the instance + * + * @param params – instance parameters + */ + constructor(params: PopoverItemHtmlParams) { + super(); + + this.nodes = { + root: Dom.make('div', css.root), + }; + + this.nodes.root.appendChild(params.element); + } + + /** + * Returns popover item root element + */ + public getElement(): HTMLElement { + return this.nodes.root; + } + + /** + * Toggles item hidden state + * + * @param isHidden - true if item should be hidden + */ + public toggleHidden(isHidden: boolean): void { + this.nodes.root?.classList.toggle(css.hidden, isHidden); + } + + /** + * Returns list of buttons and inputs inside custom content + */ + public getControls(): HTMLElement[] { + /** Query buttons and inputs inside custom html */ + const controls = this.nodes.root.querySelectorAll( + `button, ${Dom.allInputsSelector}` + ); + + return Array.from(controls); + } +} diff --git a/src/components/utils/popover/components/popover-item/popover-item.types.ts b/src/components/utils/popover/components/popover-item/popover-item.types.ts index e9e7f95c4..8fa8d0962 100644 --- a/src/components/utils/popover/components/popover-item/popover-item.types.ts +++ b/src/components/utils/popover/components/popover-item/popover-item.types.ts @@ -1,3 +1,16 @@ +/** + * Popover item types + */ +export enum PopoverItemType { + /** Regular item with icon, title and other properties */ + Default = 'default', + + /** Gray line used to separate items from each other */ + Separator = 'separator', + + /** Item with custom html content */ + Html = 'html' +} /** * Represents popover item separator. @@ -7,7 +20,22 @@ export interface PopoverItemSeparatorParams { /** * Item type */ - type: 'separator' + type: PopoverItemType.Separator +} + +/** + * Represents popover item with custom html content + */ +export interface PopoverItemHtmlParams { + /** + * Item type + */ + type: PopoverItemType.Html; + + /** + * Custom html content to be displayed in the popover + */ + element: HTMLElement } /** @@ -17,7 +45,7 @@ interface PopoverItemDefaultBaseParams { /** * Item type */ - type?: 'default'; + type?: PopoverItemType.Default; /** * Displayed text @@ -119,5 +147,8 @@ export type PopoverItemDefaultParams = /** * Represents single popover item */ -export type PopoverItemParams = PopoverItemDefaultParams | PopoverItemSeparatorParams; +export type PopoverItemParams = + PopoverItemDefaultParams | + PopoverItemSeparatorParams | + PopoverItemHtmlParams; diff --git a/src/components/utils/popover/popover-abstract.ts b/src/components/utils/popover/popover-abstract.ts index 0191dcd64..418195b1b 100644 --- a/src/components/utils/popover/popover-abstract.ts +++ b/src/components/utils/popover/popover-abstract.ts @@ -1,4 +1,4 @@ -import { PopoverItem, PopoverItemDefault, PopoverItemSeparator } from './components/popover-item'; +import { PopoverItem, PopoverItemDefault, PopoverItemSeparator, PopoverItemType } from './components/popover-item'; import Dom from '../../dom'; import { SearchInput, SearchInputEvent, SearchableItem } from './components/search-input'; import EventsDispatcher from '../events'; @@ -6,6 +6,7 @@ import Listeners from '../listeners'; import { PopoverEventMap, PopoverMessages, PopoverParams, PopoverEvent, PopoverNodes } from './popover.types'; import { css } from './popover.const'; import { PopoverItemParams } from './components/popover-item'; +import { PopoverItemHtml } from './components/popover-item/popover-item-html/popover-item-html'; /** * Class responsible for rendering popover and handling its behaviour @@ -27,10 +28,9 @@ export abstract class PopoverAbstract protected nodes: Nodes; /** - * List of usual interactive popover items that can be clicked, hovered, etc. - * (excluding separators) + * List of default popover items that are searchable and may have confirmation state */ - protected get itemsInteractive(): PopoverItemDefault[] { + protected get itemsDefault(): PopoverItemDefault[] { return this.items.filter(item => item instanceof PopoverItemDefault) as PopoverItemDefault[]; } @@ -97,10 +97,6 @@ export abstract class PopoverAbstract this.nodes.popover.appendChild(this.nodes.popoverContainer); - if (params.customContent) { - this.addCustomContent(params.customContent); - } - if (params.searchable) { this.addSearch(); } @@ -131,7 +127,7 @@ export abstract class PopoverAbstract this.nodes.popover.classList.remove(css.popoverOpened); this.nodes.popover.classList.remove(css.popoverOpenTop); - this.itemsInteractive.forEach(item => item.reset()); + this.itemsDefault.forEach(item => item.reset()); if (this.search !== undefined) { this.search.clear(); @@ -155,8 +151,10 @@ export abstract class PopoverAbstract protected buildItems(items: PopoverItemParams[]): Array { return items.map(item => { switch (item.type) { - case 'separator': + case PopoverItemType.Separator: return new PopoverItemSeparator(); + case PopoverItemType.Html: + return new PopoverItemHtml(item); default: return new PopoverItemDefault(item); } @@ -169,7 +167,7 @@ export abstract class PopoverAbstract * @param event - event to retrieve popover item from */ protected getTargetItem(event: Event): PopoverItemDefault | undefined { - return this.itemsInteractive.find(el => { + return this.itemsDefault.find(el => { const itemEl = el.getElement(); if (itemEl === null) { @@ -197,14 +195,13 @@ export abstract class PopoverAbstract if (item instanceof PopoverItemDefault) { isHidden = !data.items.includes(item); - } else if (item instanceof PopoverItemSeparator) { + } else if (item instanceof PopoverItemSeparator || item instanceof PopoverItemHtml) { /** Should hide separators if nothing found message displayed or if there is some search query applied */ isHidden = isNothingFound || !isEmptyQuery; } item.toggleHidden(isHidden); }); this.toggleNothingFoundMessage(isNothingFound); - this.toggleCustomContent(isEmptyQuery); }; /** @@ -212,7 +209,7 @@ export abstract class PopoverAbstract */ private addSearch(): void { this.search = new SearchInput({ - items: this.itemsInteractive, + items: this.itemsDefault, placeholder: this.messages.search, }); @@ -225,17 +222,6 @@ export abstract class PopoverAbstract this.nodes.popoverContainer.insertBefore(searchElement, this.nodes.popoverContainer.firstChild); } - /** - * Adds custom html content to the popover - * - * @param content - html content to append - */ - private addCustomContent(content: HTMLElement): void { - this.nodes.customContent = content; - this.nodes.customContent.classList.add(css.customContent); - this.nodes.popoverContainer.insertBefore(content, this.nodes.popoverContainer.firstChild); - } - /** * Handles clicks inside popover * @@ -259,7 +245,7 @@ export abstract class PopoverAbstract } /** Cleanup other items state */ - this.itemsInteractive.filter(x => x !== item).forEach(x => x.reset()); + this.itemsDefault.filter(x => x !== item).forEach(x => x.reset()); item.handleClick(); @@ -279,15 +265,6 @@ export abstract class PopoverAbstract this.nodes.nothingFoundMessage.classList.toggle(css.nothingFoundMessageDisplayed, isDisplayed); } - /** - * Toggles custom content visibility - * - * @param isDisplayed - true if custom content should be displayed - */ - private toggleCustomContent(isDisplayed: boolean): void { - this.nodes.customContent?.classList.toggle(css.customContentHidden, isDisplayed); - } - /** * - Toggles item active state, if clicked popover item has property 'toggle' set to true. * @@ -302,7 +279,7 @@ export abstract class PopoverAbstract } if (typeof clickedItem.toggle === 'string') { - const itemsInToggleGroup = this.itemsInteractive.filter(item => item.toggle === clickedItem.toggle); + const itemsInToggleGroup = this.itemsDefault.filter(item => item.toggle === clickedItem.toggle); /** If there's only one item in toggle group, toggle it */ if (itemsInToggleGroup.length === 1) { diff --git a/src/components/utils/popover/popover-desktop.ts b/src/components/utils/popover/popover-desktop.ts index 8e056eaa7..5d6440b00 100644 --- a/src/components/utils/popover/popover-desktop.ts +++ b/src/components/utils/popover/popover-desktop.ts @@ -7,6 +7,7 @@ import { css } from './popover.const'; import { SearchInputEvent, SearchableItem } from './components/search-input'; import { cacheable } from '../../utils'; import { PopoverItemDefault } from './components/popover-item'; +import { PopoverItemHtml } from './components/popover-item/popover-item-html/popover-item-html'; /** * Desktop popover. @@ -18,11 +19,6 @@ export class PopoverDesktop extends PopoverAbstract { */ public flipper: Flipper; - /** - * List of html elements inside custom content area that should be available for keyboard navigation - */ - private customContentFlippableItems: HTMLElement[] | undefined; - /** * Reference to nested popover if exists. * Undefined by default, PopoverDesktop when exists and null after destroyed. @@ -63,10 +59,6 @@ export class PopoverDesktop extends PopoverAbstract { this.nodes.popover.classList.add(css.popoverNested); } - if (params.customContentFlippableItems) { - this.customContentFlippableItems = params.customContentFlippableItems; - } - if (params.scopeElement !== undefined) { this.scopeElement = params.scopeElement; } @@ -148,10 +140,10 @@ export class PopoverDesktop extends PopoverAbstract { public hide(): void { super.hide(); - this.flipper.deactivate(); - this.destroyNestedPopoverIfExists(); + this.flipper.deactivate(); + this.previouslyHoveredItem = null; } @@ -283,23 +275,28 @@ export class PopoverDesktop extends PopoverAbstract { /** * Returns list of elements available for keyboard navigation. - * Contains both usual popover items elements and custom html content. */ private get flippableElements(): HTMLElement[] { - const popoverItemsElements = this.itemsInteractive.map(item => item.getElement()); - const customContentControlsElements = this.customContentFlippableItems || []; - - /** - * Combine elements inside custom content area with popover items elements - */ - return customContentControlsElements.concat(popoverItemsElements as HTMLElement[]); + const result = this.items + .map(item => { + if (item instanceof PopoverItemDefault) { + return item.getElement(); + } + if (item instanceof PopoverItemHtml) { + return item.getControls(); + } + }) + .flat() + .filter(item => item !== undefined && item !== null); + + return result as HTMLElement[]; } /** * Called on flipper navigation */ private onFlip = (): void => { - const focusedItem = this.itemsInteractive.find(item => item.isFocused); + const focusedItem = this.itemsDefault.find(item => item.isFocused); focusedItem?.onFocus(); }; diff --git a/src/components/utils/popover/popover.const.ts b/src/components/utils/popover/popover.const.ts index 4fc693a7a..2f6808124 100644 --- a/src/components/utils/popover/popover.const.ts +++ b/src/components/utils/popover/popover.const.ts @@ -17,8 +17,6 @@ export const css = { search: className('search'), nothingFoundMessage: className('nothing-found-message'), nothingFoundMessageDisplayed: className('nothing-found-message', 'displayed'), - customContent: className('custom-content'), - customContentHidden: className('custom-content', 'hidden'), items: className('items'), overlay: className('overlay'), overlayHidden: className('overlay', 'hidden'), diff --git a/src/components/utils/popover/popover.types.ts b/src/components/utils/popover/popover.types.ts index 8b52c54e4..9f1dcd730 100644 --- a/src/components/utils/popover/popover.types.ts +++ b/src/components/utils/popover/popover.types.ts @@ -15,16 +15,6 @@ export interface PopoverParams { */ scopeElement?: HTMLElement; - /** - * Arbitrary html element to be inserted before items list - */ - customContent?: HTMLElement; - - /** - * List of html elements inside custom content area that should be available for keyboard navigation - */ - customContentFlippableItems?: HTMLElement[]; - /** * True if popover should contain search field */ @@ -92,9 +82,6 @@ export interface PopoverNodes { /** Popover items wrapper */ items: HTMLElement; - - /** Custom html content area */ - customContent: HTMLElement | undefined; } /** diff --git a/src/styles/popover.css b/src/styles/popover.css index 3a99fe165..c24a3db25 100644 --- a/src/styles/popover.css +++ b/src/styles/popover.css @@ -130,7 +130,7 @@ } } - &__search, &__custom-content:not(:empty) { + &__search { margin-bottom: 5px; } @@ -151,18 +151,6 @@ } } - &__custom-content:not(:empty) { - padding: 4px; - - @media (--not-mobile) { - padding: 0; - } - } - - &__custom-content--hidden { - display: none; - } - &--nested { .ce-popover__container { /* Variable --nesting-level is set via js in showNestedPopoverForItem() method */ @@ -210,6 +198,12 @@ } } + .ce-popover-item-html { + &--hidden { + display: none; + } +} + .ce-popover-item { --border-radius: 6px; border-radius: var(--border-radius); diff --git a/test/cypress/tests/ui/BlockTunes.cy.ts b/test/cypress/tests/ui/BlockTunes.cy.ts index 0cf9207a1..3ee359b51 100644 --- a/test/cypress/tests/ui/BlockTunes.cy.ts +++ b/test/cypress/tests/ui/BlockTunes.cy.ts @@ -1,6 +1,7 @@ import { selectionChangeDebounceTimeout } from '../../../../src/components/constants'; import Header from '@editorjs/header'; import { ToolboxConfig } from '../../../../types'; +import { TunesMenuConfig } from '../../../../types/tools'; describe('BlockTunes', function () { @@ -344,4 +345,97 @@ describe('BlockTunes', function () { }); }); }); + + describe('Tunes order', () => { + it('should display block specific tunes before common tunes', () => { + /** + * Tool with several toolbox entries configured + */ + class TestTool { + /** + * TestTool contains several toolbox options + */ + public static get toolbox(): ToolboxConfig { + return [ + { + title: 'Title 1', + icon: 'Icon1', + data: { + level: 1, + }, + }, + ]; + } + + /** + * Tool can render itself + */ + public render(): HTMLDivElement { + const div = document.createElement('div'); + + div.innerText = 'Some text'; + + return div; + } + + /** + * + */ + public renderSettings(): TunesMenuConfig { + return { + icon: 'Icon', + title: 'Tune', + }; + } + + /** + * Tool can save it's data + */ + public save(): { text: string; level: number } { + return { + text: 'Some text', + level: 1, + }; + } + } + + /** Editor instance with TestTool installed and one block of TestTool type */ + cy.createEditor({ + tools: { + testTool: TestTool, + }, + data: { + blocks: [ + { + type: 'testTool', + data: { + text: 'Some text', + level: 1, + }, + }, + ], + }, + }); + + /** Open block tunes menu */ + cy.get('[data-cy=editorjs]') + .get('.ce-block') + .click(); + + cy.get('[data-cy=editorjs]') + .get('.ce-toolbar__settings-btn') + .click(); + + /** Check there are more than 1 tune */ + cy.get('[data-cy=editorjs]') + .get('.ce-popover-item') + .should('have.length.above', 1); + + /** Check the first tune is tool specific tune */ + cy.get('[data-cy=editorjs]') + .get('.ce-popover-item:first-child') + .contains('Tune') + .should('exist'); + }); + }); }); diff --git a/test/cypress/tests/utils/popover.cy.ts b/test/cypress/tests/utils/popover.cy.ts index 0d89f3bac..1fe4f3885 100644 --- a/test/cypress/tests/utils/popover.cy.ts +++ b/test/cypress/tests/utils/popover.cy.ts @@ -1,4 +1,4 @@ -import { PopoverDesktop as Popover } from '../../../../src/components/utils/popover'; +import { PopoverDesktop as Popover, PopoverItemType } from '../../../../src/components/utils/popover'; import { PopoverItemParams } from '../../../../types'; import { TunesMenuConfig } from '../../../../types/tools'; @@ -115,7 +115,7 @@ describe('Popover', () => { .should('have.class', 'ce-popover-item--disabled') .click() .then(() => { - if (items[0].type !== 'default') { + if (items[0].type !== PopoverItemType.Default) { return; } // Check onActivate callback has never been called @@ -244,22 +244,149 @@ describe('Popover', () => { }); }); - it('should render custom html content', () => { - const customHtml = document.createElement('div'); + it('should display item with custom html', () => { + /** + * Block Tune with html as return type of render() method + */ + class TestTune { + public static isTune = true; - customHtml.setAttribute('data-cy-name', 'customContent'); - customHtml.innerText = 'custom html content'; - const popover = new Popover({ - customContent: customHtml, - items: [], + /** Tune control displayed in block tunes popover */ + public render(): HTMLElement { + const button = document.createElement('button'); + + button.classList.add('ce-settings__button'); + button.innerText = 'Tune'; + + return button; + } + } + + /** Create editor instance */ + cy.createEditor({ + tools: { + testTool: TestTune, + }, + tunes: [ 'testTool' ], + data: { + blocks: [ + { + type: 'paragraph', + data: { + text: 'Hello', + }, + }, + ], + }, }); - cy.document().then(doc => { - doc.body.append(popover.getElement()); + /** Open block tunes menu */ + cy.get('[data-cy=editorjs]') + .get('.cdx-block') + .click(); + + cy.get('[data-cy=editorjs]') + .get('.ce-toolbar__settings-btn') + .click(); + + /** Check item with custom html content is displayed */ + cy.get('[data-cy=editorjs]') + .get('.ce-popover .ce-popover-item-html') + .contains('Tune') + .should('be.visible'); + }); + + it('should support flipping between custom content items', () => { + /** + * Block Tune with html as return type of render() method + */ + class TestTune1 { + public static isTune = true; + + /** Tune control displayed in block tunes popover */ + public render(): HTMLElement { + const button = document.createElement('button'); + + button.classList.add('ce-settings__button'); + button.innerText = 'Tune1'; + + return button; + } + } + + /** + * Block Tune with html as return type of render() method + */ + class TestTune2 { + public static isTune = true; + + /** Tune control displayed in block tunes popover */ + public render(): HTMLElement { + const button = document.createElement('button'); - /* Check custom content exists in the popover */ - cy.get('[data-cy-name=customContent]'); + button.classList.add('ce-settings__button'); + button.innerText = 'Tune2'; + + return button; + } + } + + /** Create editor instance */ + cy.createEditor({ + tools: { + testTool1: TestTune1, + testTool2: TestTune2, + }, + tunes: ['testTool1', 'testTool2'], + data: { + blocks: [ + { + type: 'paragraph', + data: { + text: 'Hello', + }, + }, + ], + }, }); + + /** Open block tunes menu */ + cy.get('[data-cy=editorjs]') + .get('.cdx-block') + .click(); + + cy.get('[data-cy=editorjs]') + .get('.ce-toolbar__settings-btn') + .click(); + + /** Press Tab */ + // eslint-disable-next-line cypress/require-data-selectors -- cy.tab() not working here + cy.get('body').tab(); + + /** Check the first custom html item is focused */ + cy.get('[data-cy=editorjs]') + .get('.ce-popover .ce-popover-item-html .ce-settings__button') + .contains('Tune1') + .should('have.class', 'ce-popover-item--focused'); + + /** Press Tab */ + // eslint-disable-next-line cypress/require-data-selectors -- cy.tab() not working here + cy.get('body').tab(); + + /** Check the second custom html item is focused */ + cy.get('[data-cy=editorjs]') + .get('.ce-popover .ce-popover-item-html .ce-settings__button') + .contains('Tune2') + .should('have.class', 'ce-popover-item--focused'); + + /** Press Tab */ + // eslint-disable-next-line cypress/require-data-selectors -- cy.tab() not working here + cy.get('body').tab(); + + /** Check that default popover item got focused */ + cy.get('[data-cy=editorjs]') + .get('[data-item-name=move-up]') + .should('have.class', 'ce-popover-item--focused'); }); it('should display nested popover (desktop)', () => { @@ -454,7 +581,6 @@ describe('Popover', () => { /** Tool data displayed in block tunes popover */ public render(): TunesMenuConfig { return { - // @ts-expect-error type is not specified on purpose to test the back compatibility onActivate: (): void => {}, icon: 'Icon', title: 'Tune', @@ -464,7 +590,6 @@ describe('Popover', () => { } } - /** Create editor instance */ cy.createEditor({ tools: { @@ -515,7 +640,7 @@ describe('Popover', () => { name: 'test-item', }, { - type: 'separator', + type: PopoverItemType.Separator, }, ]; } @@ -577,7 +702,7 @@ describe('Popover', () => { name: 'test-item-1', }, { - type: 'separator', + type: PopoverItemType.Separator, }, { onActivate: (): void => {}, @@ -664,7 +789,7 @@ describe('Popover', () => { name: 'test-item-1', }, { - type: 'separator', + type: PopoverItemType.Separator, }, { onActivate: (): void => {}, diff --git a/types/tools/tool-settings.d.ts b/types/tools/tool-settings.d.ts index 799224013..021ec4093 100644 --- a/types/tools/tool-settings.d.ts +++ b/types/tools/tool-settings.d.ts @@ -1,6 +1,6 @@ import { ToolConfig } from './tool-config'; import { ToolConstructable, BlockToolData } from './index'; -import { PopoverItemDefaultParams, PopoverItemSeparatorParams, PopoverItemParams } from '../configs'; +import { PopoverItemDefaultParams, PopoverItemSeparatorParams, PopoverItemHtmlParams } from '../configs'; /** * Tool may specify its toolbox configuration @@ -57,10 +57,15 @@ export type TunesMenuConfigDefaultItem = PopoverItemDefaultParams & { */ export type TunesMenuConfigSeparatorItem = PopoverItemSeparatorParams; +/** + * Represents single Tunes Menu item with custom HTML contect + */ +export type TunesMenuConfigHtmlItem = PopoverItemHtmlParams; + /** * Union of all Tunes Menu item types */ -export type TunesMenuConfigItem = TunesMenuConfigDefaultItem | TunesMenuConfigSeparatorItem; +export type TunesMenuConfigItem = TunesMenuConfigDefaultItem | TunesMenuConfigSeparatorItem | TunesMenuConfigHtmlItem; /** * Tool may specify its tunes configuration From 50f43bb35d33430044a347be25ad600d62925895 Mon Sep 17 00:00:00 2001 From: Tatiana Fomina Date: Sat, 4 May 2024 21:23:36 +0300 Subject: [PATCH 26/61] Change cypress preprocessor (#2712) --- cypress.config.ts | 2 ++ package.json | 1 + yarn.lock | 23 +++++++++++++++++++++++ 3 files changed, 26 insertions(+) diff --git a/cypress.config.ts b/cypress.config.ts index ed8fa7959..f0e4573a2 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -12,6 +12,8 @@ export default defineConfig({ // We've imported your old cypress plugins here. // You may want to clean this up later by importing these. setupNodeEvents(on, config) { + on('file:preprocessor', require('cypress-vite')(config)); + /** * Plugin for cypress that adds better terminal output for easier debugging. * Prints cy commands, browser console logs, cy.request and cy.intercept data. Great for your pipelines. diff --git a/package.json b/package.json index 34db1a1e9..401ecf4c6 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "cypress-intellij-reporter": "^0.0.7", "cypress-plugin-tab": "^1.0.5", "cypress-terminal-report": "^5.3.2", + "cypress-vite": "^1.5.0", "eslint": "^8.37.0", "eslint-config-codex": "^1.7.1", "eslint-plugin-chai-friendly": "^0.7.2", diff --git a/yarn.lock b/yarn.lock index 00de3f0fb..6b998a7b5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1424,6 +1424,21 @@ chokidar@3.5.3: optionalDependencies: fsevents "~2.3.2" +chokidar@^3.5.3: + version "3.6.0" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.6.0.tgz#197c6cc669ef2a8dc5e7b4d97ee4e092c3eb0d5b" + integrity sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw== + dependencies: + anymatch "~3.1.2" + braces "~3.0.2" + glob-parent "~5.1.2" + is-binary-path "~2.1.0" + is-glob "~4.0.1" + normalize-path "~3.0.0" + readdirp "~3.6.0" + optionalDependencies: + fsevents "~2.3.2" + ci-info@^3.2.0: version "3.9.0" resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.9.0.tgz#4279a62028a7b1f262f3473fc9605f5e218c59b4" @@ -1689,6 +1704,14 @@ cypress-terminal-report@^5.3.2: semver "^7.3.5" tv4 "^1.3.0" +cypress-vite@^1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/cypress-vite/-/cypress-vite-1.5.0.tgz#471ecc1175c7ab51b3b132c595dc3c7e222fe944" + integrity sha512-vvTMqJZgI3sN2ylQTi4OQh8LRRjSrfrIdkQD5fOj+EC/e9oHkxS96lif1SyDF1PwailG1tnpJE+VpN6+AwO/rg== + dependencies: + chokidar "^3.5.3" + debug "^4.3.4" + cypress@^13.7.1: version "13.7.1" resolved "https://registry.yarnpkg.com/cypress/-/cypress-13.7.1.tgz#d1208eb04efd46ef52a30480a5da71a03373261a" From d18eeb5dc87ff804d38388506b83ec0847971666 Mon Sep 17 00:00:00 2001 From: Tatiana Fomina Date: Thu, 16 May 2024 15:26:25 +0300 Subject: [PATCH 27/61] feat(popover): Add hint support (#2711) * Add custom item * Remove customcontent parameter from popover * Tests * Cleanup * Cleanup * Lint * Cleanup * Rename custom to html, add enum with item types * Fix tests * Support hint * Rename hint content to hint * Align hint left * Move types and exports * Update changelog * Cleanup * Add todos * Change the way hint is disabled for mobile * Get rid of buildItems override * Update comment --- docs/CHANGELOG.md | 2 + .../popover/components/hint/hint.const.ts | 16 +++++++ .../utils/popover/components/hint/hint.css | 10 ++++ .../utils/popover/components/hint/hint.ts | 46 +++++++++++++++++++ .../popover/components/hint/hint.types.ts | 19 ++++++++ .../utils/popover/components/hint/index.ts | 2 + .../popover-item-default.ts | 28 +++++++---- .../popover-item-html/popover-item-html.ts | 13 +++++- .../components/popover-item/popover-item.ts | 18 ++++++++ .../popover-item/popover-item.types.ts | 40 +++++++++++++++- .../utils/popover/popover-abstract.ts | 14 ++++-- .../utils/popover/popover-desktop.ts | 2 + .../utils/popover/popover-mobile.ts | 13 ++++-- 13 files changed, 202 insertions(+), 21 deletions(-) create mode 100644 src/components/utils/popover/components/hint/hint.const.ts create mode 100644 src/components/utils/popover/components/hint/hint.css create mode 100644 src/components/utils/popover/components/hint/hint.ts create mode 100644 src/components/utils/popover/components/hint/hint.types.ts create mode 100644 src/components/utils/popover/components/hint/index.ts diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 87b30b545..90f6c361c 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -16,6 +16,8 @@ - `Improvement` - The API `blocks.convert()` now returns the new block API - `Improvement` - The API `caret.setToBlock()` now can accept either BlockAPI or block index or block id - `New` – *Menu Config* – New item type – HTML +– `Refactoring` – Switched to Vite as Cypress bundler +– `New` – *Menu Config* – Default and HTML items now support hints ### 2.29.1 diff --git a/src/components/utils/popover/components/hint/hint.const.ts b/src/components/utils/popover/components/hint/hint.const.ts new file mode 100644 index 000000000..8b5afd77a --- /dev/null +++ b/src/components/utils/popover/components/hint/hint.const.ts @@ -0,0 +1,16 @@ +import { bem } from '../../../bem'; + +/** + * Hint block CSS class constructor + */ +const className = bem('ce-hint'); + +/** + * CSS class names to be used in hint class + */ +export const css = { + root: className(), + alignedLeft: className(null, 'align-left'), + title: className('title'), + description: className('description'), +}; diff --git a/src/components/utils/popover/components/hint/hint.css b/src/components/utils/popover/components/hint/hint.css new file mode 100644 index 000000000..94d886bd9 --- /dev/null +++ b/src/components/utils/popover/components/hint/hint.css @@ -0,0 +1,10 @@ +.ce-hint { + &--align-left { + text-align: left; + } + + &__description { + opacity: 0.6; + margin-top: 3px; + } +} diff --git a/src/components/utils/popover/components/hint/hint.ts b/src/components/utils/popover/components/hint/hint.ts new file mode 100644 index 000000000..eb91de126 --- /dev/null +++ b/src/components/utils/popover/components/hint/hint.ts @@ -0,0 +1,46 @@ +import Dom from '../../../../dom'; +import { css } from './hint.const'; +import { HintParams } from './hint.types'; + +import './hint.css'; + +/** + * Represents the hint content component + */ +export class Hint { + /** + * Html element used to display hint content on screen + */ + private nodes: { + root: HTMLElement; + title: HTMLElement; + description?: HTMLElement; + }; + + /** + * Constructs the hint content instance + * + * @param params - hint content parameters + */ + constructor(params: HintParams) { + this.nodes = { + root: Dom.make('div', [css.root, css.alignedLeft]), + title: Dom.make('div', css.title, { textContent: params.title }), + }; + + this.nodes.root.appendChild(this.nodes.title); + + if (params.description !== undefined) { + this.nodes.description = Dom.make('div', css.description, { textContent: params.description }); + + this.nodes.root.appendChild(this.nodes.description); + } + } + + /** + * Returns the root element of the hint content + */ + public getElement(): HTMLElement { + return this.nodes.root; + } +} diff --git a/src/components/utils/popover/components/hint/hint.types.ts b/src/components/utils/popover/components/hint/hint.types.ts new file mode 100644 index 000000000..ca4b878ce --- /dev/null +++ b/src/components/utils/popover/components/hint/hint.types.ts @@ -0,0 +1,19 @@ +/** + * Hint parameters + */ +export interface HintParams { + /** + * Title of the hint + */ + title: string; + + /** + * Secondary text to be displayed below the title + */ + description?: string; +} + +/** + * Possible hint positions + */ +export type HintPosition = 'top' | 'bottom' | 'left' | 'right'; diff --git a/src/components/utils/popover/components/hint/index.ts b/src/components/utils/popover/components/hint/index.ts new file mode 100644 index 000000000..9e4870aba --- /dev/null +++ b/src/components/utils/popover/components/hint/index.ts @@ -0,0 +1,2 @@ +export * from './hint'; +export * from './hint.types'; diff --git a/src/components/utils/popover/components/popover-item/popover-item-default/popover-item-default.ts b/src/components/utils/popover/components/popover-item/popover-item-default/popover-item-default.ts index 71cdb7b37..6a5d12d01 100644 --- a/src/components/utils/popover/components/popover-item/popover-item-default/popover-item-default.ts +++ b/src/components/utils/popover/components/popover-item/popover-item-default/popover-item-default.ts @@ -2,7 +2,9 @@ import Dom from '../../../../../dom'; import { IconDotCircle, IconChevronRight } from '@codexteam/icons'; import { PopoverItemDefaultParams as PopoverItemDefaultParams, - PopoverItemParams as PopoverItemParams + PopoverItemParams as PopoverItemParams, + PopoverItemRenderParamsMap, + PopoverItemType } from '../popover-item.types'; import { PopoverItem } from '../popover-item'; import { css } from './popover-item-default.const'; @@ -11,8 +13,9 @@ import { css } from './popover-item-default.const'; * Represents sigle popover item node * * @todo move nodes initialization to constructor - * @todo replace multiple make() usages with constructing separate instaces + * @todo replace multiple make() usages with constructing separate instances * @todo split regular popover item and popover item with confirmation to separate classes + * @todo display icon on the right side of the item for rtl languages */ export class PopoverItemDefault extends PopoverItem { /** @@ -72,10 +75,6 @@ export class PopoverItemDefault extends PopoverItem { icon: null, }; - /** - * Popover item params - */ - private params: PopoverItemDefaultParams; /** * If item is in confirmation state, stores confirmation params such as icon, label, onActivate callback and so on @@ -86,12 +85,13 @@ export class PopoverItemDefault extends PopoverItem { * Constructs popover item instance * * @param params - popover item construction params + * @param renderParams - popover item render params. + * The parameters that are not set by user via popover api but rather depend on technical implementation */ - constructor(params: PopoverItemDefaultParams) { + constructor(private readonly params: PopoverItemDefaultParams, renderParams?: PopoverItemRenderParamsMap[PopoverItemType.Default]) { super(); - this.params = params; - this.nodes.root = this.make(params); + this.nodes.root = this.make(params, renderParams); } /** @@ -159,8 +159,9 @@ export class PopoverItemDefault extends PopoverItem { * Constructs HTML element corresponding to popover item params * * @param params - item construction params + * @param renderParams - popover item render params */ - private make(params: PopoverItemDefaultParams): HTMLElement { + private make(params: PopoverItemDefaultParams, renderParams?: PopoverItemRenderParamsMap[PopoverItemType.Default]): HTMLElement { const el = Dom.make('div', css.container); if (params.name) { @@ -197,6 +198,13 @@ export class PopoverItemDefault extends PopoverItem { el.classList.add(css.disabled); } + if (params.hint !== undefined && renderParams?.hint?.enabled !== false) { + this.addHint(el, { + ...params.hint, + position: renderParams?.hint?.position || 'right', + }); + } + return el; } diff --git a/src/components/utils/popover/components/popover-item/popover-item-html/popover-item-html.ts b/src/components/utils/popover/components/popover-item/popover-item-html/popover-item-html.ts index 138c30326..3fed70042 100644 --- a/src/components/utils/popover/components/popover-item/popover-item-html/popover-item-html.ts +++ b/src/components/utils/popover/components/popover-item/popover-item-html/popover-item-html.ts @@ -1,5 +1,5 @@ import { PopoverItem } from '../popover-item'; -import { PopoverItemHtmlParams } from '../popover-item.types'; +import { PopoverItemHtmlParams, PopoverItemRenderParamsMap, PopoverItemType } from '../popover-item.types'; import { css } from './popover-item-html.const'; import Dom from '../../../../../dom'; @@ -16,8 +16,10 @@ export class PopoverItemHtml extends PopoverItem { * Constructs the instance * * @param params – instance parameters + * @param renderParams – popover item render params. + * The parameters that are not set by user via popover api but rather depend on technical implementation */ - constructor(params: PopoverItemHtmlParams) { + constructor(params: PopoverItemHtmlParams, renderParams?: PopoverItemRenderParamsMap[PopoverItemType.Html]) { super(); this.nodes = { @@ -25,6 +27,13 @@ export class PopoverItemHtml extends PopoverItem { }; this.nodes.root.appendChild(params.element); + + if (params.hint !== undefined && renderParams?.hint?.enabled !== false) { + this.addHint(this.nodes.root, { + ...params.hint, + position: renderParams?.hint?.position || 'right', + }); + } } /** diff --git a/src/components/utils/popover/components/popover-item/popover-item.ts b/src/components/utils/popover/components/popover-item/popover-item.ts index b0eb95d7a..a9a4713a9 100644 --- a/src/components/utils/popover/components/popover-item/popover-item.ts +++ b/src/components/utils/popover/components/popover-item/popover-item.ts @@ -1,7 +1,25 @@ +import * as tooltip from '../../../../utils/tooltip'; +import { type HintPosition, Hint } from '../hint'; + /** * Popover item abstract class */ export abstract class PopoverItem { + /** + * Adds hint to the item element if hint data is provided + * + * @param itemElement - popover item root element to add hint to + * @param hintData - hint data + */ + protected addHint(itemElement: HTMLElement, hintData: { title: string, description?: string; position: HintPosition }): void { + const content = new Hint(hintData); + + tooltip.onHover(itemElement, content.getElement(), { + placement: hintData.position, + hidingDelay: 100, + }); + } + /** * Returns popover item root element */ diff --git a/src/components/utils/popover/components/popover-item/popover-item.types.ts b/src/components/utils/popover/components/popover-item/popover-item.types.ts index 8fa8d0962..c0a5501e2 100644 --- a/src/components/utils/popover/components/popover-item/popover-item.types.ts +++ b/src/components/utils/popover/components/popover-item/popover-item.types.ts @@ -1,3 +1,5 @@ +import { HintParams, HintPosition } from '../hint'; + /** * Popover item types */ @@ -35,7 +37,12 @@ export interface PopoverItemHtmlParams { /** * Custom html content to be displayed in the popover */ - element: HTMLElement + element: HTMLElement; + + /** + * Hint data to be displayed on item hover + */ + hint?: HintParams; } /** @@ -89,6 +96,11 @@ interface PopoverItemDefaultBaseParams { * In case of string, works like radio buttons group and highlights as inactive any other item that has same toggle key value. */ toggle?: boolean | string; + + /** + * Hint data to be displayed on item hover + */ + hint?: HintParams; } /** @@ -117,7 +129,6 @@ export interface PopoverItemWithoutConfirmationParams extends PopoverItemDefault * @param event - event that initiated item activation */ onActivate: (item: PopoverItemParams, event?: PointerEvent) => void; - } @@ -152,3 +163,28 @@ export type PopoverItemParams = PopoverItemSeparatorParams | PopoverItemHtmlParams; + +/** + * Popover item render params. + * The parameters that are not set by user via popover api but rather depend on technical implementation + */ +export type PopoverItemRenderParamsMap = { + [key in PopoverItemType.Default | PopoverItemType.Html]?: { + /** + * Hint render params + */ + hint?: { + /** + * Hint position relative to the item + */ + position?: HintPosition; + + /** + * If false, hint will not be rendered. + * True by default. + * Used to disable hints on mobile popover + */ + enabled: boolean; + } + }; +}; diff --git a/src/components/utils/popover/popover-abstract.ts b/src/components/utils/popover/popover-abstract.ts index 418195b1b..2bb096288 100644 --- a/src/components/utils/popover/popover-abstract.ts +++ b/src/components/utils/popover/popover-abstract.ts @@ -1,4 +1,4 @@ -import { PopoverItem, PopoverItemDefault, PopoverItemSeparator, PopoverItemType } from './components/popover-item'; +import { PopoverItem, PopoverItemDefault, PopoverItemRenderParamsMap, PopoverItemSeparator, PopoverItemType } from './components/popover-item'; import Dom from '../../dom'; import { SearchInput, SearchInputEvent, SearchableItem } from './components/search-input'; import EventsDispatcher from '../events'; @@ -39,6 +39,7 @@ export abstract class PopoverAbstract */ protected search: SearchInput | undefined; + /** * Messages that will be displayed in popover */ @@ -51,8 +52,13 @@ export abstract class PopoverAbstract * Constructs the instance * * @param params - popover construction params + * @param itemsRenderParams - popover item render params. + * The parameters that are not set by user via popover api but rather depend on technical implementation */ - constructor(protected readonly params: PopoverParams) { + constructor( + protected readonly params: PopoverParams, + protected readonly itemsRenderParams: PopoverItemRenderParamsMap = {} + ) { super(); this.items = this.buildItems(params.items); @@ -154,9 +160,9 @@ export abstract class PopoverAbstract case PopoverItemType.Separator: return new PopoverItemSeparator(); case PopoverItemType.Html: - return new PopoverItemHtml(item); + return new PopoverItemHtml(item, this.itemsRenderParams[PopoverItemType.Html]); default: - return new PopoverItemDefault(item); + return new PopoverItemDefault(item, this.itemsRenderParams[PopoverItemType.Default]); } }); } diff --git a/src/components/utils/popover/popover-desktop.ts b/src/components/utils/popover/popover-desktop.ts index 5d6440b00..1570d2fe9 100644 --- a/src/components/utils/popover/popover-desktop.ts +++ b/src/components/utils/popover/popover-desktop.ts @@ -12,6 +12,8 @@ import { PopoverItemHtml } from './components/popover-item/popover-item-html/pop /** * Desktop popover. * On desktop devices popover behaves like a floating element. Nested popover appears at right or left side. + * + * @todo support rtl for nested popovers and search */ export class PopoverDesktop extends PopoverAbstract { /** diff --git a/src/components/utils/popover/popover-mobile.ts b/src/components/utils/popover/popover-mobile.ts index c309b95ca..2bf849ee5 100644 --- a/src/components/utils/popover/popover-mobile.ts +++ b/src/components/utils/popover/popover-mobile.ts @@ -3,10 +3,11 @@ import ScrollLocker from '../scroll-locker'; import { PopoverHeader } from './components/popover-header'; import { PopoverStatesHistory } from './utils/popover-states-history'; import { PopoverMobileNodes, PopoverParams } from './popover.types'; -import { PopoverItemDefault, PopoverItemParams } from './components/popover-item'; +import { PopoverItemDefault, PopoverItemParams, PopoverItemType } from './components/popover-item'; import { css } from './popover.const'; import Dom from '../../dom'; + /** * Mobile Popover. * On mobile devices Popover behaves like a fixed panel at the bottom of screen. Nested item appears like "pages" with the "back" button @@ -41,7 +42,13 @@ export class PopoverMobile extends PopoverAbstract { * @param params - popover params */ constructor(params: PopoverParams) { - super(params); + super(params, { + [PopoverItemType.Default]: { + hint: { + enabled: false, + }, + }, + }); this.nodes.overlay = Dom.make('div', [css.overlay, css.overlayHidden]); this.nodes.popover.insertBefore(this.nodes.overlay, this.nodes.popover.firstChild); @@ -112,8 +119,8 @@ export class PopoverMobile extends PopoverAbstract { /** * Removes rendered popover items and header and displays new ones * - * @param title - new popover header text * @param items - new popover items + * @param title - new popover header text */ private updateItemsAndHeader(items: PopoverItemParams[], title?: string ): void { /** Re-render header */ From 29d68ecb47644a55363e4a3592ed66754632662e Mon Sep 17 00:00:00 2001 From: Peter Savchenko Date: Thu, 23 May 2024 20:06:33 +0300 Subject: [PATCH 28/61] fix(block-events): caret losing after backspace after nested list (#2723) * feat: Fix caret loss after Backspace at the start of block when previous block is not convertible * fix create shadow caret * fix: remove unnecessary blank line in blockEvents.ts * fix: pass event object to slashPressed method in blockEvents.ts * fix eslint --- docs/CHANGELOG.md | 1 + package.json | 2 +- src/components/modules/blockEvents.ts | 10 +- .../tests/modules/BlockEvents/Backspace.cy.ts | 92 ++++++++++++++++++- 4 files changed, 96 insertions(+), 9 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 90f6c361c..23a9a5869 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -13,6 +13,7 @@ - `Fix` – Unwanted scroll on first typing on iOS devices - `Fix` - Unwanted soft line break on Enter press after period and space (". |") on iOS devices - `Fix` - Caret lost after block conversion on mobile devices. +- `Fix` - Caret lost after Backspace at the start of block when previoius block is not convertable - `Improvement` - The API `blocks.convert()` now returns the new block API - `Improvement` - The API `caret.setToBlock()` now can accept either BlockAPI or block index or block id - `New` – *Menu Config* – New item type – HTML diff --git a/package.json b/package.json index 401ecf4c6..f3d80a868 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@editorjs/editorjs", - "version": "2.30.0-rc.9", + "version": "2.30.0-rc.10", "description": "Editor.js — Native JS, based on API and Open Source", "main": "dist/editorjs.umd.js", "module": "dist/editorjs.mjs", diff --git a/src/components/modules/blockEvents.ts b/src/components/modules/blockEvents.ts index f1e98336e..9504e7b6a 100644 --- a/src/components/modules/blockEvents.ts +++ b/src/components/modules/blockEvents.ts @@ -60,7 +60,7 @@ export default class BlockEvents extends Module { * @todo probably using "beforeInput" event would be better here */ if (event.key === '/' && !event.ctrlKey && !event.metaKey) { - this.slashPressed(); + this.slashPressed(event); } /** @@ -233,8 +233,10 @@ export default class BlockEvents extends Module { /** * '/' keydown inside a Block + * + * @param event - keydown */ - private slashPressed(): void { + private slashPressed(event: KeyboardEvent): void { const currentBlock = this.Editor.BlockManager.currentBlock; const canOpenToolbox = currentBlock.isEmpty; @@ -395,7 +397,7 @@ export default class BlockEvents extends Module { return; } - const bothBlocksMergeable = areBlocksMergeable(currentBlock, previousBlock); + const bothBlocksMergeable = areBlocksMergeable(previousBlock, currentBlock); /** * If Blocks could be merged, do it @@ -499,7 +501,7 @@ export default class BlockEvents extends Module { private mergeBlocks(targetBlock: Block, blockToMerge: Block): void { const { BlockManager, Caret, Toolbar } = this.Editor; - Caret.createShadow(targetBlock.pluginsContent); + Caret.createShadow(targetBlock.lastInput); BlockManager .mergeBlocks(targetBlock, blockToMerge) diff --git a/test/cypress/tests/modules/BlockEvents/Backspace.cy.ts b/test/cypress/tests/modules/BlockEvents/Backspace.cy.ts index d9c8be04a..cbf51ab40 100644 --- a/test/cypress/tests/modules/BlockEvents/Backspace.cy.ts +++ b/test/cypress/tests/modules/BlockEvents/Backspace.cy.ts @@ -1,6 +1,7 @@ import type EditorJS from '../../../../../types/index'; import Chainable = Cypress.Chainable; import { SimpleHeader } from '../../../fixtures/tools/SimpleHeader'; +import type { ConversionConfig } from '../../../../../types/index'; /** @@ -425,11 +426,11 @@ describe('Backspace keydown', function () { .should('not.have.class', 'ce-toolbar--opened'); }); - it('should simply set Caret to the end of the previous Block if Caret at the start of the Block but Blocks are not mergeable. Also, should close the Toolbox.', function () { + it('should simply set Caret to the end of the previous Block if Caret at the start of the Block but Blocks are not mergeable (target Bock is lack of merge() and conversionConfig). Also, should close the Toolbox.', function () { /** - * Mock of tool without merge method + * Mock of tool without merge() method */ - class ExampleOfUnmergeableTool { + class UnmergeableToolWithoutConversionConfig { /** * Render method mock */ @@ -452,7 +453,90 @@ describe('Backspace keydown', function () { cy.createEditor({ tools: { - code: ExampleOfUnmergeableTool, + code: UnmergeableToolWithoutConversionConfig, + }, + data: { + blocks: [ + { + type: 'code', + data: {}, + }, + { + type: 'paragraph', + data: { + text: 'Second block', + }, + }, + ], + }, + }); + + cy.get('[data-cy=editorjs]') + .find('.ce-paragraph') + .last() + .click() + .type('{home}') + .type('{backspace}'); + + cy.get('[data-cy=editorjs]') + .find('[data-cy=unmergeable-tool]') + .as('firstBlock'); + + /** + * Caret is set to the previous Block + */ + cy.window() + .then((window) => { + const selection = window.getSelection(); + const range = selection.getRangeAt(0); + + cy.get('@firstBlock').should(($div) => { + expect($div[0].contains(range.startContainer)).to.be.true; + }); + }); + }); + + it('should simply set Caret to the end of the previous Block if Caret at the start of the Block but Blocks are not mergeable (target Bock is lack of merge() but has the conversionConfig). Also, should close the Toolbox.', function () { + /** + * Mock of tool without merge() method + */ + class UnmergeableToolWithConversionConfig { + /** + * Render method mock + */ + public render(): HTMLElement { + const container = document.createElement('div'); + + container.dataset.cy = 'unmergeable-tool'; + container.contentEditable = 'true'; + container.innerHTML = 'Unmergeable not empty tool'; + + return container; + } + + /** + * Saving logic is not necessary for this test + */ + public save(): { key: string } { + return { + key: 'value', + }; + } + + /** + * Mock of the conversionConfig + */ + public static get conversionConfig(): ConversionConfig { + return { + export: 'key', + import: 'key', + }; + } + } + + cy.createEditor({ + tools: { + code: UnmergeableToolWithConversionConfig, }, data: { blocks: [ From afa99a4a8e4676bc8e0a9cb45839fa0915e4be03 Mon Sep 17 00:00:00 2001 From: Peter Savchenko Date: Fri, 28 Jun 2024 21:20:53 +0300 Subject: [PATCH 29/61] fix(caret): trailing spaces handling (#2741) * Imporove caret.isAtStart * use selection to simplify caret at start/end check * caret util and tests * lint * eslint fix * fix tests * patch version, changelog * fix navigation out of delimiter * arrow left tests * left/right arrow tests * chore: Fix typo in comment for block navigation * lint fix * resolve some ts errors in strict mode * Revert "resolve some ts errors in strict mode" This reverts commit 3252ac679f21242c436341e462c327d9bdba6700. * ts errors fix * rename utils --- .eslintrc | 3 + docs/CHANGELOG.md | 1 + package.json | 2 +- src/components/block/index.ts | 33 +- src/components/dom.ts | 31 +- src/components/modules/blockEvents.ts | 35 ++- src/components/modules/caret.ts | 279 ++--------------- src/components/utils/bem.ts | 6 +- src/components/utils/caret.ts | 188 +++++++++++ .../cypress/fixtures/tools/ContentlessTool.ts | 38 +++ .../utils/createEditorWithTextBlocks.ts | 21 ++ .../tests/modules/BlockEvents/ArrowLeft.cy.ts | 280 +++++++++++++++++ .../modules/BlockEvents/ArrowRight.cy.ts | 293 ++++++++++++++++++ .../tests/modules/BlockEvents/Backspace.cy.ts | 133 ++++++-- .../tests/modules/BlockEvents/Delete.cy.ts | 151 +++++++-- 15 files changed, 1173 insertions(+), 321 deletions(-) create mode 100644 src/components/utils/caret.ts create mode 100644 test/cypress/fixtures/tools/ContentlessTool.ts create mode 100644 test/cypress/support/utils/createEditorWithTextBlocks.ts create mode 100644 test/cypress/tests/modules/BlockEvents/ArrowLeft.cy.ts create mode 100644 test/cypress/tests/modules/BlockEvents/ArrowRight.cy.ts diff --git a/.eslintrc b/.eslintrc index 41bddb053..1548af3a4 100644 --- a/.eslintrc +++ b/.eslintrc @@ -34,5 +34,8 @@ "unknown": true, "requestAnimationFrame": true, "navigator": true + }, + "rules": { + "jsdoc/require-returns-type": "off" } } diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 23a9a5869..66a0629f0 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -19,6 +19,7 @@ - `New` – *Menu Config* – New item type – HTML – `Refactoring` – Switched to Vite as Cypress bundler – `New` – *Menu Config* – Default and HTML items now support hints +– `Fix` — Deleting whitespaces at the start/end of the block ### 2.29.1 diff --git a/package.json b/package.json index f3d80a868..40ec893d2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@editorjs/editorjs", - "version": "2.30.0-rc.10", + "version": "2.30.0-rc.11", "description": "Editor.js — Native JS, based on API and Open Source", "main": "dist/editorjs.umd.js", "module": "dist/editorjs.mjs", diff --git a/src/components/block/index.ts b/src/components/block/index.ts index 1a58e6382..decc9e064 100644 --- a/src/components/block/index.ts +++ b/src/components/block/index.ts @@ -154,8 +154,6 @@ export default class Block extends EventsDispatcher { /** * Cached inputs - * - * @type {HTMLElement[]} */ private cachedInputs: HTMLElement[] = []; @@ -269,8 +267,6 @@ export default class Block extends EventsDispatcher { /** * Find and return all editable elements (contenteditable and native inputs) in the Tool HTML - * - * @returns {HTMLElement[]} */ public get inputs(): HTMLElement[] { /** @@ -299,19 +295,18 @@ export default class Block extends EventsDispatcher { /** * Return current Tool`s input - * - * @returns {HTMLElement} + * If Block doesn't contain inputs, return undefined */ - public get currentInput(): HTMLElement | Node { + public get currentInput(): HTMLElement | undefined { return this.inputs[this.inputIndex]; } /** * Set input index to the passed element * - * @param {HTMLElement | Node} element - HTML Element to set as current input + * @param element - HTML Element to set as current input */ - public set currentInput(element: HTMLElement | Node) { + public set currentInput(element: HTMLElement) { const index = this.inputs.findIndex((input) => input === element || input.contains(element)); if (index !== -1) { @@ -321,19 +316,17 @@ export default class Block extends EventsDispatcher { /** * Return first Tool`s input - * - * @returns {HTMLElement} + * If Block doesn't contain inputs, return undefined */ - public get firstInput(): HTMLElement { + public get firstInput(): HTMLElement | undefined { return this.inputs[0]; } /** * Return first Tool`s input - * - * @returns {HTMLElement} + * If Block doesn't contain inputs, return undefined */ - public get lastInput(): HTMLElement { + public get lastInput(): HTMLElement | undefined { const inputs = this.inputs; return inputs[inputs.length - 1]; @@ -341,19 +334,17 @@ export default class Block extends EventsDispatcher { /** * Return next Tool`s input or undefined if it doesn't exist - * - * @returns {HTMLElement} + * If Block doesn't contain inputs, return undefined */ - public get nextInput(): HTMLElement { + public get nextInput(): HTMLElement | undefined { return this.inputs[this.inputIndex + 1]; } /** * Return previous Tool`s input or undefined if it doesn't exist - * - * @returns {HTMLElement} + * If Block doesn't contain inputs, return undefined */ - public get previousInput(): HTMLElement { + public get previousInput(): HTMLElement | undefined { return this.inputs[this.inputIndex - 1]; } diff --git a/src/components/dom.ts b/src/components/dom.ts index 1bf2b1c47..c52938ae3 100644 --- a/src/components/dom.ts +++ b/src/components/dom.ts @@ -2,6 +2,8 @@ import * as _ from './utils'; /** * DOM manipulations helper + * + * @todo get rid of class and make separate utility functions */ export default class Dom { /** @@ -211,9 +213,10 @@ export default class Dom { * @param {Node} node - root Node. From this vertex we start Deep-first search * {@link https://en.wikipedia.org/wiki/Depth-first_search} * @param {boolean} [atLast] - find last text node - * @returns {Node} - it can be text Node or Element Node, so that caret will able to work with it + * @returns - it can be text Node or Element Node, so that caret will able to work with it + * Can return null if node is Document or DocumentFragment, or node is not attached to the DOM */ - public static getDeepestNode(node: Node, atLast = false): Node { + public static getDeepestNode(node: Node, atLast = false): Node | null { /** * Current function have two directions: * - starts from first child and every time gets first or nextSibling in special cases @@ -590,3 +593,27 @@ export default class Dom { }; } } + +/** + * Determine whether a passed text content is a collapsed whitespace. + * + * In HTML, whitespaces at the start and end of elements and outside elements are ignored. + * There are two types of whitespaces in HTML: + * - Visible ( ) + * - Invisible (regular trailing spaces, tabs, etc) + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/Document_Object_Model/Whitespace + * @see https://www.w3.org/TR/css-text-3/#white-space-processing + * @param textContent — any string, for ex a textContent of a node + * @returns True if passed text content is whitespace which is collapsed (invisible) in browser + */ +export function isCollapsedWhitespaces(textContent: string): boolean { + /** + * Throughout, whitespace is defined as one of the characters + * "\t" TAB \u0009 + * "\n" LF \u000A + * "\r" CR \u000D + * " " SPC \u0020 + */ + return !/[^\t\n\r ]/.test(textContent); +} diff --git a/src/components/modules/blockEvents.ts b/src/components/modules/blockEvents.ts index 9504e7b6a..7228798f8 100644 --- a/src/components/modules/blockEvents.ts +++ b/src/components/modules/blockEvents.ts @@ -7,6 +7,7 @@ import SelectionUtils from '../selection'; import Flipper from '../flipper'; import type Block from '../block'; import { areBlocksMergeable } from '../utils/blocks'; +import * as caretUtils from '../utils/caret'; /** * @@ -270,6 +271,10 @@ export default class BlockEvents extends Module { const { BlockManager, UI } = this.Editor; const currentBlock = BlockManager.currentBlock; + if (currentBlock === undefined) { + return; + } + /** * Don't handle Enter keydowns when Tool sets enableLineBreaks to true. * Uses for Tools like where line breaks should be handled by default behaviour. @@ -297,34 +302,34 @@ export default class BlockEvents extends Module { return; } - let newCurrent = this.Editor.BlockManager.currentBlock; + let blockToFocus = currentBlock; /** * If enter has been pressed at the start of the text, just insert paragraph Block above */ - if (this.Editor.Caret.isAtStart && !this.Editor.BlockManager.currentBlock.hasMedia) { + if (currentBlock.currentInput !== undefined && caretUtils.isCaretAtStartOfInput(currentBlock.currentInput) && !currentBlock.hasMedia) { this.Editor.BlockManager.insertDefaultBlockAtIndex(this.Editor.BlockManager.currentBlockIndex); /** * If caret is at very end of the block, just append the new block without splitting * to prevent unnecessary dom mutation observing */ - } else if (this.Editor.Caret.isAtEnd) { - newCurrent = this.Editor.BlockManager.insertDefaultBlockAtIndex(this.Editor.BlockManager.currentBlockIndex + 1); + } else if (currentBlock.currentInput && caretUtils.isCaretAtEndOfInput(currentBlock.currentInput)) { + blockToFocus = this.Editor.BlockManager.insertDefaultBlockAtIndex(this.Editor.BlockManager.currentBlockIndex + 1); } else { /** * Split the Current Block into two blocks * Renew local current node after split */ - newCurrent = this.Editor.BlockManager.split(); + blockToFocus = this.Editor.BlockManager.split(); } - this.Editor.Caret.setToBlock(newCurrent); + this.Editor.Caret.setToBlock(blockToFocus); /** * Show Toolbar */ - this.Editor.Toolbar.moveAndOpen(newCurrent); + this.Editor.Toolbar.moveAndOpen(blockToFocus); event.preventDefault(); } @@ -338,6 +343,10 @@ export default class BlockEvents extends Module { const { BlockManager, Caret } = this.Editor; const { currentBlock, previousBlock } = BlockManager; + if (currentBlock === undefined) { + return; + } + /** * If some fragment is selected, leave native behaviour */ @@ -348,7 +357,7 @@ export default class BlockEvents extends Module { /** * If caret is not at the start, leave native behaviour */ - if (!Caret.isAtStart) { + if (!currentBlock.currentInput || !caretUtils.isCaretAtStartOfInput(currentBlock.currentInput)) { return; } /** @@ -431,7 +440,7 @@ export default class BlockEvents extends Module { /** * If caret is not at the end, leave native behaviour */ - if (!Caret.isAtEnd) { + if (!caretUtils.isCaretAtEndOfInput(currentBlock.currentInput)) { return; } @@ -534,7 +543,9 @@ export default class BlockEvents extends Module { */ this.Editor.Toolbar.close(); - const shouldEnableCBS = this.Editor.Caret.isAtEnd || this.Editor.BlockSelection.anyBlockSelected; + const { currentBlock } = this.Editor.BlockManager; + const caretAtEnd = currentBlock?.currentInput !== undefined ? caretUtils.isCaretAtEndOfInput(currentBlock.currentInput) : undefined; + const shouldEnableCBS = caretAtEnd || this.Editor.BlockSelection.anyBlockSelected; if (event.shiftKey && event.keyCode === _.keyCodes.DOWN && shouldEnableCBS) { this.Editor.CrossBlockSelection.toggleBlockSelectedState(); @@ -594,7 +605,9 @@ export default class BlockEvents extends Module { */ this.Editor.Toolbar.close(); - const shouldEnableCBS = this.Editor.Caret.isAtStart || this.Editor.BlockSelection.anyBlockSelected; + const { currentBlock } = this.Editor.BlockManager; + const caretAtStart = currentBlock?.currentInput !== undefined ? caretUtils.isCaretAtStartOfInput(currentBlock.currentInput) : undefined; + const shouldEnableCBS = caretAtStart || this.Editor.BlockSelection.anyBlockSelected; if (event.shiftKey && event.keyCode === _.keyCodes.UP && shouldEnableCBS) { this.Editor.CrossBlockSelection.toggleBlockSelectedState(false); diff --git a/src/components/modules/caret.ts b/src/components/modules/caret.ts index 57d5c964d..5193daa3a 100644 --- a/src/components/modules/caret.ts +++ b/src/components/modules/caret.ts @@ -1,19 +1,14 @@ -/** - * @class Caret - * @classdesc Contains methods for working Caret - * - * Uses Range methods to manipulate with caret - * @module Caret - * @version 2.0.0 - */ - import Selection from '../selection'; import Module from '../__module'; import Block from '../block'; -import $ from '../dom'; +import * as caretUtils from '../utils/caret'; +import $ from '../dom'; /** - * @typedef {Caret} Caret + * Caret + * Contains methods for working Caret + * + * @todo get rid of this module and separate it for utility functions */ export default class Caret extends Module { /** @@ -39,196 +34,6 @@ export default class Caret extends Module { }; } - /** - * Get's deepest first node and checks if offset is zero - * - * @returns {boolean} - */ - public get isAtStart(): boolean { - const { currentBlock } = this.Editor.BlockManager; - - /** - * If Block does not contain inputs, treat caret as "at start" - */ - if (!currentBlock?.focusable) { - return true; - } - - const selection = Selection.get(); - const firstNode = $.getDeepestNode(currentBlock.currentInput); - let focusNode = selection.focusNode; - - /** In case lastNode is native input */ - if ($.isNativeInput(firstNode)) { - return (firstNode as HTMLInputElement).selectionEnd === 0; - } - - /** Case when selection have been cleared programmatically, for example after CBS */ - if (!selection.anchorNode) { - return false; - } - - /** - * Workaround case when caret in the text like " |Hello!" - * selection.anchorOffset is 1, but real caret visible position is 0 - * - * @type {number} - */ - - let firstLetterPosition = focusNode.textContent.search(/\S/); - - if (firstLetterPosition === -1) { // empty text - firstLetterPosition = 0; - } - - /** - * If caret was set by external code, it might be set to text node wrapper. - *
|hello
<---- Selection references to
instead of text node - * - * In this case, anchor node has ELEMENT_NODE node type. - * Anchor offset shows amount of children between start of the element and caret position. - * - * So we use child with focusOffset index as new anchorNode. - */ - let focusOffset = selection.focusOffset; - - if (focusNode.nodeType !== Node.TEXT_NODE && focusNode.childNodes.length) { - if (focusNode.childNodes[focusOffset]) { - focusNode = focusNode.childNodes[focusOffset]; - focusOffset = 0; - } else { - focusNode = focusNode.childNodes[focusOffset - 1]; - focusOffset = focusNode.textContent.length; - } - } - - /** - * In case of - *
- *

<-- first (and deepest) node is - * |adaddad <-- focus node - *
- */ - if ($.isLineBreakTag(firstNode as HTMLElement) || $.isEmpty(firstNode)) { - const leftSiblings = this.getHigherLevelSiblings(focusNode as HTMLElement, 'left'); - const nothingAtLeft = leftSiblings.every((node) => { - /** - * Workaround case when block starts with several
's (created by SHIFT+ENTER) - * - * @see https://github.com/codex-team/editor.js/issues/726 - * We need to allow to delete such line breaks, so in this case caret IS NOT AT START - */ - const regularLineBreak = $.isLineBreakTag(node); - /** - * Workaround SHIFT+ENTER in Safari, that creates

instead of
- */ - const lineBreakInSafari = node.children.length === 1 && $.isLineBreakTag(node.children[0] as HTMLElement); - const isLineBreak = regularLineBreak || lineBreakInSafari; - - return $.isEmpty(node) && !isLineBreak; - }); - - if (nothingAtLeft && focusOffset === firstLetterPosition) { - return true; - } - } - - /** - * We use <= comparison for case: - * "| Hello" <--- selection.anchorOffset is 0, but firstLetterPosition is 1 - */ - return firstNode === null || (focusNode === firstNode && focusOffset <= firstLetterPosition); - } - - /** - * Get's deepest last node and checks if offset is last node text length - * - * @returns {boolean} - */ - public get isAtEnd(): boolean { - const { currentBlock } = this.Editor.BlockManager; - - /** - * If Block does not contain inputs, treat caret as "at end" - */ - if (!currentBlock.focusable) { - return true; - } - - const selection = Selection.get(); - let focusNode = selection.focusNode; - - const lastNode = $.getDeepestNode(currentBlock.currentInput, true); - - /** In case lastNode is native input */ - if ($.isNativeInput(lastNode)) { - return (lastNode as HTMLInputElement).selectionEnd === (lastNode as HTMLInputElement).value.length; - } - - /** Case when selection have been cleared programmatically, for example after CBS */ - if (!selection.focusNode) { - return false; - } - - /** - * If caret was set by external code, it might be set to text node wrapper. - *
hello|
<---- Selection references to
instead of text node - * - * In this case, anchor node has ELEMENT_NODE node type. - * Anchor offset shows amount of children between start of the element and caret position. - * - * So we use child with focusOffset - 1 as new focusNode. - */ - let focusOffset = selection.focusOffset; - - if (focusNode.nodeType !== Node.TEXT_NODE && focusNode.childNodes.length) { - if (focusNode.childNodes[focusOffset - 1]) { - focusNode = focusNode.childNodes[focusOffset - 1]; - focusOffset = focusNode.textContent.length; - } else { - focusNode = focusNode.childNodes[0]; - focusOffset = 0; - } - } - - /** - * In case of - *
- * adaddad| <-- anchor node - *

<-- first (and deepest) node is - *
- */ - if ($.isLineBreakTag(lastNode as HTMLElement) || $.isEmpty(lastNode)) { - const rightSiblings = this.getHigherLevelSiblings(focusNode as HTMLElement, 'right'); - const nothingAtRight = rightSiblings.every((node, i) => { - /** - * If last right sibling is BR isEmpty returns false, but there actually nothing at right - */ - const isLastBR = i === rightSiblings.length - 1 && $.isLineBreakTag(node as HTMLElement); - - return isLastBR || ($.isEmpty(node) && !$.isLineBreakTag(node)); - }); - - if (nothingAtRight && focusOffset === focusNode.textContent.length) { - return true; - } - } - - /** - * Workaround case: - * hello | <--- anchorOffset will be 5, but textContent.length will be 6. - * Why not regular .trim(): - * in case of ' hello |' trim() will also remove space at the beginning, so length will be lower than anchorOffset - */ - const rightTrimmedText = lastNode.textContent.replace(/\s+$/, ''); - - /** - * We use >= comparison for case: - * "Hello |" <--- selection.anchorOffset is 7, but rightTrimmedText is 6 - */ - return focusNode === lastNode && focusOffset >= rightTrimmedText.length; - } - /** * Method gets Block instance and puts caret to the text node with offset * There two ways that method applies caret position: @@ -428,11 +233,23 @@ export default class Caret extends Module { public navigateNext(force = false): boolean { const { BlockManager } = this.Editor; const { currentBlock, nextBlock } = BlockManager; - const { nextInput } = currentBlock; - const isAtEnd = this.isAtEnd; + + if (currentBlock === undefined) { + return false; + } + + const { nextInput, currentInput } = currentBlock; + const isAtEnd = currentInput !== undefined ? caretUtils.isCaretAtEndOfInput(currentInput) : undefined; + let blockToNavigate = nextBlock; - const navigationAllowed = force || isAtEnd; + /** + * We should jump to the next block if: + * - 'force' is true (Tab-navigation) + * - caret is at the end of the current block + * - block does not contain any inputs (e.g. to allow go next when Delimiter is focused) + */ + const navigationAllowed = force || isAtEnd || !currentBlock.focusable; /** If next Tool`s input exists, focus on it. Otherwise set caret to the next Block */ if (nextInput && navigationAllowed) { @@ -486,8 +303,16 @@ export default class Caret extends Module { return false; } - const { previousInput } = currentBlock; - const navigationAllowed = force || this.isAtStart; + const { previousInput, currentInput } = currentBlock; + + /** + * We should jump to the previous block if: + * - 'force' is true (Tab-navigation) + * - caret is at the start of the current block + * - block does not contain any inputs (e.g. to allow go back when Delimiter is focused) + */ + const caretAtStart = currentInput !== undefined ? caretUtils.isCaretAtStartOfInput(currentInput) : undefined; + const navigationAllowed = force || caretAtStart || !currentBlock.focusable; /** If previous Tool`s input exists, focus on it. Otherwise set caret to the previous Block */ if (previousInput && navigationAllowed) { @@ -586,46 +411,4 @@ export default class Caret extends Module { selection.removeAllRanges(); selection.addRange(newRange); } - - /** - * Get all first-level (first child of [contenteditable]) siblings from passed node - * Then you can check it for emptiness - * - * @example - * - * @param {HTMLElement} from - element from which siblings should be searched - * @param {'left' | 'right'} direction - direction of search - * @returns {HTMLElement[]} - */ - private getHigherLevelSiblings(from: HTMLElement, direction?: 'left' | 'right'): HTMLElement[] { - let current = from; - const siblings = []; - - /** - * Find passed node's firs-level parent (in example - blockquote) - */ - while (current.parentNode && (current.parentNode as HTMLElement).contentEditable !== 'true') { - current = current.parentNode as HTMLElement; - } - - const sibling = direction === 'left' ? 'previousSibling' : 'nextSibling'; - - /** - * Find all left/right siblings - */ - while (current[sibling]) { - current = current[sibling] as HTMLElement; - siblings.push(current); - } - - return siblings; - } } diff --git a/src/components/utils/bem.ts b/src/components/utils/bem.ts index 264c2bf53..428891e2f 100644 --- a/src/components/utils/bem.ts +++ b/src/components/utils/bem.ts @@ -9,10 +9,12 @@ const MODIFIER_DELIMITER = '--'; * @example bem('ce-popover)('container', 'hidden') -> 'ce-popover__container--hidden' * @example bem('ce-popover)(null, 'hidden') -> 'ce-popover--hidden' * @param blockName - string with block name - * @param elementName - string with element name - * @param modifier - modifier to be appended */ export function bem(blockName: string) { + /** + * @param elementName - string with element name + * @param modifier - modifier to be appended + */ return (elementName?: string | null, modifier?: string) => { const className = [blockName, elementName] .filter(x => !!x) diff --git a/src/components/utils/caret.ts b/src/components/utils/caret.ts new file mode 100644 index 000000000..58bf96044 --- /dev/null +++ b/src/components/utils/caret.ts @@ -0,0 +1,188 @@ +import $, { isCollapsedWhitespaces } from '../dom'; + +/** + * Returns TextNode containing a caret and a caret offset in it + * Returns null if there is no caret set + * + * Handles a case when focusNode is an ElementNode and focusOffset is a child index, + * returns child node with focusOffset index as a new focusNode + */ +export function getCaretNodeAndOffset(): [ Node | null, number ] { + const selection = window.getSelection(); + + if (selection === null) { + return [null, 0]; + } + + let focusNode = selection.focusNode; + let focusOffset = selection.focusOffset; + + if (focusNode === null) { + return [null, 0]; + } + + /** + * Case when focusNode is an Element (or Document). In this case, focusOffset is a child index. + * We need to return child with focusOffset index as a new focusNode. + * + *
|hello
<---- Selection references to
instead of text node + * + * + */ + if (focusNode.nodeType !== Node.TEXT_NODE && focusNode.childNodes.length > 0) { + /** + * In normal cases, focusOffset is a child index. + */ + if (focusNode.childNodes[focusOffset]) { + focusNode = focusNode.childNodes[focusOffset]; + focusOffset = 0; + /** + * But in Firefox, focusOffset can be 1 with the single child. + */ + } else { + focusNode = focusNode.childNodes[focusOffset - 1]; + focusOffset = focusNode.textContent.length; + } + } + + return [focusNode, focusOffset]; +} + +/** + * Checks content at left or right of the passed node for emptiness. + * + * @param contenteditable - The contenteditable element containing the nodes. + * @param fromNode - The starting node to check from. + * @param offsetInsideNode - The offset inside the starting node. + * @param direction - The direction to check ('left' or 'right'). + * @returns true if adjacent content is empty, false otherwise. + */ +export function checkContenteditableSliceForEmptiness(contenteditable: HTMLElement, fromNode: Node, offsetInsideNode: number, direction: 'left' | 'right'): boolean { + const range = document.createRange(); + + /** + * In case of "left": + * Set range from the start of the contenteditable to the passed offset + */ + if (direction === 'left') { + range.setStart(contenteditable, 0); + range.setEnd(fromNode, offsetInsideNode); + + /** + * In case of "right": + * Set range from the passed offset to the end of the contenteditable + */ + } else { + range.setStart(fromNode, offsetInsideNode); + range.setEnd(contenteditable, contenteditable.childNodes.length); + } + + /** + * Clone the range's content and check its text content + */ + const clonedContent = range.cloneContents(); + const tempDiv = document.createElement('div'); + + tempDiv.appendChild(clonedContent); + + const textContent = tempDiv.textContent || ''; + + /** + * In HTML there are two types of whitespaces: + * - visible ( ) + * - invisible (trailing spaces, tabs, etc.) + * + * If text contains only invisible whitespaces, it is considered to be empty + */ + return isCollapsedWhitespaces(textContent); +} + +/** + * Checks if caret is at the start of the passed input + * + * Cases: + * Native input: + * - if offset is 0, caret is at the start + * Contenteditable: + * - caret at the first text node and offset is 0 — caret is at the start + * - caret not at the first text node — we need to check left siblings for emptiness + * - caret offset > 0, but all left part is visible (nbsp) — caret is not at the start + * - caret offset > 0, but all left part is invisible (whitespaces) — caret is at the start + * + * @param input - input where caret should be checked + */ +export function isCaretAtStartOfInput(input: HTMLElement): boolean { + const firstNode = $.getDeepestNode(input); + + if (firstNode === null || $.isEmpty(input)) { + return true; + } + + /** + * In case of native input, we simply check if offset is 0 + */ + if ($.isNativeInput(firstNode)) { + return (firstNode as HTMLInputElement).selectionEnd === 0; + } + + if ($.isEmpty(input)) { + return true; + } + + const [caretNode, caretOffset] = getCaretNodeAndOffset(); + + /** + * If there is no selection, caret is not at the start + */ + if (caretNode === null) { + return false; + } + + /** + * If there is nothing visible to the left of the caret, it is considered to be at the start + */ + return checkContenteditableSliceForEmptiness(input, caretNode, caretOffset, 'left'); +} + +/** + * Checks if caret is at the end of the passed input + * + * Cases: + * Native input: + * - if offset is equal to value length, caret is at the end + * Contenteditable: + * - caret at the last text node and offset is equal to text length — caret is at the end + * - caret not at the last text node — we need to check right siblings for emptiness + * - caret offset < text length, but all right part is visible (nbsp) — caret is at the end + * - caret offset < text length, but all right part is invisible (whitespaces) — caret is at the end + * + * @param input - input where caret should be checked + */ +export function isCaretAtEndOfInput(input: HTMLElement): boolean { + const lastNode = $.getDeepestNode(input, true); + + if (lastNode === null) { + return true; + } + + /** + * In case of native input, we simply check if offset is equal to value length + */ + if ($.isNativeInput(lastNode)) { + return (lastNode as HTMLInputElement).selectionEnd === (lastNode as HTMLInputElement).value.length; + } + + const [caretNode, caretOffset] = getCaretNodeAndOffset(); + + /** + * If there is no selection, caret is not at the end + */ + if (caretNode === null) { + return false; + } + + /** + * If there is nothing visible to the right of the caret, it is considered to be at the end + */ + return checkContenteditableSliceForEmptiness(input, caretNode, caretOffset, 'right'); +} diff --git a/test/cypress/fixtures/tools/ContentlessTool.ts b/test/cypress/fixtures/tools/ContentlessTool.ts new file mode 100644 index 000000000..4ca3ca2ac --- /dev/null +++ b/test/cypress/fixtures/tools/ContentlessTool.ts @@ -0,0 +1,38 @@ +import { BlockTool } from "../../../../types"; + +/** + * In the simplest Contentless Tool (eg. Delimiter) there is no data to save + */ +interface ContentlessToolData {} + +/** + * This tool behaves like a delimiter + */ +export default class ContentlessToolMock implements BlockTool { + /** + * Renders a single content editable element as tools element + */ + public render(): HTMLElement { + const wrapper = document.createElement('div'); + + wrapper.dataset.cyType = 'contentless-tool'; + + wrapper.textContent = '***'; + + return wrapper; + } + + /** + * Save method mock + */ + public save(): ContentlessToolData { + return {}; + } + + /** + * Allow Tool to have no content + */ + public static get contentless(): boolean { + return true; + } +} diff --git a/test/cypress/support/utils/createEditorWithTextBlocks.ts b/test/cypress/support/utils/createEditorWithTextBlocks.ts new file mode 100644 index 000000000..f5797856d --- /dev/null +++ b/test/cypress/support/utils/createEditorWithTextBlocks.ts @@ -0,0 +1,21 @@ +import Chainable = Cypress.Chainable; +import type EditorJS from '../../../../types/index'; + + +/** + * Creates Editor instance with list of Paragraph blocks of passed texts + * + * @param textBlocks - list of texts for Paragraph blocks + */ +export function createEditorWithTextBlocks(textBlocks: string[]): Chainable { + return cy.createEditor({ + data: { + blocks: textBlocks.map((text) => ({ + type: 'paragraph', + data: { + text, + }, + })), + }, + }); +} diff --git a/test/cypress/tests/modules/BlockEvents/ArrowLeft.cy.ts b/test/cypress/tests/modules/BlockEvents/ArrowLeft.cy.ts new file mode 100644 index 000000000..f9081a37a --- /dev/null +++ b/test/cypress/tests/modules/BlockEvents/ArrowLeft.cy.ts @@ -0,0 +1,280 @@ +import { createEditorWithTextBlocks } from '../../../support/utils/createEditorWithTextBlocks'; +import ContentlessToolMock from '../../../fixtures/tools/ContentlessTool'; + +describe('Arrow Left', function () { + describe('starting whitespaces handling', function () { + it(' | — should natively move caret over the visible space. Then move to the prev block', function () { + createEditorWithTextBlocks([ + '1', + ' 2', + ]); + + cy.get('[data-cy=editorjs]') + .find('.ce-paragraph') + .last() // select second block + .click() + .type('{leftArrow}') // set caret before "2" + .type('{leftArrow}') // move caret over nbsp + .type('{leftArrow}'); // move to the prev block + + /** + * Caret is set to the end of the previous block + */ + cy.window() + .then((window) => { + const selection = window.getSelection(); + const range = selection.getRangeAt(0); + + cy.get('[data-cy=editorjs]') + .find('.ce-paragraph') + .should(($block) => { + expect($block[0].contains(range.startContainer)).to.be.true; + expect(range.startOffset).to.eq(1); + }); + }); + }); + it(' | — should ignore invisible space before caret and move caret to the prev block', function () { + createEditorWithTextBlocks([ + '1', + ' 2', + ]); + + cy.get('[data-cy=editorjs]') + .find('.ce-paragraph') + .last() // select second block + .click() + .type('{leftArrow}') // set caret before "2" + .type('{leftArrow}'); // move to the prev block + + /** + * Caret is set to the end of the previous block + */ + cy.window() + .then((window) => { + const selection = window.getSelection(); + const range = selection.getRangeAt(0); + + cy.get('[data-cy=editorjs]') + .find('.ce-paragraph') + .should(($block) => { + expect($block[0].contains(range.startContainer)).to.be.true; + expect(range.startOffset).to.eq(1); + }); + }); + }); + + it('| — should ignore empty tags before caret and move caret to the prev block', function () { + createEditorWithTextBlocks([ + '1', + '2', + ]); + + cy.get('[data-cy=editorjs]') + .find('.ce-paragraph') + .last() // select second block + .click() + .type('{leftArrow}') // set caret before "2" + .type('{leftArrow}'); // move to the prev block + + /** + * Caret is set to the end of the previous block + */ + cy.window() + .then((window) => { + const selection = window.getSelection(); + const range = selection.getRangeAt(0); + + cy.get('[data-cy=editorjs]') + .find('.ce-paragraph') + .should(($block) => { + expect($block[0].contains(range.startContainer)).to.be.true; + expect(range.startOffset).to.eq(1); + }); + }); + }); + it(' | — should move caret over the visible space and then to the prev block', function () { + createEditorWithTextBlocks([ + '1', + ' 2', + ]); + + cy.get('[data-cy=editorjs]') + .find('.ce-paragraph') + .last() + .click() + .type('{leftArrow}') // set caret before "2" + .type('{leftArrow}') // move caret over nbsp + .type('{leftArrow}'); // move to the prev block + + /** + * Caret is set to the end of the previous block + */ + cy.window() + .then((window) => { + const selection = window.getSelection(); + const range = selection.getRangeAt(0); + + cy.get('[data-cy=editorjs]') + .find('.ce-paragraph') + .should(($block) => { + expect($block[0].contains(range.startContainer)).to.be.true; + expect(range.startOffset).to.eq(1); + }); + }); + }); + + it(' | — should ignore empty tag and move caret over the visible space. Then move to the prev block', function () { + createEditorWithTextBlocks([ + '1', + ' 2', + ]); + + cy.get('[data-cy=editorjs]') + .find('.ce-paragraph') + .last() + .click() + .type('{leftArrow}') // set caret before "2" + .type('{leftArrow}') // ignore empty tag and move caret over nbsp + .type('{leftArrow}'); // move to the prev block + + /** + * Caret is set to the end of the previous block + */ + cy.window() + .then((window) => { + const selection = window.getSelection(); + const range = selection.getRangeAt(0); + + cy.get('[data-cy=editorjs]') + .find('.ce-paragraph') + .should(($block) => { + expect($block[0].contains(range.startContainer)).to.be.true; + expect(range.startOffset).to.eq(1); + }); + }); + }); + + it('  | — should move caret over the visible space. Then move to the prev block', function () { + createEditorWithTextBlocks([ + '1', + '  2', + ]); + + cy.get('[data-cy=editorjs]') + .find('.ce-paragraph') + .last() + .click() + .type('{leftArrow}') // set caret before "2" + .type('{leftArrow}') // remove nbsp + .type('{leftArrow}'); // ignore regular space and move to the prev block + + /** + * Caret is set to the end of the previous block + */ + cy.window() + .then((window) => { + const selection = window.getSelection(); + const range = selection.getRangeAt(0); + + cy.get('[data-cy=editorjs]') + .find('.ce-paragraph') + .should(($block) => { + expect($block[0].contains(range.startContainer)).to.be.true; + expect(range.startOffset).to.eq(1); + }); + }); + }); + }); + + /** + * In this test we check case: + * + * Text Block №1 + * Delimiter + * Text Block №2 + * + * we set caret to the start of the Text Block №2 and press Left Arrow + * + * Expected: Delimiter is selected + * + * Then we press Left Arrow again + * + * Expected: Caret is set to the end of the Text Block №1 + */ + it('should move caret to the prev block if currently focused block is contentless (Delimiter)', function () { + cy.createEditor({ + tools: { + delimiter: ContentlessToolMock, + }, + data: { + blocks: [ + { + id: 'block1', + type: 'paragraph', + data: { + text: '1', + }, + }, + { + id: 'block2', + type: 'delimiter', + data: {}, + }, + { + id: 'block3', + type: 'paragraph', + data: { + text: '2', + }, + }, + ], + }, + }); + + cy.get('[data-cy=editorjs]') + .find('.ce-paragraph') + .last() + .as('thirdBlock') + .click() + .type('{moveToStart}') // set caret before "2" + .type('{leftArrow}'); // navigate to the Delimiter + + /** + * We navigated to the Delimiter and it is highlighted + */ + cy.get('[data-cy=editorjs]') + .find('div[data-cy-type=contentless-tool]') + .parents('.ce-block') + .as('delimiterBlock') + .should('have.class', 'ce-block--selected'); + + /** + * Now press Left again and we should be navigated to the end of the previous block + */ + cy.get('@thirdBlock') + .type('{leftArrow}'); + + /** + * Delimiter is not selected anymore + */ + cy.get('@delimiterBlock') + .should('not.have.class', 'ce-block--selected'); + + /** + * Caret is set to the end of the first block + */ + cy.window() + .then((window) => { + const selection = window.getSelection(); + const range = selection.getRangeAt(0); + + cy.get('[data-cy=editorjs]') + .find('.ce-paragraph') + .first() + .should(($block) => { + expect($block[0].contains(range.startContainer)).to.be.true; + expect(range.startOffset).to.eq(1); + }); + }); + }); +}); diff --git a/test/cypress/tests/modules/BlockEvents/ArrowRight.cy.ts b/test/cypress/tests/modules/BlockEvents/ArrowRight.cy.ts new file mode 100644 index 000000000..ee9f88472 --- /dev/null +++ b/test/cypress/tests/modules/BlockEvents/ArrowRight.cy.ts @@ -0,0 +1,293 @@ +import { createEditorWithTextBlocks } from '../../../support/utils/createEditorWithTextBlocks'; +import ContentlessToolMock from '../../../fixtures/tools/ContentlessTool'; + +describe('Arrow Right', function () { + describe('starting whitespaces handling', function () { + it('|  — should natively move caret over the visible space. Then move to the next block', function () { + createEditorWithTextBlocks([ + '1 ', + '2', + ]); + + cy.get('[data-cy=editorjs]') + .find('.ce-paragraph') + .first() // select first block + .as('firstBlock') + .click() + .type('{moveToStart}') + .type('{rightArrow}') // set caret after "1" + .type('{rightArrow}') // move caret over nbsp + .type('{rightArrow}'); // move to the next block + + /** + * Caret is set to the start of the next block + */ + cy.window() + .then((window) => { + const selection = window.getSelection(); + const range = selection.getRangeAt(0); + + cy.get('[data-cy=editorjs]') + .find('.ce-paragraph') + .last() + .should(($block) => { + expect($block[0].contains(range.startContainer)).to.be.true; + expect(range.startOffset).to.eq(0); + }); + }); + }); + + it('"| " — should ignore invisible space after caret and move caret to the next block', function () { + createEditorWithTextBlocks([ + '1 ', + '2', + ]); + + cy.get('[data-cy=editorjs]') + .find('.ce-paragraph') + .first() + .click() + .type('{moveToStart}') + .type('{rightArrow}') // set caret after "1" + .type('{rightArrow}'); // ignore " " and move to the next block + + /** + * Caret is set to the start of the next block + */ + cy.window() + .then((window) => { + const selection = window.getSelection(); + const range = selection.getRangeAt(0); + + cy.get('[data-cy=editorjs]') + .find('.ce-paragraph') + .last() + .should(($block) => { + expect($block[0].contains(range.startContainer)).to.be.true; + expect(range.startOffset).to.eq(0); + }); + }); + }); + + it('| — should ignore empty tags after caret and move caret to the next block', function () { + createEditorWithTextBlocks([ + '1', + '2', + ]); + + cy.get('[data-cy=editorjs]') + .find('.ce-paragraph') + .first() + .click() + .type('{moveToStart}') + .type('{rightArrow}') // set caret after "1" + .type('{rightArrow}'); // ignore empty tag and move to the next block + + /** + * Caret is set to the start of the next block + */ + cy.window() + .then((window) => { + const selection = window.getSelection(); + const range = selection.getRangeAt(0); + + cy.get('[data-cy=editorjs]') + .find('.ce-paragraph') + .last() + .should(($block) => { + expect($block[0].contains(range.startContainer)).to.be.true; + expect(range.startOffset).to.eq(0); + }); + }); + }); + it('|  — should move caret over the visible space and then to the next block', function () { + createEditorWithTextBlocks([ + '1 ', + '2', + ]); + + cy.get('[data-cy=editorjs]') + .find('.ce-paragraph') + .first() + .click() + .type('{moveToStart}') + .type('{rightArrow}') // set caret after "1" + .type('{rightArrow}') // move caret over nbsp + .type('{rightArrow}'); // move to the next block + + /** + * Caret is set to the start of the next block + */ + cy.window() + .then((window) => { + const selection = window.getSelection(); + const range = selection.getRangeAt(0); + + cy.get('[data-cy=editorjs]') + .find('.ce-paragraph') + .last() + .should(($block) => { + expect($block[0].contains(range.startContainer)).to.be.true; + expect(range.startOffset).to.eq(0); + }); + }); + }); + + it('|  — should ignore empty tag and move caret over the visible space. Then move to the next block', function () { + createEditorWithTextBlocks([ + '1 ', + '2', + ]); + + cy.get('[data-cy=editorjs]') + .find('.ce-paragraph') + .first() + .click() + .type('{moveToStart}') + .type('{rightArrow}') // set caret after "1" + .type('{rightArrow}') // ignore empty tag and move caret over nbsp + .type('{rightArrow}'); // move to the next block + + /** + * Caret is set to the start of the next block + */ + cy.window() + .then((window) => { + const selection = window.getSelection(); + const range = selection.getRangeAt(0); + + cy.get('[data-cy=editorjs]') + .find('.ce-paragraph') + .last() + .should(($block) => { + expect($block[0].contains(range.startContainer)).to.be.true; + expect(range.startOffset).to.eq(0); + }); + }); + }); + + it('"|  " — should move caret over the visible space. Then ignore a trailing space and move to the next block', function () { + createEditorWithTextBlocks([ + '1  ', + '2', + ]); + + cy.get('[data-cy=editorjs]') + .find('.ce-paragraph') + .first() + .click() + .type('{moveToStart}') + .type('{rightArrow}') // set caret after "1" + .type('{rightArrow}') // move caret over nbsp + .type('{rightArrow}'); // ignore " " and move to the next block + + /** + * Caret is set to the start of the next block + */ + cy.window() + .then((window) => { + const selection = window.getSelection(); + const range = selection.getRangeAt(0); + + cy.get('[data-cy=editorjs]') + .find('.ce-paragraph') + .last() + .should(($block) => { + expect($block[0].contains(range.startContainer)).to.be.true; + expect(range.startOffset).to.eq(0); + }); + }); + }); + }); + + /** + * In this test we check case: + * + * Text Block №1 + * Delimiter + * Text Block №2 + * + * we set caret to the end of the Text Block №1 and press Right Arrow + * + * Expected: Delimiter is selected + * + * Then we press Right Arrow again + * + * Expected: Caret is set to the start of the Text Block №2 + */ + it('should move caret to the next block if currently focused block is contentless (Delimiter)', function () { + cy.createEditor({ + tools: { + delimiter: ContentlessToolMock, + }, + data: { + blocks: [ + { + id: 'block1', + type: 'paragraph', + data: { + text: '1', + }, + }, + { + id: 'block2', + type: 'delimiter', + data: {}, + }, + { + id: 'block3', + type: 'paragraph', + data: { + text: '2', + }, + }, + ], + }, + }); + + cy.get('[data-cy=editorjs]') + .find('.ce-paragraph') + .first() + .as('firstBlock') + .click() // caret at the end + .type('{rightArrow}'); // navigate to the Delimiter + + /** + * We navigated to the Delimiter and it is highlighted + */ + cy.get('[data-cy=editorjs]') + .find('div[data-cy-type=contentless-tool]') + .parents('.ce-block') + .as('delimiterBlock') + .should('have.class', 'ce-block--selected'); + + /** + * Now press Right again and we should be navigated to the start of the next block + */ + cy.get('@firstBlock') + .type('{rightArrow}'); + + /** + * Delimiter is not selected anymore + */ + cy.get('@delimiterBlock') + .should('not.have.class', 'ce-block--selected'); + + /** + * Caret is set to the start of the next block + */ + cy.window() + .then((window) => { + const selection = window.getSelection(); + const range = selection.getRangeAt(0); + + cy.get('[data-cy=editorjs]') + .find('.ce-paragraph') + .last() + .should(($block) => { + expect($block[0].contains(range.startContainer)).to.be.true; + expect(range.startOffset).to.eq(0); + }); + }); + }); +}); diff --git a/test/cypress/tests/modules/BlockEvents/Backspace.cy.ts b/test/cypress/tests/modules/BlockEvents/Backspace.cy.ts index cbf51ab40..ad39440e1 100644 --- a/test/cypress/tests/modules/BlockEvents/Backspace.cy.ts +++ b/test/cypress/tests/modules/BlockEvents/Backspace.cy.ts @@ -1,28 +1,125 @@ import type EditorJS from '../../../../../types/index'; -import Chainable = Cypress.Chainable; import { SimpleHeader } from '../../../fixtures/tools/SimpleHeader'; import type { ConversionConfig } from '../../../../../types/index'; +import { createEditorWithTextBlocks } from '../../../support/utils/createEditorWithTextBlocks'; +describe('Backspace keydown', function () { + describe('starting whitespaces handling', function () { + it(' | — should delete visible space', function () { + createEditorWithTextBlocks([ + '1', + ' 2', + ]); + + cy.get('[data-cy=editorjs]') + .find('.ce-paragraph') + .last() + .click() + .type('{leftArrow}') // set caret before "2" + .type('{backspace}'); + + cy.get('[data-cy=editorjs]') + .find('div.ce-block') + .last() + .should('have.text', '2'); + }); + it(' | — should ignore invisible space before caret and handle it like regular backspace case (merge with previous)', function () { + createEditorWithTextBlocks([ + '1', + ' 2', + ]); + + cy.get('[data-cy=editorjs]') + .find('.ce-paragraph') + .last() + .click() + .type('{leftArrow}') // set caret before "2" + .type('{backspace}'); + + cy.get('[data-cy=editorjs]') + .find('div.ce-block') + .last() + .should('have.text', '12'); + }); + it('| — should ignore empty tags before caret and handle it like regular backspace case (merge with previous)', function () { + createEditorWithTextBlocks([ + '1', + '2', + ]); + + cy.get('[data-cy=editorjs]') + .find('.ce-paragraph') + .last() + .click() + .type('{leftArrow}') // set caret before "2" + .type('{backspace}'); + + cy.get('[data-cy=editorjs]') + .find('div.ce-block') + .last() + .should('have.text', '12'); + }); + it(' | — should remove visible space and ignore empty tag', function () { + createEditorWithTextBlocks([ + '1', + ' 2', + ]); + + cy.get('[data-cy=editorjs]') + .find('.ce-paragraph') + .last() + .click() + .type('{leftArrow}') // set caret before "2" + .type('{backspace}') // remove nbsp + .type('{backspace}'); // ignore empty tag and merge + + cy.get('[data-cy=editorjs]') + .find('div.ce-block') + .last() + .should('have.text', '12'); + }); + + it(' | — should remove visible space and ignore empty tag', function () { + createEditorWithTextBlocks([ + '1', + ' 2', + ]); + + cy.get('[data-cy=editorjs]') + .find('.ce-paragraph') + .last() + .click() + .type('{leftArrow}') // set caret before "2" + .type('{backspace}') // remove nbsp + .type('{backspace}'); // ignore empty tag and merge + + cy.get('[data-cy=editorjs]') + .find('div.ce-block') + .last() + .should('have.text', '12'); + }); + + it('  | — should remove visible space and ignore space', function () { + createEditorWithTextBlocks([ + '1', + '  2', + ]); + + cy.get('[data-cy=editorjs]') + .find('.ce-paragraph') + .last() + .click() + .type('{leftArrow}') // set caret before "2" + .type('{backspace}') // remove nbsp + .type('{backspace}'); // ignore regular space and merge -/** - * Creates Editor instance with list of Paragraph blocks of passed texts - * - * @param textBlocks - list of texts for Paragraph blocks - */ -function createEditorWithTextBlocks(textBlocks: string[]): Chainable { - return cy.createEditor({ - data: { - blocks: textBlocks.map((text) => ({ - type: 'paragraph', - data: { - text, - }, - })), - }, + cy.get('[data-cy=editorjs]') + .find('div.ce-block') + .last() + .should('have.text', '12'); + }); }); -} -describe('Backspace keydown', function () { it('should just delete chars (native behaviour) when some fragment is selected', function () { createEditorWithTextBlocks([ 'The first block', diff --git a/test/cypress/tests/modules/BlockEvents/Delete.cy.ts b/test/cypress/tests/modules/BlockEvents/Delete.cy.ts index a8983905d..816649c5d 100644 --- a/test/cypress/tests/modules/BlockEvents/Delete.cy.ts +++ b/test/cypress/tests/modules/BlockEvents/Delete.cy.ts @@ -1,24 +1,139 @@ import type EditorJS from '../../../../../types/index'; - -/** - * Creates Editor instance with list of Paragraph blocks of passed texts - * - * @param textBlocks - list of texts for Paragraph blocks - */ -function createEditorWithTextBlocks(textBlocks: string[]): void { - cy.createEditor({ - data: { - blocks: textBlocks.map((text) => ({ - type: 'paragraph', - data: { - text, - }, - })), - }, - }); -} +import { createEditorWithTextBlocks } from '../../../support/utils/createEditorWithTextBlocks'; describe('Delete keydown', function () { + describe('ending whitespaces handling', function () { + it('|  — should delete visible space', function () { + createEditorWithTextBlocks([ + '1 ', + '2', + ]); + + cy.get('[data-cy=editorjs]') + .find('.ce-paragraph') + .first() + .click() + .type('{moveToStart}') + .type('{rightArrow}') // set caret after "1"; + .type('{del}') // delete visible space + .type('{del}') // merge with next block + + .should('have.text', '12'); + }); + it('"| " — should ignore invisible space after caret and handle it like regular delete case (merge with next)', function () { + createEditorWithTextBlocks([ + '1 ', + '2', + ]); + + cy.get('[data-cy=editorjs]') + .find('.ce-paragraph') + .first() + .click() + .type('{moveToStart}') + .type('{rightArrow}') // set caret after "1"; + .type('{del}'); + + cy.get('[data-cy=editorjs]') + .find('div.ce-block') + .last() + .should('have.text', '1 2'); + }); + it('| — should ignore empty tags after caret and handle it like regular delete case (merge)', function () { + createEditorWithTextBlocks([ + '1', + '2', + ]); + + cy.get('[data-cy=editorjs]') + .find('.ce-paragraph') + .first() + .click() + .type('{moveToStart}') + .type('{rightArrow}') // set caret after "1"; + .type('{del}'); + + cy.get('[data-cy=editorjs]') + .find('div.ce-block') + .last() + .should('have.text', '12'); + }); + it('|  — should remove visible space and ignore empty tag', function () { + createEditorWithTextBlocks([ + '1 ', + '2', + ]); + + cy.get('[data-cy=editorjs]') + .find('.ce-paragraph') + .first() + .click() + .type('{moveToStart}') + .type('{rightArrow}') // set caret after "1"; + .type('{del}') // remove nbsp + .type('{del}'); // ignore empty tag and merge + + cy.get('[data-cy=editorjs]') + .find('div.ce-block') + .last() + .should('have.text', '12'); + }); + + it('|  — should remove visible space and ignore empty tag', function () { + createEditorWithTextBlocks([ + '1 ', + '2', + ]); + + cy.get('[data-cy=editorjs]') + .find('.ce-paragraph') + .first() + .click() + .type('{moveToStart}') + .type('{rightArrow}') // set caret after "1"; + .type('{del}') // remove nbsp + .type('{del}'); // ignore empty tag and merge + + cy.get('[data-cy=editorjs]') + .find('div.ce-block') + .last() + .should('have.text', '12'); + }); + + it('"|  " — should remove visible space and ignore space', function () { + createEditorWithTextBlocks([ + '1  ', + '2', + ]); + + cy.get('[data-cy=editorjs]') + .find('.ce-paragraph') + .first() + .click() + .type('{moveToStart}') + .type('{rightArrow}') // set caret after "1"; + .type('{del}') // remove nbsp + .type('{del}'); // ignore regular space and merge + + cy.get('[data-cy=editorjs]') + .find('div.ce-block') + .last() + /** + * In current implementation, we have different behaviour in Firefox: + * - Safari, Chrome merge blocks and without whitespace - "12" + * - Firefox merge blocks and with whitespace - "1 2" + * + * So, we have to check both variants. + * + * @todo remove this check after fixing the Firefox merge behaviour + */ + .should(($block) => { + const text = $block.text(); + + expect(text).to.match(/12|1 2/); + }); + }); + }); it('should just delete chars (native behaviour) when some fragment is selected', function () { createEditorWithTextBlocks([ 'The first block', From 897be419db934e629a3bcd178ee4f0cbed24e937 Mon Sep 17 00:00:00 2001 From: Peter Savchenko Date: Sat, 29 Jun 2024 12:16:35 +0300 Subject: [PATCH 30/61] improve block constructor types (#2749) --- docs/CHANGELOG.md | 1 + types/tools/block-tool.d.ts | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 66a0629f0..b600eb944 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -20,6 +20,7 @@ – `Refactoring` – Switched to Vite as Cypress bundler – `New` – *Menu Config* – Default and HTML items now support hints – `Fix` — Deleting whitespaces at the start/end of the block +– `Improvement` — *Types* — `BlockToolConstructorOptions` type improved, `block` and `config` are not optional anymore ### 2.29.1 diff --git a/types/tools/block-tool.d.ts b/types/tools/block-tool.d.ts index 8c9bb858a..bac653226 100644 --- a/types/tools/block-tool.d.ts +++ b/types/tools/block-tool.d.ts @@ -86,8 +86,8 @@ export interface BlockTool extends BaseTool { export interface BlockToolConstructorOptions { api: API; data: BlockToolData; - config?: ToolConfig; - block?: BlockAPI; + config: ToolConfig; + block: BlockAPI; readOnly: boolean; } From df7fa75c13f31ae2478c08b8be9f59740f8a8dd1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 29 Jun 2024 12:17:59 +0300 Subject: [PATCH 31/61] Bump version (#2708) Co-authored-by: github-actions --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 40ec893d2..357141103 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@editorjs/editorjs", - "version": "2.30.0-rc.11", + "version": "2.30.0-rc.12", "description": "Editor.js — Native JS, based on API and Open Source", "main": "dist/editorjs.umd.js", "module": "dist/editorjs.mjs", From 439658a9123b747ab45a7391dab146194200cb0e Mon Sep 17 00:00:00 2001 From: Tatiana Fomina Date: Sun, 30 Jun 2024 22:27:37 +0300 Subject: [PATCH 32/61] feat(inline-tools): Inline tools rendered as popover (#2718) --- docs/CHANGELOG.md | 6 + example/tools/checklist | 2 +- example/tools/code | 2 +- example/tools/delimiter | 2 +- example/tools/embed | 2 +- example/tools/image | 2 +- example/tools/inline-code | 2 +- example/tools/link | 2 +- example/tools/list | 2 +- example/tools/marker | 2 +- example/tools/nested-list | 2 +- example/tools/quote | 2 +- example/tools/raw | 2 +- example/tools/table | 2 +- example/tools/warning | 2 +- index.html | 2 +- .../block-tunes/block-tune-delete.ts | 4 +- src/components/block/api.ts | 10 +- .../inline-tools/inline-tool-bold.ts | 53 +- .../inline-tools/inline-tool-convert.ts | 113 ++++ src/components/modules/api/blocks.ts | 18 + src/components/modules/api/i18n.ts | 19 +- src/components/modules/api/index.ts | 9 +- src/components/modules/api/selection.ts | 13 +- src/components/modules/api/tools.ts | 16 + src/components/modules/blockEvents.ts | 10 +- src/components/modules/blockSelection.ts | 5 - src/components/modules/dragNDrop.ts | 2 +- src/components/modules/index.ts | 6 +- .../modules/toolbar/blockSettings.ts | 111 +--- src/components/modules/toolbar/conversion.ts | 314 ---------- src/components/modules/toolbar/inline.ts | 535 ++++++------------ src/components/modules/tools.ts | 18 +- src/components/modules/ui.ts | 16 +- src/components/tools/base.ts | 7 +- src/components/tools/block.ts | 2 +- src/components/tools/factory.ts | 9 +- src/components/tools/inline.ts | 2 +- src/components/tools/tune.ts | 2 +- src/components/ui/toolbox.ts | 6 +- src/components/utils/blocks.ts | 78 ++- .../popover/components/hint/hint.const.ts | 3 +- .../utils/popover/components/hint/hint.css | 8 +- .../utils/popover/components/hint/hint.ts | 2 +- .../popover/components/hint/hint.types.ts | 10 + .../popover-item-default.ts | 39 +- .../popover-item-html/popover-item-html.ts | 13 +- .../components/popover-item/popover-item.ts | 107 ++++ .../popover-item/popover-item.types.ts | 183 ++++-- src/components/utils/popover/index.ts | 5 +- .../utils/popover/popover-abstract.ts | 137 ++--- .../utils/popover/popover-desktop.ts | 244 +++++--- .../utils/popover/popover-inline.ts | 186 ++++++ .../utils/popover/popover-mobile.ts | 5 + src/components/utils/popover/popover.const.ts | 32 ++ src/components/utils/popover/popover.types.ts | 16 +- src/components/utils/tools.ts | 18 + src/styles/conversion-toolbar.css | 96 ---- src/styles/export.css | 33 +- src/styles/inline-toolbar.css | 65 ++- src/styles/input.css | 3 +- src/styles/main.css | 3 +- src/styles/popover-inline.css | 143 +++++ src/styles/popover.css | 74 +-- src/styles/settings.css | 33 -- src/styles/variables.css | 48 -- src/tools/paragraph | 1 + src/types-internal/editor-modules.d.ts | 6 +- test/cypress/tests/api/tools.cy.ts | 12 +- test/cypress/tests/block-ids.cy.ts | 2 +- .../cypress/tests/modules/InlineToolbar.cy.ts | 17 +- test/cypress/tests/onchange.cy.ts | 10 +- test/cypress/tests/sanitisation.cy.ts | 2 +- test/cypress/tests/tools/BlockTool.cy.ts | 10 +- test/cypress/tests/tools/BlockTune.cy.ts | 10 +- test/cypress/tests/tools/InlineTool.cy.ts | 10 +- test/cypress/tests/tools/ToolsFactory.cy.ts | 9 +- test/cypress/tests/ui/BlockTunes.cy.ts | 6 +- test/cypress/tests/ui/InlineToolbar.cy.ts | 175 ++++++ test/cypress/tests/utils/popover.cy.ts | 184 +++++- types/api/block.d.ts | 8 +- types/api/blocks.d.ts | 8 + types/api/index.d.ts | 1 + types/api/selection.d.ts | 23 + types/api/tools.d.ts | 11 + types/block-tunes/block-tune.d.ts | 7 +- types/index.d.ts | 4 +- types/tools/block-tool.d.ts | 4 +- types/tools/index.d.ts | 1 + types/tools/inline-tool.d.ts | 10 +- types/tools/menu-config.d.ts | 54 ++ types/tools/tool-settings.d.ts | 60 +- types/tools/tool.d.ts | 15 +- 93 files changed, 2076 insertions(+), 1504 deletions(-) create mode 100644 src/components/inline-tools/inline-tool-convert.ts create mode 100644 src/components/modules/api/tools.ts delete mode 100644 src/components/modules/toolbar/conversion.ts create mode 100644 src/components/utils/popover/popover-inline.ts create mode 100644 src/components/utils/tools.ts delete mode 100644 src/styles/conversion-toolbar.css create mode 100644 src/styles/popover-inline.css delete mode 100644 src/styles/settings.css create mode 160000 src/tools/paragraph create mode 100644 test/cypress/tests/ui/InlineToolbar.cy.ts create mode 100644 types/api/tools.d.ts create mode 100644 types/tools/menu-config.d.ts diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index b600eb944..dfc062f8e 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -19,6 +19,12 @@ - `New` – *Menu Config* – New item type – HTML – `Refactoring` – Switched to Vite as Cypress bundler – `New` – *Menu Config* – Default and HTML items now support hints +- `New` – Inline Toolbar has new look 💅 +- `New` – Inline Tool's `render()` now supports [Menu Config](https://editorjs.io/menu-config/) format +- `New` – *ToolsAPI* – All installed block tools now accessible via ToolsAPI `getBlockTools()` method +- `New` – *SelectionAPI* – Exposed methods `save()` and `restore()` that allow to save selection to be able to temporally move focus away, methods `setFakeBackground()` and `removeFakeBackground()` that allow to immitate selection while focus moved away +- `Impovement` – *MenuConfig* – TunesMenuConfig deprecated, use MenuConfig type instead +- `New` – *BlocksAPI* – Exposed `getBlockByElement()` method that helps find block by any child html element – `Fix` — Deleting whitespaces at the start/end of the block – `Improvement` — *Types* — `BlockToolConstructorOptions` type improved, `block` and `config` are not optional anymore diff --git a/example/tools/checklist b/example/tools/checklist index 1c116d5e0..b1367277e 160000 --- a/example/tools/checklist +++ b/example/tools/checklist @@ -1 +1 @@ -Subproject commit 1c116d5e09e19951948d6166047aa2f30877aaf9 +Subproject commit b1367277e070bbbf80b7b14b1963845ba9a71d8c diff --git a/example/tools/code b/example/tools/code index f281996f8..193f5f6f0 160000 --- a/example/tools/code +++ b/example/tools/code @@ -1 +1 @@ -Subproject commit f281996f82c7ac676172757e45687cae27443427 +Subproject commit 193f5f6f00288679a97bfe620a4d811e5acd9b16 diff --git a/example/tools/delimiter b/example/tools/delimiter index 4ca1c1c97..86e8c5501 160000 --- a/example/tools/delimiter +++ b/example/tools/delimiter @@ -1 +1 @@ -Subproject commit 4ca1c1c972261f47dd34f6b8754763a4a79a4866 +Subproject commit 86e8c5501dcbb8eaaeec756e1145db49b8339160 diff --git a/example/tools/embed b/example/tools/embed index dfdbf2423..23de06be6 160000 --- a/example/tools/embed +++ b/example/tools/embed @@ -1 +1 @@ -Subproject commit dfdbf2423d2777f7026a7df768c6582e1a409db7 +Subproject commit 23de06be69bb9e636a2278b0d54f8c2d85d7ae13 diff --git a/example/tools/image b/example/tools/image index 25d46cd8d..72c651da4 160000 --- a/example/tools/image +++ b/example/tools/image @@ -1 +1 @@ -Subproject commit 25d46cd8d3930851b14ddc26ee80fb5b485e1496 +Subproject commit 72c651da48cf15907de155ce987606e062caaf0a diff --git a/example/tools/inline-code b/example/tools/inline-code index dcd4c1774..7cc94718e 160000 --- a/example/tools/inline-code +++ b/example/tools/inline-code @@ -1 +1 @@ -Subproject commit dcd4c17740c9ba636140751596aff1e9f6ef6b01 +Subproject commit 7cc94718e4c20d6f9db2c236a60b119c39d389e0 diff --git a/example/tools/link b/example/tools/link index aaa69d540..861de29b1 160000 --- a/example/tools/link +++ b/example/tools/link @@ -1 +1 @@ -Subproject commit aaa69d5408bad34027d6252a3892d40f9fa121be +Subproject commit 861de29b1d553bb9377dcbaf451af605b28b57bd diff --git a/example/tools/list b/example/tools/list index a6dc6a692..f0e9f0110 160000 --- a/example/tools/list +++ b/example/tools/list @@ -1 +1 @@ -Subproject commit a6dc6a692b88c9eff3d87223b239e7517b160c67 +Subproject commit f0e9f0110983cd973a1345f2885b18db4fd54636 diff --git a/example/tools/marker b/example/tools/marker index 8d6897fca..13e0b1cf7 160000 --- a/example/tools/marker +++ b/example/tools/marker @@ -1 +1 @@ -Subproject commit 8d6897fca43e387bcdf4a681380be975fe8f2a07 +Subproject commit 13e0b1cf72cfa706dc236e617683a5e349a021f5 diff --git a/example/tools/nested-list b/example/tools/nested-list index 95b37462d..53a12f3d8 160000 --- a/example/tools/nested-list +++ b/example/tools/nested-list @@ -1 +1 @@ -Subproject commit 95b37462dc93c19b83f0481f509034a40d436cf2 +Subproject commit 53a12f3d816630e071ef8230c4a5870a7c0d0551 diff --git a/example/tools/quote b/example/tools/quote index 9377ca713..02e0db32a 160000 --- a/example/tools/quote +++ b/example/tools/quote @@ -1 +1 @@ -Subproject commit 9377ca713f552576b8b11f77cf371b67261ec00b +Subproject commit 02e0db32a101ec5cfa61210de45be7de647c40c6 diff --git a/example/tools/raw b/example/tools/raw index cae470fde..b4164eac4 160000 --- a/example/tools/raw +++ b/example/tools/raw @@ -1 +1 @@ -Subproject commit cae470fded570ef9a82a45734526ccf45959e204 +Subproject commit b4164eac4d81259a15368d7681884e3554554662 diff --git a/example/tools/table b/example/tools/table index 2948cd759..b6290a2c4 160000 --- a/example/tools/table +++ b/example/tools/table @@ -1 +1 @@ -Subproject commit 2948cd7595e632f7555e2dc09e6bac050a2b87ea +Subproject commit b6290a2c4c668476d5b83c454e1bf04487f317cf diff --git a/example/tools/warning b/example/tools/warning index e63e91aa8..e2726a7b3 160000 --- a/example/tools/warning +++ b/example/tools/warning @@ -1 +1 @@ -Subproject commit e63e91aa833d774be9bf4a76013b1025a009989d +Subproject commit e2726a7b301c960d318aa1ec73bac97f474e3d68 diff --git a/index.html b/index.html index 31b8c2b8a..ddbba1f57 100644 --- a/index.html +++ b/index.html @@ -132,7 +132,7 @@ */ header: { class: Header, - inlineToolbar: ['marker', 'link'], + inlineToolbar: ['link', 'marker'], config: { placeholder: 'Header' }, diff --git a/src/components/block-tunes/block-tune-delete.ts b/src/components/block-tunes/block-tune-delete.ts index 904eefae5..4f1258109 100644 --- a/src/components/block-tunes/block-tune-delete.ts +++ b/src/components/block-tunes/block-tune-delete.ts @@ -5,7 +5,7 @@ */ import { API, BlockTune } from '../../../types'; import { IconCross } from '@codexteam/icons'; -import { TunesMenuConfig } from '../../../types/tools'; +import { MenuConfig } from '../../../types/tools/menu-config'; /** * @@ -35,7 +35,7 @@ export default class DeleteTune implements BlockTune { /** * Tune's appearance in block settings menu */ - public render(): TunesMenuConfig { + public render(): MenuConfig { return { icon: IconCross, title: this.api.i18n.t('Delete'), diff --git a/src/components/block/api.ts b/src/components/block/api.ts index 589c6e73f..64a12b00d 100644 --- a/src/components/block/api.ts +++ b/src/components/block/api.ts @@ -1,5 +1,5 @@ import Block from './index'; -import { BlockToolData, ToolConfig } from '../../../types/tools'; +import { BlockToolData, ToolConfig, ToolboxConfigEntry } from '../../../types/tools'; import { SavedData } from '../../../types/data-formats'; import { BlockAPI as BlockAPIInterface } from '../../../types/api'; @@ -128,6 +128,14 @@ function BlockAPI( dispatchChange(): void { block.dispatchChange(); }, + + /** + * Tool could specify several entries to be displayed at the Toolbox (for example, "Heading 1", "Heading 2", "Heading 3") + * This method returns the entry that is related to the Block (depended on the Block data) + */ + getActiveToolboxEntry(): Promise { + return block.getActiveToolboxEntry(); + }, }; Object.setPrototypeOf(this, blockAPI); diff --git a/src/components/inline-tools/inline-tool-bold.ts b/src/components/inline-tools/inline-tool-bold.ts index 9ddef46ce..9959011dd 100644 --- a/src/components/inline-tools/inline-tool-bold.ts +++ b/src/components/inline-tools/inline-tool-bold.ts @@ -1,5 +1,6 @@ import { InlineTool, SanitizerConfig } from '../../../types'; import { IconBold } from '@codexteam/icons'; +import { MenuConfig } from '../../../types/tools'; /** * Bold Tool @@ -38,52 +39,18 @@ export default class BoldInlineTool implements InlineTool { */ private readonly commandName: string = 'bold'; - /** - * Styles - */ - private readonly CSS = { - button: 'ce-inline-tool', - buttonActive: 'ce-inline-tool--active', - buttonModifier: 'ce-inline-tool--bold', - }; - - /** - * Elements - */ - private nodes: {button: HTMLButtonElement} = { - button: undefined, - }; - /** * Create button for Inline Toolbar */ - public render(): HTMLElement { - this.nodes.button = document.createElement('button') as HTMLButtonElement; - this.nodes.button.type = 'button'; - this.nodes.button.classList.add(this.CSS.button, this.CSS.buttonModifier); - this.nodes.button.innerHTML = IconBold; - - return this.nodes.button; - } - - /** - * Wrap range with tag - */ - public surround(): void { - document.execCommand(this.commandName); - } - - /** - * Check selection and set activated state to button if there are tag - * - * @returns {boolean} - */ - public checkState(): boolean { - const isActive = document.queryCommandState(this.commandName); - - this.nodes.button.classList.toggle(this.CSS.buttonActive, isActive); - - return isActive; + public render(): MenuConfig { + return { + icon: IconBold, + name: 'bold', + onActivate: () => { + document.execCommand(this.commandName); + }, + isActive: () => document.queryCommandState(this.commandName), + }; } /** diff --git a/src/components/inline-tools/inline-tool-convert.ts b/src/components/inline-tools/inline-tool-convert.ts new file mode 100644 index 000000000..ccc66b7df --- /dev/null +++ b/src/components/inline-tools/inline-tool-convert.ts @@ -0,0 +1,113 @@ +import { IconReplace } from '@codexteam/icons'; +import { InlineTool, API } from '../../../types'; +import { MenuConfig } from '../../../types/tools'; +import * as _ from '../utils'; +import { Blocks, Selection, Tools, I18n, Caret } from '../../../types/api'; +import SelectionUtils from '../selection'; +import { getConvertibleToolsForBlock } from '../utils/blocks'; + +/** + * Inline tools for converting blocks + */ +export default class ConvertInlineTool implements InlineTool { + /** + * Specifies Tool as Inline Toolbar Tool + */ + public static isInline = true; + + /** + * API for working with editor blocks + */ + private readonly blocksAPI: Blocks; + + /** + * API for working with Selection + */ + private readonly selectionAPI: Selection; + + /** + * API for working with Tools + */ + private readonly toolsAPI: Tools; + + /** + * I18n API + */ + private readonly i18nAPI: I18n; + + /** + * API for working with Caret + */ + private readonly caretAPI: Caret; + + /** + * @param api - Editor.js API + */ + constructor({ api }: { api: API }) { + this.i18nAPI = api.i18n; + this.blocksAPI = api.blocks; + this.selectionAPI = api.selection; + this.toolsAPI = api.tools; + this.caretAPI = api.caret; + } + + /** + * Returns tool's UI config + */ + public async render(): Promise { + const currentSelection = SelectionUtils.get(); + const currentBlock = this.blocksAPI.getBlockByElement(currentSelection.anchorNode as HTMLElement); + const allBlockTools = this.toolsAPI.getBlockTools(); + const convertibleTools = await getConvertibleToolsForBlock(currentBlock, allBlockTools); + + if (convertibleTools.length === 0) { + return []; + } + + const convertToItems = convertibleTools.reduce((result, tool) => { + tool.toolbox.forEach((toolboxItem) => { + result.push({ + icon: toolboxItem.icon, + title: toolboxItem.title, + name: tool.name, + closeOnActivate: true, + onActivate: async () => { + const newBlock = await this.blocksAPI.convert(currentBlock.id, tool.name, toolboxItem.data); + + this.caretAPI.setToBlock(newBlock, 'end'); + }, + }); + }); + + return result; + }, []); + + const currentBlockToolboxItem = await currentBlock.getActiveToolboxEntry(); + const icon = currentBlockToolboxItem !== undefined ? currentBlockToolboxItem.icon : IconReplace; + const isDesktop = !_.isMobileScreen(); + + return { + icon, + name: 'convert-to', + hint: { + title: this.i18nAPI.t('Convert to'), + }, + children: { + searchable: isDesktop, + items: convertToItems, + onOpen: () => { + if (isDesktop) { + this.selectionAPI.setFakeBackground(); + this.selectionAPI.save(); + } + }, + onClose: () => { + if (isDesktop) { + this.selectionAPI.restore(); + this.selectionAPI.removeFakeBackground(); + } + }, + }, + }; + } +} diff --git a/src/components/modules/api/blocks.ts b/src/components/modules/api/blocks.ts index 02f23ff5a..dbb7831a3 100644 --- a/src/components/modules/api/blocks.ts +++ b/src/components/modules/api/blocks.ts @@ -29,6 +29,7 @@ export default class BlocksAPI extends Module { getCurrentBlockIndex: (): number => this.getCurrentBlockIndex(), getBlockIndex: (id: string): number => this.getBlockIndex(id), getBlocksCount: (): number => this.getBlocksCount(), + getBlockByElement: (element: HTMLElement) => this.getBlockByElement(element), stretchBlock: (index: number, status = true): void => this.stretchBlock(index, status), insertNewBlock: (): void => this.insertNewBlock(), insert: this.insert, @@ -108,6 +109,23 @@ export default class BlocksAPI extends Module { return new BlockAPI(block); } + /** + * Get Block API object by any child html element + * + * @param element - html element to get Block by + */ + public getBlockByElement(element: HTMLElement): BlockAPIInterface | undefined { + const block = this.Editor.BlockManager.getBlock(element); + + if (block === undefined) { + _.logLabeled('There is no block corresponding to element `' + element + '`', 'warn'); + + return; + } + + return new BlockAPI(block); + } + /** * Call Block Manager method that swap Blocks * diff --git a/src/components/modules/api/i18n.ts b/src/components/modules/api/i18n.ts index 9ecb9ceb0..6d42314cc 100644 --- a/src/components/modules/api/i18n.ts +++ b/src/components/modules/api/i18n.ts @@ -2,7 +2,6 @@ import { I18n } from '../../../../types/api'; import I18nInternal from '../../i18n'; import { logLabeled } from '../../utils'; import Module from '../../__module'; -import { ToolClass } from '../../tools/collection'; /** * Provides methods for working with i18n @@ -11,14 +10,15 @@ export default class I18nAPI extends Module { /** * Return namespace section for tool or block tune * - * @param tool - tool object + * @param toolName - tool name + * @param isTune - is tool a block tune */ - private static getNamespace(tool: ToolClass): string { - if (tool.isTune()) { - return `blockTunes.${tool.name}`; + private static getNamespace(toolName, isTune): string { + if (isTune) { + return `blockTunes.${toolName}`; } - return `tools.${tool.name}`; + return `tools.${toolName}`; } /** @@ -37,14 +37,15 @@ export default class I18nAPI extends Module { /** * Return I18n API methods with tool namespaced dictionary * - * @param tool - Tool object + * @param toolName - tool name + * @param isTune - is tool a block tune */ - public getMethodsForTool(tool: ToolClass): I18n { + public getMethodsForTool(toolName: string, isTune: boolean): I18n { return Object.assign( this.methods, { t: (dictKey: string): string => { - return I18nInternal.t(I18nAPI.getNamespace(tool), dictKey); + return I18nInternal.t(I18nAPI.getNamespace(toolName, isTune), dictKey); }, }); } diff --git a/src/components/modules/api/index.ts b/src/components/modules/api/index.ts index 0f3562230..e66e605c7 100644 --- a/src/components/modules/api/index.ts +++ b/src/components/modules/api/index.ts @@ -7,7 +7,6 @@ */ import Module from '../../__module'; import { API as APIInterfaces } from '../../../../types'; -import { ToolClass } from '../../tools/collection'; /** * @class API @@ -20,6 +19,7 @@ export default class API extends Module { return { blocks: this.Editor.BlocksAPI.methods, caret: this.Editor.CaretAPI.methods, + tools: this.Editor.ToolsAPI.methods, events: this.Editor.EventsAPI.methods, listeners: this.Editor.ListenersAPI.methods, notifier: this.Editor.NotifierAPI.methods, @@ -39,13 +39,14 @@ export default class API extends Module { /** * Returns Editor.js Core API methods for passed tool * - * @param tool - tool object + * @param toolName - tool name + * @param isTune - is tool a block tune */ - public getMethodsForTool(tool: ToolClass): APIInterfaces { + public getMethodsForTool(toolName: string, isTune: boolean): APIInterfaces { return Object.assign( this.methods, { - i18n: this.Editor.I18nAPI.getMethodsForTool(tool), + i18n: this.Editor.I18nAPI.getMethodsForTool(toolName, isTune), } ) as APIInterfaces; } diff --git a/src/components/modules/api/selection.ts b/src/components/modules/api/selection.ts index a796ddd10..ba3087209 100644 --- a/src/components/modules/api/selection.ts +++ b/src/components/modules/api/selection.ts @@ -7,6 +7,11 @@ import Module from '../../__module'; * Provides with methods working with SelectionUtils */ export default class SelectionAPI extends Module { + /** + * Global SelectionUtils instance + */ + private selectionUtils = new SelectionUtils(); + /** * Available methods * @@ -16,6 +21,10 @@ export default class SelectionAPI extends Module { return { findParentTag: (tagName: string, className?: string): HTMLElement | null => this.findParentTag(tagName, className), expandToTag: (node: HTMLElement): void => this.expandToTag(node), + save: () => this.selectionUtils.save(), + restore: () => this.selectionUtils.restore(), + setFakeBackground: () => this.selectionUtils.setFakeBackground(), + removeFakeBackground: () => this.selectionUtils.removeFakeBackground(), }; } @@ -27,7 +36,7 @@ export default class SelectionAPI extends Module { * @returns {HTMLElement|null} */ public findParentTag(tagName: string, className?: string): HTMLElement | null { - return new SelectionUtils().findParentTag(tagName, className); + return this.selectionUtils.findParentTag(tagName, className); } /** @@ -36,6 +45,6 @@ export default class SelectionAPI extends Module { * @param {HTMLElement} node - tag that should contain selection */ public expandToTag(node: HTMLElement): void { - new SelectionUtils().expandToTag(node); + this.selectionUtils.expandToTag(node); } } diff --git a/src/components/modules/api/tools.ts b/src/components/modules/api/tools.ts new file mode 100644 index 000000000..f120fa590 --- /dev/null +++ b/src/components/modules/api/tools.ts @@ -0,0 +1,16 @@ +import { Tools as ToolsAPIInterface } from '../../../../types/api'; +import Module from '../../__module'; + +/** + * Provides methods for accessing installed Editor tools + */ +export default class ToolsAPI extends Module { + /** + * Available methods + */ + public get methods(): ToolsAPIInterface { + return { + getBlockTools: () => Array.from(this.Editor.Tools.blockTools.values()), + }; + } +} diff --git a/src/components/modules/blockEvents.ts b/src/components/modules/blockEvents.ts index 7228798f8..4c4ebf918 100644 --- a/src/components/modules/blockEvents.ts +++ b/src/components/modules/blockEvents.ts @@ -90,12 +90,10 @@ export default class BlockEvents extends Module { /** * When user type something: * - close Toolbar - * - close Conversion Toolbar * - clear block highlighting */ if (_.isPrintableKey(event.keyCode)) { this.Editor.Toolbar.close(); - this.Editor.ConversionToolbar.close(); /** * Allow to use shortcuts with selected blocks @@ -203,9 +201,9 @@ export default class BlockEvents extends Module { * @param {KeyboardEvent} event - keydown */ private tabPressed(event: KeyboardEvent): void { - const { InlineToolbar, ConversionToolbar, Caret } = this.Editor; + const { InlineToolbar, Caret } = this.Editor; - const isFlipperActivated = ConversionToolbar.opened || InlineToolbar.opened; + const isFlipperActivated = InlineToolbar.opened; if (isFlipperActivated) { return; @@ -653,7 +651,6 @@ export default class BlockEvents extends Module { const toolboxItemSelected = (event.keyCode === _.keyCodes.ENTER && this.Editor.Toolbar.toolbox.opened), blockSettingsItemSelected = (event.keyCode === _.keyCodes.ENTER && this.Editor.BlockSettings.opened), inlineToolbarItemSelected = (event.keyCode === _.keyCodes.ENTER && this.Editor.InlineToolbar.opened), - conversionToolbarItemSelected = (event.keyCode === _.keyCodes.ENTER && this.Editor.ConversionToolbar.opened), flippingToolbarItems = event.keyCode === _.keyCodes.TAB; /** @@ -666,8 +663,7 @@ export default class BlockEvents extends Module { flippingToolbarItems || toolboxItemSelected || blockSettingsItemSelected || - inlineToolbarItemSelected || - conversionToolbarItemSelected + inlineToolbarItemSelected ); } diff --git a/src/components/modules/blockSelection.ts b/src/components/modules/blockSelection.ts index 4e0cd0398..f6844bdcc 100644 --- a/src/components/modules/blockSelection.ts +++ b/src/components/modules/blockSelection.ts @@ -431,11 +431,6 @@ export default class BlockSelection extends Module { */ this.needToSelectAll = false; this.readyToBlockSelection = false; - - /** - * Close ConversionToolbar when all Blocks selected - */ - this.Editor.ConversionToolbar.close(); } else if (this.readyToBlockSelection) { /** * prevent default selection when we use custom selection diff --git a/src/components/modules/dragNDrop.ts b/src/components/modules/dragNDrop.ts index f1b3d3936..3826365c2 100644 --- a/src/components/modules/dragNDrop.ts +++ b/src/components/modules/dragNDrop.ts @@ -69,8 +69,8 @@ export default class DragNDrop extends Module { private async processDrop(dropEvent: DragEvent): Promise { const { BlockManager, - Caret, Paste, + Caret, } = this.Editor; dropEvent.preventDefault(); diff --git a/src/components/modules/index.ts b/src/components/modules/index.ts index 17dd56a45..2c945f43d 100644 --- a/src/components/modules/index.ts +++ b/src/components/modules/index.ts @@ -11,6 +11,7 @@ import ReadOnlyAPI from './api/readonly'; import SanitizerAPI from './api/sanitizer'; import SaverAPI from './api/saver'; import SelectionAPI from './api/selection'; +import ToolsAPI from './api/tools'; import StylesAPI from './api/styles'; import ToolbarAPI from './api/toolbar'; import TooltipAPI from './api/tooltip'; @@ -18,7 +19,6 @@ import UiAPI from './api/ui'; /** ./toolbar */ import BlockSettings from './toolbar/blockSettings'; -import ConversionToolbar from './toolbar/conversion'; import Toolbar from './toolbar/index'; import InlineToolbar from './toolbar/inline'; @@ -52,6 +52,7 @@ export default { SanitizerAPI, SaverAPI, SelectionAPI, + ToolsAPI, StylesAPI, ToolbarAPI, TooltipAPI, @@ -59,7 +60,6 @@ export default { // Toolbar Modules BlockSettings, - ConversionToolbar, Toolbar, InlineToolbar, @@ -78,4 +78,4 @@ export default { Saver, Tools, UI, -}; \ No newline at end of file +}; diff --git a/src/components/modules/toolbar/blockSettings.ts b/src/components/modules/toolbar/blockSettings.ts index c415be531..828850d09 100644 --- a/src/components/modules/toolbar/blockSettings.ts +++ b/src/components/modules/toolbar/blockSettings.ts @@ -5,15 +5,14 @@ import Block from '../../block'; import I18n from '../../i18n'; import { I18nInternalNS } from '../../i18n/namespace-internal'; import Flipper from '../../flipper'; -import { TunesMenuConfigItem } from '../../../../types/tools'; +import { MenuConfigItem } from '../../../../types/tools'; import { resolveAliases } from '../../utils/resolve-aliases'; -import { type Popover, PopoverDesktop, PopoverMobile, PopoverItemParams, PopoverItemDefaultParams, PopoverItemType } from '../../utils/popover'; +import { type Popover, PopoverDesktop, PopoverMobile, PopoverItemParams, PopoverItemType } from '../../utils/popover'; import { PopoverEvent } from '../../utils/popover/popover.types'; import { isMobileScreen } from '../../utils'; import { EditorMobileLayoutToggled } from '../../events'; -import * as _ from '../../utils'; import { IconReplace } from '@codexteam/icons'; -import { isSameBlockData } from '../../utils/blocks'; +import { getConvertibleToolsForBlock } from '../../utils/blocks'; /** * HTML Elements that used for BlockSettings @@ -141,7 +140,7 @@ export default class BlockSettings extends Module { }, }); - this.popover.on(PopoverEvent.Close, this.onPopoverClose); + this.popover.on(PopoverEvent.Closed, this.onPopoverClose); this.nodes.wrapper?.append(this.popover.getElement()); @@ -189,7 +188,7 @@ export default class BlockSettings extends Module { this.eventsDispatcher.emit(this.events.closed); if (this.popover) { - this.popover.off(PopoverEvent.Close, this.onPopoverClose); + this.popover.off(PopoverEvent.Closed, this.onPopoverClose); this.popover.destroy(); this.popover.getElement().remove(); this.popover = null; @@ -204,8 +203,8 @@ export default class BlockSettings extends Module { * @param commonTunes – common tunes * @param toolTunes - tool specific tunes */ - private async getTunesItems(currentBlock: Block, commonTunes: TunesMenuConfigItem[], toolTunes?: TunesMenuConfigItem[]): Promise { - const items = [] as TunesMenuConfigItem[]; + private async getTunesItems(currentBlock: Block, commonTunes: MenuConfigItem[], toolTunes?: MenuConfigItem[]): Promise { + const items = [] as MenuConfigItem[]; if (toolTunes !== undefined && toolTunes.length > 0) { items.push(...toolTunes); @@ -214,13 +213,34 @@ export default class BlockSettings extends Module { }); } - const convertToItems = await this.getConvertToItems(currentBlock); + const allBlockTools = Array.from(this.Editor.Tools.blockTools.values()); + const convertibleTools = await getConvertibleToolsForBlock(currentBlock, allBlockTools); + const convertToItems = convertibleTools.reduce((result, tool) => { + tool.toolbox.forEach((toolboxItem) => { + result.push({ + icon: toolboxItem.icon, + title: toolboxItem.title, + name: tool.name, + closeOnActivate: true, + onActivate: async () => { + const { BlockManager, Caret } = this.Editor; + + const newBlock = await BlockManager.convert(currentBlock, tool.name, toolboxItem.data); + + Caret.setToBlock(newBlock, Caret.positions.END); + }, + }); + }); + + return result; + }, []); if (convertToItems.length > 0) { items.push({ icon: IconReplace, title: I18n.ui(I18nInternalNS.ui.popover, 'Convert to'), children: { + searchable: true, items: convertToItems, }, }); @@ -234,77 +254,6 @@ export default class BlockSettings extends Module { return items.map(tune => this.resolveTuneAliases(tune)); } - /** - * Returns list of all available conversion menu items - * - * @param currentBlock - block we are about to open block tunes for - */ - private async getConvertToItems(currentBlock: Block): Promise { - const conversionEntries = Array.from(this.Editor.Tools.blockTools.entries()); - - const resultItems: PopoverItemDefaultParams[] = []; - - const blockData = await currentBlock.data; - - conversionEntries.forEach(([toolName, tool]) => { - const conversionConfig = tool.conversionConfig; - - /** - * Skip tools without «import» rule specified - */ - if (!conversionConfig || !conversionConfig.import) { - return; - } - - tool.toolbox?.forEach((toolboxItem) => { - /** - * Skip tools that don't pass 'toolbox' property - */ - if (_.isEmpty(toolboxItem) || !toolboxItem.icon) { - return; - } - - let shouldSkip = false; - - if (toolboxItem.data !== undefined) { - /** - * When a tool has several toolbox entries, we need to make sure we do not add - * toolbox item with the same data to the resulting array. This helps exclude duplicates - */ - const hasSameData = isSameBlockData(toolboxItem.data, blockData); - - shouldSkip = hasSameData; - } else { - shouldSkip = toolName === currentBlock.name; - } - - - if (shouldSkip) { - return; - } - - resultItems.push({ - icon: toolboxItem.icon, - title: toolboxItem.title, - name: toolName, - onActivate: async () => { - const { BlockManager, BlockSelection, Caret } = this.Editor; - - const newBlock = await BlockManager.convert(this.Editor.BlockManager.currentBlock, toolName, toolboxItem.data); - - BlockSelection.clearSelection(); - - this.close(); - - Caret.setToBlock(newBlock, Caret.positions.END); - }, - }); - }); - }); - - return resultItems; - } - /** * Handles popover close event */ @@ -317,7 +266,7 @@ export default class BlockSettings extends Module { * * @param item - item with resolved aliases */ - private resolveTuneAliases(item: TunesMenuConfigItem): PopoverItemParams { + private resolveTuneAliases(item: MenuConfigItem): PopoverItemParams { if (item.type === PopoverItemType.Separator || item.type === PopoverItemType.Html) { return item; } diff --git a/src/components/modules/toolbar/conversion.ts b/src/components/modules/toolbar/conversion.ts deleted file mode 100644 index 4d7206d8e..000000000 --- a/src/components/modules/toolbar/conversion.ts +++ /dev/null @@ -1,314 +0,0 @@ -import Module from '../../__module'; -import $ from '../../dom'; -import * as _ from '../../utils'; -import Flipper from '../../flipper'; -import I18n from '../../i18n'; -import { I18nInternalNS } from '../../i18n/namespace-internal'; -import { ToolboxConfigEntry, BlockToolData } from '../../../../types'; - -/** - * HTML Elements used for ConversionToolbar - */ -interface ConversionToolbarNodes { - wrapper: HTMLElement; - tools: HTMLElement; -} - -/** - * Block Converter - * - * @todo Make the Conversion Toolbar no-module but a standalone class, like Toolbox - */ -export default class ConversionToolbar extends Module { - /** - * CSS getter - */ - public static get CSS(): { [key: string]: string } { - return { - conversionToolbarWrapper: 'ce-conversion-toolbar', - conversionToolbarShowed: 'ce-conversion-toolbar--showed', - conversionToolbarTools: 'ce-conversion-toolbar__tools', - conversionToolbarLabel: 'ce-conversion-toolbar__label', - conversionTool: 'ce-conversion-tool', - conversionToolHidden: 'ce-conversion-tool--hidden', - conversionToolIcon: 'ce-conversion-tool__icon', - conversionToolSecondaryLabel: 'ce-conversion-tool__secondary-label', - - conversionToolFocused: 'ce-conversion-tool--focused', - conversionToolActive: 'ce-conversion-tool--active', - }; - } - - /** - * Conversion Toolbar open/close state - * - * @type {boolean} - */ - public opened = false; - - /** - * Available tools data - */ - private tools: {name: string; toolboxItem: ToolboxConfigEntry; button: HTMLElement}[] = []; - - /** - * Instance of class that responses for leafing buttons by arrows/tab - * - * @type {Flipper|null} - */ - private flipper: Flipper = null; - - /** - * Callback that fill be fired on open/close and accepts an opening state - */ - private togglingCallback = null; - - /** - * Create UI of Conversion Toolbar - */ - public make(): HTMLElement { - this.nodes.wrapper = $.make('div', [ - ConversionToolbar.CSS.conversionToolbarWrapper, - ...(this.isRtl ? [ this.Editor.UI.CSS.editorRtlFix ] : []), - ]); - this.nodes.tools = $.make('div', ConversionToolbar.CSS.conversionToolbarTools); - - const label = $.make('div', ConversionToolbar.CSS.conversionToolbarLabel, { - textContent: I18n.ui(I18nInternalNS.ui.inlineToolbar.converter, 'Convert to'), - }); - - /** - * Add Tools that has 'import' method - */ - this.addTools(); - - /** - * Prepare Flipper to be able to leaf tools by arrows/tab - */ - this.enableFlipper(); - - $.append(this.nodes.wrapper, label); - $.append(this.nodes.wrapper, this.nodes.tools); - - return this.nodes.wrapper; - } - - /** - * Deactivates flipper and removes all nodes - */ - public destroy(): void { - /** - * Sometimes (in read-only mode) there is no Flipper - */ - if (this.flipper) { - this.flipper.deactivate(); - this.flipper = null; - } - - this.removeAllNodes(); - } - - /** - * Toggle conversion dropdown visibility - * - * @param {Function} [togglingCallback] — callback that will accept opening state - */ - public toggle(togglingCallback?: (openedState: boolean) => void): void { - if (!this.opened) { - this.open(); - } else { - this.close(); - } - - if (_.isFunction(togglingCallback)) { - this.togglingCallback = togglingCallback; - } - } - - /** - * Shows Conversion Toolbar - */ - public open(): void { - this.filterTools(); - - this.opened = true; - this.nodes.wrapper.classList.add(ConversionToolbar.CSS.conversionToolbarShowed); - - /** - * We use RAF to prevent bubbling Enter keydown on first dropdown item - * Conversion flipper will be activated after dropdown will open - */ - window.requestAnimationFrame(() => { - this.flipper.activate(this.tools.map(tool => tool.button).filter((button) => { - return !button.classList.contains(ConversionToolbar.CSS.conversionToolHidden); - })); - this.flipper.focusFirst(); - if (_.isFunction(this.togglingCallback)) { - this.togglingCallback(true); - } - }); - } - - /** - * Closes Conversion Toolbar - */ - public close(): void { - this.opened = false; - this.flipper.deactivate(); - this.nodes.wrapper.classList.remove(ConversionToolbar.CSS.conversionToolbarShowed); - - if (_.isFunction(this.togglingCallback)) { - this.togglingCallback(false); - } - } - - /** - * Returns true if it has more than one tool available for convert in - */ - public hasTools(): boolean { - if (this.tools.length === 1) { - return this.tools[0].name !== this.config.defaultBlock; - } - - return true; - } - - /** - * Replaces one Block with another - * For that Tools must provide import/export methods - * - * @param {string} replacingToolName - name of Tool which replaces current - * @param blockDataOverrides - If this conversion fired by the one of multiple Toolbox items, extend converted data with this item's "data" overrides - */ - public async replaceWithBlock(replacingToolName: string, blockDataOverrides?: BlockToolData): Promise { - const { BlockManager, BlockSelection, InlineToolbar, Caret } = this.Editor; - - const newBlock = await BlockManager.convert(this.Editor.BlockManager.currentBlock, replacingToolName, blockDataOverrides); - - BlockSelection.clearSelection(); - - this.close(); - InlineToolbar.close(); - - Caret.setToBlock(newBlock, Caret.positions.END); - } - - /** - * Iterates existing Tools and inserts to the ConversionToolbar - * if tools have ability to import - */ - private addTools(): void { - const tools = this.Editor.Tools.blockTools; - - Array - .from(tools.entries()) - .forEach(([name, tool]) => { - const conversionConfig = tool.conversionConfig; - - /** - * Skip tools without «import» rule specified - */ - if (!conversionConfig || !conversionConfig.import) { - return; - } - tool.toolbox?.forEach((toolboxItem) => - this.addToolIfValid(name, toolboxItem) - ); - }); - } - - /** - * Inserts a tool to the ConversionToolbar if the tool's toolbox config is valid - * - * @param name - tool's name - * @param toolboxSettings - tool's single toolbox setting - */ - private addToolIfValid(name: string, toolboxSettings: ToolboxConfigEntry): void { - /** - * Skip tools that don't pass 'toolbox' property - */ - if (_.isEmpty(toolboxSettings) || !toolboxSettings.icon) { - return; - } - - this.addTool(name, toolboxSettings); - } - - /** - * Add tool to the Conversion Toolbar - * - * @param toolName - name of Tool to add - * @param toolboxItem - tool's toolbox item data - */ - private addTool(toolName: string, toolboxItem: ToolboxConfigEntry): void { - const tool = $.make('div', [ ConversionToolbar.CSS.conversionTool ]); - const icon = $.make('div', [ ConversionToolbar.CSS.conversionToolIcon ]); - - tool.dataset.tool = toolName; - icon.innerHTML = toolboxItem.icon; - - $.append(tool, icon); - $.append(tool, $.text(I18n.t(I18nInternalNS.toolNames, toolboxItem.title || _.capitalize(toolName)))); - - const shortcut = this.Editor.Tools.blockTools.get(toolName)?.shortcut; - - if (shortcut) { - const shortcutEl = $.make('span', ConversionToolbar.CSS.conversionToolSecondaryLabel, { - innerText: _.beautifyShortcut(shortcut), - }); - - $.append(tool, shortcutEl); - } - - $.append(this.nodes.tools, tool); - this.tools.push({ - name: toolName, - button: tool, - toolboxItem: toolboxItem, - }); - - this.listeners.on(tool, 'click', async () => { - await this.replaceWithBlock(toolName, toolboxItem.data); - }); - } - - /** - * Hide current Tool and show others - */ - private async filterTools(): Promise { - const { currentBlock } = this.Editor.BlockManager; - const currentBlockActiveToolboxEntry = await currentBlock.getActiveToolboxEntry(); - - /** - * Compares two Toolbox entries - * - * @param entry1 - entry to compare - * @param entry2 - entry to compare with - */ - function isTheSameToolboxEntry(entry1, entry2): boolean { - return entry1.icon === entry2.icon && entry1.title === entry2.title; - } - - this.tools.forEach(tool => { - let hidden = false; - - if (currentBlockActiveToolboxEntry) { - const isToolboxItemActive = isTheSameToolboxEntry(currentBlockActiveToolboxEntry, tool.toolboxItem); - - hidden = (tool.button.dataset.tool === currentBlock.name && isToolboxItemActive); - } - - tool.button.hidden = hidden; - tool.button.classList.toggle(ConversionToolbar.CSS.conversionToolHidden, hidden); - }); - } - - /** - * Prepare Flipper to be able to leaf tools by arrows/tab - */ - private enableFlipper(): void { - this.flipper = new Flipper({ - focusedItemClass: ConversionToolbar.CSS.conversionToolFocused, - }); - } -} diff --git a/src/components/modules/toolbar/inline.ts b/src/components/modules/toolbar/inline.ts index 006cf66fa..3192c7f04 100644 --- a/src/components/modules/toolbar/inline.ts +++ b/src/components/modules/toolbar/inline.ts @@ -1,32 +1,22 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ import Module from '../../__module'; import $ from '../../dom'; import SelectionUtils from '../../selection'; import * as _ from '../../utils'; import { InlineTool as IInlineTool } from '../../../../types'; -import Flipper from '../../flipper'; import I18n from '../../i18n'; import { I18nInternalNS } from '../../i18n/namespace-internal'; import Shortcuts from '../../utils/shortcuts'; -import * as tooltip from '../../utils/tooltip'; import { ModuleConfig } from '../../../types-internal/module-config'; -import InlineTool from '../../tools/inline'; import { CommonInternalSettings } from '../../tools/base'; -import { IconChevronDown } from '@codexteam/icons'; +import { Popover, PopoverItemHtmlParams, PopoverItemParams, PopoverItemType, WithChildren } from '../../utils/popover'; +import { PopoverInline } from '../../utils/popover/popover-inline'; /** * Inline Toolbar elements */ interface InlineToolbarNodes { wrapper: HTMLElement | undefined; - togglerAndButtonsWrapper: HTMLElement | undefined; - buttons: HTMLElement | undefined; - conversionToggler: HTMLElement | undefined; - conversionTogglerContent: HTMLElement | undefined; - /** - * Zone below the buttons where Tools can create additional actions by 'renderActions()' method - * For example, input for the 'link' tool or textarea for the 'comment' tool - */ - actions: HTMLElement | undefined; } /** @@ -42,29 +32,18 @@ export default class InlineToolbar extends Module { */ public CSS = { inlineToolbar: 'ce-inline-toolbar', - inlineToolbarShowed: 'ce-inline-toolbar--showed', - inlineToolbarLeftOriented: 'ce-inline-toolbar--left-oriented', - inlineToolbarRightOriented: 'ce-inline-toolbar--right-oriented', - inlineToolbarShortcut: 'ce-inline-toolbar__shortcut', - buttonsWrapper: 'ce-inline-toolbar__buttons', - actionsWrapper: 'ce-inline-toolbar__actions', - inlineToolButton: 'ce-inline-tool', - inputField: 'cdx-input', - focusedButton: 'ce-inline-tool--focused', - conversionToggler: 'ce-inline-toolbar__dropdown', - conversionTogglerArrow: 'ce-inline-toolbar__dropdown-arrow', - conversionTogglerHidden: 'ce-inline-toolbar__dropdown--hidden', - conversionTogglerContent: 'ce-inline-toolbar__dropdown-content', - togglerAndButtonsWrapper: 'ce-inline-toolbar__toggler-and-button-wrapper', }; /** * State of inline toolbar - * - * @type {boolean} */ public opened = false; + /** + * Popover instance reference + */ + private popover: Popover | null = null; + /** * Margin above/below the Toolbar */ @@ -72,33 +51,11 @@ export default class InlineToolbar extends Module { private readonly toolbarVerticalMargin: number = _.isMobileScreen() ? 20 : 6; /** - * TODO: Get rid of this - * * Currently visible tools instances */ - private toolsInstances: Map; + private toolsInstances: Map = new Map(); /** - * Buttons List - * - * @type {NodeList} - */ - private buttonsList: NodeList = null; - - /** - * Cache for Inline Toolbar width - * - * @type {number} - */ - private width = 0; - - /** - * Instance of class that responses for leafing buttons by arrows/tab - */ - private flipper: Flipper = null; - - /** - * @class * @param moduleConfiguration - Module Configuration * @param moduleConfiguration.config - Editor's config * @param moduleConfiguration.eventsDispatcher - Editor's event dispatcher @@ -122,7 +79,6 @@ export default class InlineToolbar extends Module { }, { timeout: 2000 }); } else { this.destroy(); - this.Editor.ConversionToolbar.destroy(); } } @@ -136,9 +92,8 @@ export default class InlineToolbar extends Module { * * @param [needToClose] - pass true to close toolbar if it is not allowed. * Avoid to use it just for closing IT, better call .close() clearly. - * @param [needToShowConversionToolbar] - pass false to not to show Conversion Toolbar */ - public async tryToShow(needToClose = false, needToShowConversionToolbar = true): Promise { + public async tryToShow(needToClose = false): Promise { if (needToClose) { this.close(); } @@ -147,9 +102,8 @@ export default class InlineToolbar extends Module { return; } - await this.addToolsFiltered(needToShowConversionToolbar); - this.move(); - this.open(needToShowConversionToolbar); + await this.open(); + this.Editor.Toolbar.close(); } @@ -165,7 +119,6 @@ export default class InlineToolbar extends Module { return; } - this.nodes.wrapper.classList.remove(this.CSS.inlineToolbarShowed); Array.from(this.toolsInstances.entries()).forEach(([name, toolInstance]) => { const shortcut = this.getToolShortcut(name); @@ -181,11 +134,14 @@ export default class InlineToolbar extends Module { } }); + this.toolsInstances = null; + this.reset(); this.opened = false; - this.flipper.deactivate(); - this.Editor.ConversionToolbar.close(); + this.popover?.hide(); + this.popover?.destroy(); + this.popover = null; } /** @@ -205,15 +161,9 @@ export default class InlineToolbar extends Module { * Removes UI and its components */ public destroy(): void { - /** - * Sometimes (in read-only mode) there is no Flipper - */ - if (this.flipper) { - this.flipper.deactivate(); - this.flipper = null; - } - this.removeAllNodes(); + this.popover?.destroy(); + this.popover = null; } /** @@ -229,69 +179,16 @@ export default class InlineToolbar extends Module { this.nodes.wrapper.setAttribute('data-cy', 'inline-toolbar'); } - /** - * Creates a different wrapper for toggler and buttons. - */ - this.nodes.togglerAndButtonsWrapper = $.make('div', this.CSS.togglerAndButtonsWrapper); - this.nodes.buttons = $.make('div', this.CSS.buttonsWrapper); - this.nodes.actions = $.make('div', this.CSS.actionsWrapper); - - // To prevent reset of a selection when click on the wrapper - this.listeners.on(this.nodes.wrapper, 'mousedown', (event) => { - const isClickedOnActionsWrapper = (event.target as Element).closest(`.${this.CSS.actionsWrapper}`); - - // If click is on actions wrapper, - // do not prevent default behavior because actions might include interactive elements - if (!isClickedOnActionsWrapper) { - event.preventDefault(); - } - }); - - /** - * Append the intermediary wrapper which contains toggler and buttons and button actions. - */ - $.append(this.nodes.wrapper, [this.nodes.togglerAndButtonsWrapper, this.nodes.actions]); /** * Append the inline toolbar to the editor. */ $.append(this.Editor.UI.nodes.wrapper, this.nodes.wrapper); - - /** - * Add button that will allow switching block type - */ - this.addConversionToggler(); - - /** - * Wrapper for the inline tools - * Will be appended after the Conversion Toolbar toggler - */ - $.append(this.nodes.togglerAndButtonsWrapper, this.nodes.buttons); - - /** - * Prepare conversion toolbar. - * If it has any conversion tool then it will be enabled in the Inline Toolbar - */ - this.prepareConversionToolbar(); - - /** - * Recalculate initial width with all buttons - * We use RIC to prevent forced layout during editor initialization to make it faster - */ - window.requestAnimationFrame(() => { - this.recalculateWidth(); - }); - - /** - * Allow to leaf buttons by arrows / tab - * Buttons will be filled on opening - */ - this.enableFlipper(); } /** * Shows Inline Toolbar */ - private open(): void { + private async open(): Promise { if (this.opened) { return; } @@ -299,26 +196,37 @@ export default class InlineToolbar extends Module { /** * Show Inline Toolbar */ - this.nodes.wrapper.classList.add(this.CSS.inlineToolbarShowed); - this.buttonsList = this.nodes.buttons.querySelectorAll(`.${this.CSS.inlineToolButton}`); this.opened = true; - /** - * Get currently visible buttons to pass it to the Flipper - */ - let visibleTools = Array.from(this.buttonsList); + if (this.popover !== null) { + this.popover.destroy(); + } + + const inlineTools = await this.getInlineTools(); + + this.popover = new PopoverInline({ + items: inlineTools, + scopeElement: this.Editor.API.methods.ui.nodes.redactor, + messages: { + nothingFound: I18n.ui(I18nInternalNS.ui.popover, 'Nothing found'), + search: I18n.ui(I18nInternalNS.ui.popover, 'Filter'), + }, + }); - visibleTools.unshift(this.nodes.conversionToggler); - visibleTools = visibleTools.filter((tool) => !(tool as HTMLElement).hidden); + this.move(this.popover.size.width); - this.flipper.activate(visibleTools as HTMLElement[]); + this.nodes.wrapper?.append(this.popover.getElement()); + + this.popover.show(); } /** * Move Toolbar to the selected text + * + * @param popoverWidth - width of the toolbar popover */ - private move(): void { + private move(popoverWidth: number): void { const selectionRect = SelectionUtils.rect as DOMRect; const wrapperOffset = this.Editor.UI.nodes.wrapper.getBoundingClientRect(); const newCoords = { @@ -330,30 +238,25 @@ export default class InlineToolbar extends Module { this.toolbarVerticalMargin, }; - const realRightCoord = newCoords.x + this.width + wrapperOffset.x; + const realRightCoord = newCoords.x + popoverWidth + wrapperOffset.x; /** * Prevent InlineToolbar from overflowing the content zone on the right side */ if (realRightCoord > this.Editor.UI.contentRect.right) { - newCoords.x = this.Editor.UI.contentRect.right - this.width - wrapperOffset.x; + newCoords.x = this.Editor.UI.contentRect.right -popoverWidth - wrapperOffset.x; } - this.nodes.wrapper.style.left = Math.floor(newCoords.x) + 'px'; - this.nodes.wrapper.style.top = Math.floor(newCoords.y) + 'px'; + this.nodes.wrapper!.style.left = Math.floor(newCoords.x) + 'px'; + this.nodes.wrapper!.style.top = Math.floor(newCoords.y) + 'px'; } /** * Clear orientation classes and reset position */ private reset(): void { - this.nodes.wrapper.classList.remove( - this.CSS.inlineToolbarLeftOriented, - this.CSS.inlineToolbarRightOriented - ); - - this.nodes.wrapper.style.left = '0'; - this.nodes.wrapper.style.top = '0'; + this.nodes.wrapper!.style.left = '0'; + this.nodes.wrapper!.style.top = '0'; } /** @@ -382,6 +285,10 @@ export default class InlineToolbar extends Module { ? currentSelection.anchorNode.parentElement : currentSelection.anchorNode; + if (target === null) { + return false; + } + if (currentSelection && tagsConflictsWithSelection.includes(target.tagName)) { return false; } @@ -403,204 +310,142 @@ export default class InlineToolbar extends Module { return currentBlock.tool.inlineTools.size !== 0; } - /** - * Recalculate inline toolbar width - */ - private recalculateWidth(): void { - this.width = this.nodes.wrapper.offsetWidth; - } - - /** - * Create a toggler for Conversion Dropdown - * and prepend it to the buttons list - */ - private addConversionToggler(): void { - this.nodes.conversionToggler = $.make('div', this.CSS.conversionToggler); - this.nodes.conversionTogglerContent = $.make('div', this.CSS.conversionTogglerContent); - - const iconWrapper = $.make('div', this.CSS.conversionTogglerArrow, { - innerHTML: IconChevronDown, - }); - - this.nodes.conversionToggler.appendChild(this.nodes.conversionTogglerContent); - this.nodes.conversionToggler.appendChild(iconWrapper); - - this.nodes.togglerAndButtonsWrapper.appendChild(this.nodes.conversionToggler); - - if (import.meta.env.MODE === 'test') { - this.nodes.conversionToggler.setAttribute('data-cy', 'conversion-toggler'); - } - - this.listeners.on(this.nodes.conversionToggler, 'click', () => { - this.Editor.ConversionToolbar.toggle((conversionToolbarOpened) => { - /** - * When ConversionToolbar is opening on activated InlineToolbar flipper - * Then we need to temporarily deactivate InlineToolbar flipper so that we could flip ConversionToolbar items - * - * Other case when ConversionToolbar is closing (for example, by escape) but we need to continue flipping - * InlineToolbar items, we activate InlineToolbar flipper - */ - const canActivateInlineToolbarFlipper = !conversionToolbarOpened && this.opened; - - if (canActivateInlineToolbarFlipper) { - this.flipper.activate(); - } else if (this.opened) { - this.flipper.deactivate(); - } - }); - }); - - if (_.isMobileScreen() === false ) { - tooltip.onHover(this.nodes.conversionToggler, I18n.ui(I18nInternalNS.ui.inlineToolbar.converter, 'Convert to'), { - placement: 'top', - hidingDelay: 100, - }); - } - } - - /** - * Changes Conversion Dropdown content for current block's Tool - */ - private async setConversionTogglerContent(): Promise { - const { BlockManager } = this.Editor; - const { currentBlock } = BlockManager; - const toolName = currentBlock.name; - - /** - * If tool does not provide 'export' rule, hide conversion dropdown - */ - const conversionConfig = currentBlock.tool.conversionConfig; - const exportRuleDefined = conversionConfig && conversionConfig.export; - - this.nodes.conversionToggler.hidden = !exportRuleDefined; - this.nodes.conversionToggler.classList.toggle(this.CSS.conversionTogglerHidden, !exportRuleDefined); - - /** - * Get icon or title for dropdown - */ - const toolboxSettings = await currentBlock.getActiveToolboxEntry() || {}; - - this.nodes.conversionTogglerContent.innerHTML = - toolboxSettings.icon || - toolboxSettings.title || - _.capitalize(toolName); - } - - /** - * Makes the Conversion Dropdown - */ - private prepareConversionToolbar(): void { - const ct = this.Editor.ConversionToolbar.make(); - - $.append(this.nodes.wrapper, ct); - } - /** * Working with Tools * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ */ /** - * Append only allowed Tools - * - * @param {boolean} needToShowConversionToolbar - pass false to not to show Conversion Toolbar (e.g. for Footnotes-like tools) + * Returns Inline Tools segregated by their appearance type: popover items and custom html elements. + * Sets this.toolsInstances map */ - private async addToolsFiltered(needToShowConversionToolbar = true): Promise { + private async getInlineTools(): Promise { const currentSelection = SelectionUtils.get(); const currentBlock = this.Editor.BlockManager.getBlock(currentSelection.anchorNode as HTMLElement); - /** - * Clear buttons list - */ - this.nodes.buttons.innerHTML = ''; - this.nodes.actions.innerHTML = ''; - this.toolsInstances = new Map(); + const inlineTools = Array.from(currentBlock.tool.inlineTools.values()); - Array.from(currentBlock.tool.inlineTools.values()).forEach(tool => { - this.addTool(tool); - }); + const popoverItems = [] as PopoverItemParams[]; - if (needToShowConversionToolbar && this.Editor.ConversionToolbar.hasTools()) { - /** - * Change Conversion Dropdown content for current tool - */ - await this.setConversionTogglerContent(); - } else { - /** - * hide Conversion Dropdown with there are no tools - */ - this.nodes.conversionToggler.hidden = true; + if (this.toolsInstances === null) { + this.toolsInstances = new Map(); } - /** - * Recalculate width because some buttons can be hidden - */ - this.recalculateWidth(); - } - - /** - * Add tool button and activate clicks - * - * @param {InlineTool} tool - InlineTool object - */ - private addTool(tool: InlineTool): void { - const instance = tool.create(); - const button = instance.render(); - - if (!button) { - _.log('Render method must return an instance of Node', 'warn', tool.name); + for (let i = 0; i < inlineTools.length; i++) { + const tool = inlineTools[i]; + const instance = tool.create(); + const renderedTool = await instance.render(); - return; - } + this.toolsInstances.set(tool.name, instance); - button.dataset.tool = tool.name; - this.nodes.buttons.appendChild(button); - this.toolsInstances.set(tool.name, instance); + /** Enable tool shortcut */ + const shortcut = this.getToolShortcut(tool.name); - if (_.isFunction(instance.renderActions)) { - const actions = instance.renderActions(); - - this.nodes.actions.appendChild(actions); - } - - this.listeners.on(button, 'click', (event) => { - this.toolClicked(instance); - event.preventDefault(); - }); - - const shortcut = this.getToolShortcut(tool.name); - - if (shortcut) { - try { - this.enableShortcuts(instance, shortcut); - } catch (e) {} - } - - /** - * Enable tooltip module on button - */ - const tooltipContent = $.make('div'); - const toolTitle = I18n.t( - I18nInternalNS.toolNames, - tool.title || _.capitalize(tool.name) - ); - - tooltipContent.appendChild($.text(toolTitle)); - - if (shortcut) { - tooltipContent.appendChild($.make('div', this.CSS.inlineToolbarShortcut, { - textContent: _.beautifyShortcut(shortcut), - })); - } + if (shortcut) { + try { + this.enableShortcuts(tool.name, shortcut); + } catch (e) {} + } - if (_.isMobileScreen() === false ) { - tooltip.onHover(button, tooltipContent, { - placement: 'top', - hidingDelay: 100, + const shortcutBeautified = shortcut !== undefined ? _.beautifyShortcut(shortcut) : undefined; + + const toolTitle = I18n.t( + I18nInternalNS.toolNames, + tool.title || _.capitalize(tool.name) + ); + + [ renderedTool ].flat().forEach((item) => { + const commonPopoverItemParams = { + name: tool.name, + onActivate: () => { + this.toolClicked(instance); + }, + hint: { + title: toolTitle, + description: shortcutBeautified, + }, + } as PopoverItemParams; + + if ($.isElement(item)) { + /** + * Deprecated way to add custom html elements to the Inline Toolbar + */ + + const popoverItem = { + ...commonPopoverItemParams, + element: item, + type: PopoverItemType.Html, + } as PopoverItemParams; + + /** + * If tool specifies actions in deprecated manner, append them as children + */ + if (_.isFunction(instance.renderActions)) { + const actions = instance.renderActions(); + + (popoverItem as WithChildren).children = { + isOpen: instance.checkState(SelectionUtils.get()), + items: [ + { + type: PopoverItemType.Html, + element: actions, + }, + ], + }; + } else { + /** + * Legacy inline tools might perform some UI mutating logic in checkState method, so, call it just in case + */ + instance.checkState(SelectionUtils.get()); + } + + popoverItems.push(popoverItem); + } else if (item.type === PopoverItemType.Html) { + /** + * Actual way to add custom html elements to the Inline Toolbar + */ + popoverItems.push({ + ...commonPopoverItemParams, + ...item, + type: PopoverItemType.Html, + }); + } else if (item.type === PopoverItemType.Separator) { + /** + * Separator item + */ + popoverItems.push({ + type: PopoverItemType.Separator, + }); + } else { + /** + * Default item + */ + const popoverItem = { + ...commonPopoverItemParams, + ...item, + type: PopoverItemType.Default, + } as PopoverItemParams; + + /** Prepend with separator if item has children and not the first one */ + if ('children' in popoverItem && i !== 0) { + popoverItems.push({ + type: PopoverItemType.Separator, + }); + } + + popoverItems.push(popoverItem); + + /** Append separator after the item is it has children and not the last one */ + if ('children' in popoverItem && i < inlineTools.length - 1) { + popoverItems.push({ + type: PopoverItemType.Separator, + }); + } + } }); } - instance.checkState(SelectionUtils.get()); + return popoverItems; } /** @@ -608,7 +453,7 @@ export default class InlineToolbar extends Module { * * @param toolName — Tool name */ - private getToolShortcut(toolName): string | void { + private getToolShortcut(toolName: string): string | undefined { const { Tools } = this.Editor; /** @@ -628,16 +473,16 @@ export default class InlineToolbar extends Module { return this.inlineTools[toolName][CommonInternalSettings.Shortcut]; } - return tool.shortcut; + return tool?.shortcut; } /** * Enable Tool shortcut with Editor Shortcuts Module * - * @param {InlineTool} tool - Tool instance - * @param {string} shortcut - shortcut according to the ShortcutData Module format + * @param toolName - tool name + * @param shortcut - shortcut according to the ShortcutData Module format */ - private enableShortcuts(tool: IInlineTool, shortcut: string): void { + private enableShortcuts(toolName: string, shortcut: string): void { Shortcuts.add({ name: shortcut, handler: (event) => { @@ -662,7 +507,8 @@ export default class InlineToolbar extends Module { } event.preventDefault(); - this.toolClicked(tool); + + this.popover?.activateItemByName(toolName); }, on: this.Editor.UI.nodes.redactor, }); @@ -671,22 +517,17 @@ export default class InlineToolbar extends Module { /** * Inline Tool button clicks * - * @param {InlineTool} tool - Tool's instance + * @param tool - Tool's instance */ private toolClicked(tool: IInlineTool): void { const range = SelectionUtils.range; - tool.surround(range); - this.checkToolsState(); - - /** - * If tool has "actions", so after click it will probably toggle them on. - * For example, the Inline Link Tool will show the URL-input. - * So we disable the Flipper for that case to allow Tool bind own Enter listener - */ - if (tool.renderActions !== undefined) { - this.flipper.deactivate(); + if (range === null) { + return; } + + tool.surround?.(range); + this.checkToolsState(); } /** @@ -694,7 +535,7 @@ export default class InlineToolbar extends Module { */ private checkToolsState(): void { this.toolsInstances.forEach((toolInstance) => { - toolInstance.checkState(SelectionUtils.get()); + toolInstance.checkState?.(SelectionUtils.get()); }); } @@ -703,7 +544,7 @@ export default class InlineToolbar extends Module { * Tools that has isInline is true */ private get inlineTools(): { [name: string]: IInlineTool } { - const result = {}; + const result = {} as { [name: string]: IInlineTool } ; Array .from(this.Editor.Tools.inlineTools.entries()) @@ -713,18 +554,4 @@ export default class InlineToolbar extends Module { return result; } - - /** - * Allow to leaf buttons by arrows / tab - * Buttons will be filled on opening - */ - private enableFlipper(): void { - this.flipper = new Flipper({ - focusedItemClass: this.CSS.focusedButton, - allowedKeys: [ - _.keyCodes.ENTER, - _.keyCodes.TAB, - ], - }); - } } diff --git a/src/components/modules/tools.ts b/src/components/modules/tools.ts index e8b82295a..01fd7ef14 100644 --- a/src/components/modules/tools.ts +++ b/src/components/modules/tools.ts @@ -5,6 +5,7 @@ import { SanitizerConfig, ToolConfig, ToolConstructable, ToolSettings } from '.. import BoldInlineTool from '../inline-tools/inline-tool-bold'; import ItalicInlineTool from '../inline-tools/inline-tool-italic'; import LinkInlineTool from '../inline-tools/inline-tool-link'; +import ConvertInlineTool from '../inline-tools/inline-tool-convert'; import Stub from '../../tools/stub'; import ToolsFactory from '../tools/factory'; import InlineTool from '../tools/inline'; @@ -176,6 +177,14 @@ export default class Tools extends Module { */ private get internalTools(): { [toolName: string]: ToolConstructable | ToolSettings & { isInternal?: boolean } } { return { + convertTo: { + class: ConvertInlineTool, + isInternal: true, + }, + link: { + class: LinkInlineTool, + isInternal: true, + }, bold: { class: BoldInlineTool, isInternal: true, @@ -184,10 +193,6 @@ export default class Tools extends Module { class: ItalicInlineTool, isInternal: true, }, - link: { - class: LinkInlineTool, - isInternal: true, - }, paragraph: { class: Paragraph, inlineToolbar: true, @@ -224,7 +229,7 @@ export default class Tools extends Module { /** * Some Tools validation */ - const inlineToolRequiredMethods = ['render', 'surround', 'checkState']; + const inlineToolRequiredMethods = [ 'render' ]; const notImplementedMethods = inlineToolRequiredMethods.filter((method) => !tool.create()[method]); if (notImplementedMethods.length) { @@ -329,7 +334,8 @@ export default class Tools extends Module { */ if (Array.isArray(tool.enabledInlineTools)) { tool.inlineTools = new ToolsCollection( - tool.enabledInlineTools.map(name => [name, this.inlineTools.get(name)]) + /** Prepend ConvertTo Inline Tool */ + ['convertTo', ...tool.enabledInlineTools].map(name => [name, this.inlineTools.get(name)]) ); } } diff --git a/src/components/modules/ui.ts b/src/components/modules/ui.ts index d9b96e746..60c2a5f47 100644 --- a/src/components/modules/ui.ts +++ b/src/components/modules/ui.ts @@ -97,6 +97,7 @@ export default class UI extends Module { */ public isMobile = false; + /** * Cache for center column rectangle info * Invalidates on window resize @@ -135,6 +136,7 @@ export default class UI extends Module { this.loadStyles(); } + /** * Toggle read-only state * @@ -187,9 +189,9 @@ export default class UI extends Module { * @returns {boolean} */ public get someToolbarOpened(): boolean { - const { Toolbar, BlockSettings, InlineToolbar, ConversionToolbar } = this.Editor; + const { Toolbar, BlockSettings, InlineToolbar } = this.Editor; - return BlockSettings.opened || InlineToolbar.opened || ConversionToolbar.opened || Toolbar.toolbox.opened; + return Boolean(BlockSettings.opened || InlineToolbar.opened || Toolbar.toolbox.opened); } /** @@ -226,11 +228,10 @@ export default class UI extends Module { * Close all Editor's toolbars */ public closeAllToolbars(): void { - const { Toolbar, BlockSettings, InlineToolbar, ConversionToolbar } = this.Editor; + const { Toolbar, BlockSettings, InlineToolbar } = this.Editor; BlockSettings.close(); InlineToolbar.close(); - ConversionToolbar.close(); Toolbar.toolbox.close(); } @@ -381,6 +382,7 @@ export default class UI extends Module { this.watchBlockHoveredEvents(); } + /** * Listen redactor mousemove to emit 'block-hovered' event */ @@ -552,8 +554,6 @@ export default class UI extends Module { this.Editor.Caret.setToBlock(this.Editor.BlockManager.currentBlock, this.Editor.Caret.positions.END); } else if (this.Editor.BlockSettings.opened) { this.Editor.BlockSettings.close(); - } else if (this.Editor.ConversionToolbar.opened) { - this.Editor.ConversionToolbar.close(); } else if (this.Editor.InlineToolbar.opened) { this.Editor.InlineToolbar.close(); } else { @@ -872,8 +872,6 @@ export default class UI extends Module { this.Editor.BlockManager.setCurrentBlockByChildNode(focusedElement); } - const isNeedToShowConversionToolbar = clickedOutsideBlockContent !== true; - - this.Editor.InlineToolbar.tryToShow(true, isNeedToShowConversionToolbar); + this.Editor.InlineToolbar.tryToShow(true); } } diff --git a/src/components/tools/base.ts b/src/components/tools/base.ts index f89345a91..22bc83ef6 100644 --- a/src/components/tools/base.ts +++ b/src/components/tools/base.ts @@ -1,10 +1,9 @@ import { Tool, ToolConstructable, ToolSettings } from '../../../types/tools'; -import { SanitizerConfig } from '../../../types'; +import type { SanitizerConfig, API as ApiMethods } from '../../../types'; import * as _ from '../utils'; import type InlineTool from './inline'; import type BlockTool from './block'; import type BlockTune from './tune'; -import API from '../modules/api'; /** * What kind of plugins developers can create @@ -122,7 +121,7 @@ interface ConstructorOptions { name: string; constructable: ToolConstructable; config: ToolOptions; - api: API; + api: ApiMethods; isDefault: boolean; isInternal: boolean; defaultPlaceholder?: string | false; @@ -155,7 +154,7 @@ export default abstract class BaseTool { /** * EditorJS API for current Tool */ - protected api: API; + protected api: ApiMethods; /** * Current tool user configuration diff --git a/src/components/tools/block.ts b/src/components/tools/block.ts index c5a0f337a..a9ee44396 100644 --- a/src/components/tools/block.ts +++ b/src/components/tools/block.ts @@ -50,7 +50,7 @@ export default class BlockTool extends BaseTool { data, block, readOnly, - api: this.api.getMethodsForTool(this), + api: this.api, config: this.settings, }) as IBlockTool; } diff --git a/src/components/tools/factory.ts b/src/components/tools/factory.ts index b00427b03..fdf032c09 100644 --- a/src/components/tools/factory.ts +++ b/src/components/tools/factory.ts @@ -3,7 +3,7 @@ import { InternalInlineToolSettings, InternalTuneSettings } from './base'; import InlineTool from './inline'; import BlockTune from './tune'; import BlockTool from './block'; -import API from '../modules/api'; +import ApiModule from '../modules/api'; import { EditorConfig } from '../../../types/configs'; type ToolConstructor = typeof InlineTool | typeof BlockTool | typeof BlockTune; @@ -20,7 +20,7 @@ export default class ToolsFactory { /** * EditorJS API Module */ - private api: API; + private api: ApiModule; /** * EditorJS configuration @@ -36,7 +36,7 @@ export default class ToolsFactory { constructor( config: {[name: string]: ToolSettings & { isInternal?: boolean }}, editorConfig: EditorConfig, - api: API + api: ApiModule ) { this.api = api; this.config = config; @@ -52,12 +52,13 @@ export default class ToolsFactory { const { class: constructable, isInternal = false, ...config } = this.config[name]; const Constructor = this.getConstructor(constructable); + const isTune = constructable[InternalTuneSettings.IsTune]; return new Constructor({ name, constructable, config, - api: this.api, + api: this.api.getMethodsForTool(name, isTune), isDefault: name === this.editorConfig.defaultBlock, defaultPlaceholder: this.editorConfig.placeholder, isInternal, diff --git a/src/components/tools/inline.ts b/src/components/tools/inline.ts index 281d0d7de..64fd27288 100644 --- a/src/components/tools/inline.ts +++ b/src/components/tools/inline.ts @@ -28,7 +28,7 @@ export default class InlineTool extends BaseTool { public create(): IInlineTool { // eslint-disable-next-line new-cap return new this.constructable({ - api: this.api.getMethodsForTool(this), + api: this.api, config: this.settings, }) as IInlineTool; } diff --git a/src/components/tools/tune.ts b/src/components/tools/tune.ts index 52230cbdc..29dac95b0 100644 --- a/src/components/tools/tune.ts +++ b/src/components/tools/tune.ts @@ -27,7 +27,7 @@ export default class BlockTune extends BaseTool { public create(data: BlockTuneData, block: BlockAPI): IBlockTune { // eslint-disable-next-line new-cap return new this.constructable({ - api: this.api.getMethodsForTool(this), + api: this.api, config: this.settings, block, data, diff --git a/src/components/ui/toolbox.ts b/src/components/ui/toolbox.ts index 6bb941459..0e1502b6b 100644 --- a/src/components/ui/toolbox.ts +++ b/src/components/ui/toolbox.ts @@ -181,7 +181,7 @@ export default class Toolbox extends EventsDispatcher { } this.removeAllShortcuts(); - this.popover?.off(PopoverEvent.Close, this.onPopoverClose); + this.popover?.off(PopoverEvent.Closed, this.onPopoverClose); this.listeners.destroy(); this.api.events.off(EditorMobileLayoutToggled, this.handleMobileLayoutToggle); } @@ -253,7 +253,7 @@ export default class Toolbox extends EventsDispatcher { items: this.toolboxItemsToBeDisplayed, }); - this.popover.on(PopoverEvent.Close, this.onPopoverClose); + this.popover.on(PopoverEvent.Closed, this.onPopoverClose); this.nodes.toolbox?.append(this.popover.getElement()); } @@ -263,7 +263,7 @@ export default class Toolbox extends EventsDispatcher { private destroyPopover(): void { if (this.popover !== null) { this.popover.hide(); - this.popover.off(PopoverEvent.Close, this.onPopoverClose); + this.popover.off(PopoverEvent.Closed, this.onPopoverClose); this.popover.destroy(); this.popover = null; } diff --git a/src/components/utils/blocks.ts b/src/components/utils/blocks.ts index 9907c1be9..52e739bfa 100644 --- a/src/components/utils/blocks.ts +++ b/src/components/utils/blocks.ts @@ -1,7 +1,11 @@ +import { BlockAPI } from '../../../types'; import type { ConversionConfig } from '../../../types/configs/conversion-config'; +import { SavedData } from '../../../types/data-formats'; import type { BlockToolData } from '../../../types/tools/block-tool-data'; import type Block from '../block'; -import { isFunction, isString, log, equals } from '../utils'; +import BlockTool from '../tools/block'; +import { isFunction, isString, log, equals, isEmpty } from '../utils'; +import { isToolConvertable } from './tools'; /** @@ -11,18 +15,23 @@ import { isFunction, isString, log, equals } from '../utils'; * @param direction - export for block to merge from, import for block to merge to */ export function isBlockConvertable(block: Block, direction: 'export' | 'import'): boolean { - if (!block.tool.conversionConfig) { - return false; - } - - const conversionProp = block.tool.conversionConfig[direction]; - - return isFunction(conversionProp) || isString(conversionProp); + return isToolConvertable(block.tool, direction); } /** * Checks that all the properties of the first block data exist in second block data with the same values. * + * Example: + * + * data1 = { level: 1 } + * + * data2 = { + * text: "Heading text", + * level: 1 + * } + * + * isSameBlockData(data1, data2) => true + * * @param data1 – first block data * @param data2 – second block data */ @@ -32,6 +41,58 @@ export function isSameBlockData(data1: BlockToolData, data2: BlockToolData): boo })); } +/** + * Returns list of tools you can convert specified block to + * + * @param block - block to get conversion items for + * @param allBlockTools - all block tools available in the editor + */ +export async function getConvertibleToolsForBlock(block: BlockAPI, allBlockTools: BlockTool[]): Promise { + const savedData = await block.save() as SavedData; + const blockData = savedData.data; + + return allBlockTools.reduce((result, tool) => { + /** + * Skip tools without «import» rule specified + */ + if (!isToolConvertable(tool, 'import')) { + return result; + } + + /** Filter out invalid toolbox entries */ + const actualToolboxItems = tool.toolbox.filter((toolboxItem) => { + /** + * Skip items that don't pass 'toolbox' property or do not have an icon + */ + if (isEmpty(toolboxItem) || !toolboxItem.icon) { + return false; + } + + if (toolboxItem.data !== undefined) { + /** + * When a tool has several toolbox entries, we need to make sure we do not add + * toolbox item with the same data to the resulting array. This helps exclude duplicates + */ + if (isSameBlockData(toolboxItem.data, blockData)) { + return false; + } + } else if (tool.name === block.name) { + return false; + } + + return true; + }); + + result.push({ + ...tool, + toolbox: actualToolboxItems, + }); + + return result; + }, []); +} + + /** * Check if two blocks could be merged. * @@ -119,3 +180,4 @@ export function convertStringToBlockData(stringToImport: string, conversionConfi return {}; } } + diff --git a/src/components/utils/popover/components/hint/hint.const.ts b/src/components/utils/popover/components/hint/hint.const.ts index 8b5afd77a..03cd30cea 100644 --- a/src/components/utils/popover/components/hint/hint.const.ts +++ b/src/components/utils/popover/components/hint/hint.const.ts @@ -10,7 +10,8 @@ const className = bem('ce-hint'); */ export const css = { root: className(), - alignedLeft: className(null, 'align-left'), + alignedStart: className(null, 'align-left'), + alignedCenter: className(null, 'align-center'), title: className('title'), description: className('description'), }; diff --git a/src/components/utils/popover/components/hint/hint.css b/src/components/utils/popover/components/hint/hint.css index 94d886bd9..94e2a3e92 100644 --- a/src/components/utils/popover/components/hint/hint.css +++ b/src/components/utils/popover/components/hint/hint.css @@ -1,6 +1,10 @@ .ce-hint { - &--align-left { - text-align: left; + &--align-start { + text-align: start; + } + + &--align-center { + text-align: center; } &__description { diff --git a/src/components/utils/popover/components/hint/hint.ts b/src/components/utils/popover/components/hint/hint.ts index eb91de126..828eb139b 100644 --- a/src/components/utils/popover/components/hint/hint.ts +++ b/src/components/utils/popover/components/hint/hint.ts @@ -24,7 +24,7 @@ export class Hint { */ constructor(params: HintParams) { this.nodes = { - root: Dom.make('div', [css.root, css.alignedLeft]), + root: Dom.make('div', [css.root, params.alignment === 'center' ? css.alignedCenter : css.alignedStart]), title: Dom.make('div', css.title, { textContent: params.title }), }; diff --git a/src/components/utils/popover/components/hint/hint.types.ts b/src/components/utils/popover/components/hint/hint.types.ts index ca4b878ce..a16de7526 100644 --- a/src/components/utils/popover/components/hint/hint.types.ts +++ b/src/components/utils/popover/components/hint/hint.types.ts @@ -11,9 +11,19 @@ export interface HintParams { * Secondary text to be displayed below the title */ description?: string; + + /** + * Horizontal alignment of the hint content. Default is 'start' + */ + alignment?: HintTextAlignment; } /** * Possible hint positions */ export type HintPosition = 'top' | 'bottom' | 'left' | 'right'; + +/** + * Horizontal alignment of the hint content + */ +export type HintTextAlignment = 'start' | 'center'; diff --git a/src/components/utils/popover/components/popover-item/popover-item-default/popover-item-default.ts b/src/components/utils/popover/components/popover-item/popover-item-default/popover-item-default.ts index 6a5d12d01..1acd13f7e 100644 --- a/src/components/utils/popover/components/popover-item/popover-item-default/popover-item-default.ts +++ b/src/components/utils/popover/components/popover-item/popover-item-default/popover-item-default.ts @@ -2,7 +2,6 @@ import Dom from '../../../../../dom'; import { IconDotCircle, IconChevronRight } from '@codexteam/icons'; import { PopoverItemDefaultParams as PopoverItemDefaultParams, - PopoverItemParams as PopoverItemParams, PopoverItemRenderParamsMap, PopoverItemType } from '../popover-item.types'; @@ -39,13 +38,6 @@ export class PopoverItemDefault extends PopoverItem { return this.params.title; } - /** - * True if popover should close once item is activated - */ - public get closeOnActivate(): boolean | undefined { - return this.params.closeOnActivate; - } - /** * True if confirmation state is enabled for popover item */ @@ -75,7 +67,6 @@ export class PopoverItemDefault extends PopoverItem { icon: null, }; - /** * If item is in confirmation state, stores confirmation params such as icon, label, onActivate callback and so on */ @@ -88,8 +79,8 @@ export class PopoverItemDefault extends PopoverItem { * @param renderParams - popover item render params. * The parameters that are not set by user via popover api but rather depend on technical implementation */ - constructor(private readonly params: PopoverItemDefaultParams, renderParams?: PopoverItemRenderParamsMap[PopoverItemType.Default]) { - super(); + constructor(protected readonly params: PopoverItemDefaultParams, renderParams?: PopoverItemRenderParamsMap[PopoverItemType.Default]) { + super(params); this.nodes.root = this.make(params, renderParams); } @@ -148,13 +139,6 @@ export class PopoverItemDefault extends PopoverItem { this.disableSpecialHoverAndFocusBehavior(); } - /** - * Returns list of item children - */ - public get children(): PopoverItemParams[] { - return 'children' in this.params && this.params.children?.items !== undefined ? this.params.children.items : []; - } - /** * Constructs HTML element corresponding to popover item params * @@ -162,7 +146,8 @@ export class PopoverItemDefault extends PopoverItem { * @param renderParams - popover item render params */ private make(params: PopoverItemDefaultParams, renderParams?: PopoverItemRenderParamsMap[PopoverItemType.Default]): HTMLElement { - const el = Dom.make('div', css.container); + const tag = renderParams?.wrapperTag || 'div'; + const el = Dom.make(tag, css.container); if (params.name) { el.dataset.itemName = params.name; @@ -174,9 +159,11 @@ export class PopoverItemDefault extends PopoverItem { el.appendChild(this.nodes.icon); - el.appendChild(Dom.make('div', css.title, { - innerHTML: params.title || '', - })); + if (params.title !== undefined) { + el.appendChild(Dom.make('div', css.title, { + innerHTML: params.title || '', + })); + } if (params.secondaryLabel) { el.appendChild(Dom.make('div', css.secondaryTitle, { @@ -184,13 +171,13 @@ export class PopoverItemDefault extends PopoverItem { })); } - if (this.children.length > 0) { + if (this.hasChildren) { el.appendChild(Dom.make('div', [css.icon, css.iconChevronRight], { innerHTML: IconChevronRight, })); } - if (params.isActive) { + if (this.isActive) { el.classList.add(css.active); } @@ -221,7 +208,7 @@ export class PopoverItemDefault extends PopoverItem { const params = { ...this.params, ...newState, - confirmation: newState.confirmation, + confirmation: 'confirmation' in newState ? newState.confirmation : undefined, } as PopoverItemDefaultParams; const confirmationEl = this.make(params); @@ -291,7 +278,7 @@ export class PopoverItemDefault extends PopoverItem { * @param item - item to activate or bring to confirmation mode */ private activateOrEnableConfirmationMode(item: PopoverItemDefaultParams): void { - if (item.confirmation === undefined) { + if (!('confirmation' in item) || item.confirmation === undefined) { try { item.onActivate?.(item); this.disableConfirmationMode(); diff --git a/src/components/utils/popover/components/popover-item/popover-item-html/popover-item-html.ts b/src/components/utils/popover/components/popover-item/popover-item-html/popover-item-html.ts index 3fed70042..d911c138e 100644 --- a/src/components/utils/popover/components/popover-item/popover-item-html/popover-item-html.ts +++ b/src/components/utils/popover/components/popover-item/popover-item-html/popover-item-html.ts @@ -20,7 +20,7 @@ export class PopoverItemHtml extends PopoverItem { * The parameters that are not set by user via popover api but rather depend on technical implementation */ constructor(params: PopoverItemHtmlParams, renderParams?: PopoverItemRenderParamsMap[PopoverItemType.Html]) { - super(); + super(params); this.nodes = { root: Dom.make('div', css.root), @@ -28,6 +28,10 @@ export class PopoverItemHtml extends PopoverItem { this.nodes.root.appendChild(params.element); + if (params.name) { + this.nodes.root.dataset.itemName = params.name; + } + if (params.hint !== undefined && renderParams?.hint?.enabled !== false) { this.addHint(this.nodes.root, { ...params.hint, @@ -63,4 +67,11 @@ export class PopoverItemHtml extends PopoverItem { return Array.from(controls); } + + /** + * Called on popover item click + */ + public handleClick(): void { + this.params.onActivate?.(this.params); + } } diff --git a/src/components/utils/popover/components/popover-item/popover-item.ts b/src/components/utils/popover/components/popover-item/popover-item.ts index a9a4713a9..5e0c10db0 100644 --- a/src/components/utils/popover/components/popover-item/popover-item.ts +++ b/src/components/utils/popover/components/popover-item/popover-item.ts @@ -1,10 +1,63 @@ import * as tooltip from '../../../../utils/tooltip'; import { type HintPosition, Hint } from '../hint'; +import { PopoverItemParams } from './popover-item.types'; /** * Popover item abstract class */ export abstract class PopoverItem { + /** + * Constructs the instance + * + * @param params - instance parameters + */ + constructor(protected readonly params?: PopoverItemParams) {} + + /** + * Item name if exists + */ + public get name(): string | undefined { + if (this.params === undefined) { + return; + } + if ('name' in this.params) { + return this.params.name; + } + } + + /** + * Destroys the instance + */ + public destroy(): void { + tooltip.hide(); + } + + /** + * Called when children popover is opened (if exists) + */ + public onChildrenOpen(): void { + if (this.params === undefined) { + return; + } + + if ('children' in this.params && typeof this.params.children?.onOpen === 'function') { + this.params.children.onOpen(); + } + } + + /** + * Called when children popover is closed (if exists) + */ + public onChildrenClose(): void { + if (this.params === undefined) { + return; + } + + if ('children' in this.params && typeof this.params.children?.onClose === 'function') { + this.params.children.onClose(); + } + } + /** * Adds hint to the item element if hint data is provided * @@ -31,4 +84,58 @@ export abstract class PopoverItem { * @param isHidden - true if item should be hidden */ public abstract toggleHidden(isHidden: boolean): void; + + + /** + * Returns item children that are represented as popover items + */ + public get children(): PopoverItemParams[] { + return this.params !== undefined && 'children' in this.params && this.params.children?.items !== undefined ? this.params.children.items : []; + } + + /** + * Returns true if item has any type of children + */ + public get hasChildren(): boolean { + return this.children.length > 0; + } + + /** + * Returns true if item children should be open instantly after popover is opened and not on item click/hover + */ + public get isChildrenOpen(): boolean { + return this.params !== undefined && 'children' in this.params && this.params.children?.isOpen === true; + } + + /** + * Returns true if item has children that should be searchable + */ + public get isChildrenSearchable(): boolean { + return this.params !== undefined && 'children' in this.params && this.params.children?.searchable === true; + } + + /** + * True if popover should close once item is activated + */ + public get closeOnActivate(): boolean | undefined { + return this.params !== undefined && 'closeOnActivate' in this.params && this.params.closeOnActivate; + } + + /** + * True if item is active + */ + public get isActive(): boolean { + if (this.params === undefined) { + return false; + } + if (!('isActive' in this.params)) { + return; + } + + if (typeof this.params.isActive === 'function') { + return this.params.isActive(); + } + + return this.params.isActive === true; + } } diff --git a/src/components/utils/popover/components/popover-item/popover-item.types.ts b/src/components/utils/popover/components/popover-item/popover-item.types.ts index c0a5501e2..b90966055 100644 --- a/src/components/utils/popover/components/popover-item/popover-item.types.ts +++ b/src/components/utils/popover/components/popover-item/popover-item.types.ts @@ -1,4 +1,4 @@ -import { HintParams, HintPosition } from '../hint'; +import { HintTextAlignment, HintParams, HintPosition } from '../hint'; /** * Popover item types @@ -14,6 +14,69 @@ export enum PopoverItemType { Html = 'html' } +/** + * Represents popover item children configuration + */ +export interface PopoverItemChildren { + /** + * True if children items should be searchable + */ + searchable?: boolean; + + /** + * True if popover with children should be displayed instantly and not after item click/hover. + * False by default. + * Now is used only in the inline popover. + */ + isOpen?: boolean; + + /** + * Items of nested popover that should be open on the current item hover/click (depending on platform) + */ + items?: PopoverItemParams[]; + + /** + * Called once children popover is opened + */ + onOpen?: () => void; + + /** + * Called once children popover is closed + */ + onClose?: () => void; +} + +/** + * Adds children property to the item + */ +export type WithChildren = Omit & { + /** + * Popover item children configuration + */ + children: PopoverItemChildren; + + /** + * Items with children should not have onActivate handler + */ + onActivate?: never; +}; + +/** + * Represents popover item with confirmation. + */ +export type PopoverItemDefaultWithConfirmationParams = Omit & { + /** + * Popover item parameters that should be applied on item activation. + * May be used to ask user for confirmation before executing popover item activation handler. + */ + confirmation: PopoverItemDefaultBaseParams; + + /** + * Items with confirmation should not have onActivate handler + */ + onActivate?: never; +}; + /** * Represents popover item separator. * Special item type that is used to separate items in the popover. @@ -22,7 +85,7 @@ export interface PopoverItemSeparatorParams { /** * Item type */ - type: PopoverItemType.Separator + type: PopoverItemType.Separator; } /** @@ -43,12 +106,23 @@ export interface PopoverItemHtmlParams { * Hint data to be displayed on item hover */ hint?: HintParams; + + /** + * True if popover should close once item is activated + */ + closeOnActivate?: boolean; + + /** + * Item name + * Used in data attributes needed for cypress tests + */ + name?: string; } /** * Common parameters for all kinds of default popover items: with or without confirmation */ -interface PopoverItemDefaultBaseParams { +export interface PopoverItemDefaultBaseParams { /** * Item type */ @@ -72,7 +146,7 @@ interface PopoverItemDefaultBaseParams { /** * True if item should be highlighted as active */ - isActive?: boolean; + isActive?: boolean | (() => boolean); /** * True if item should be disabled @@ -86,7 +160,7 @@ interface PopoverItemDefaultBaseParams { /** * Item name - * Used in data attributes needed for cypress tests + * Used in data attributes needed for shortcuts work and for cypress tests */ name?: string; @@ -101,26 +175,6 @@ interface PopoverItemDefaultBaseParams { * Hint data to be displayed on item hover */ hint?: HintParams; -} - -/** - * Represents popover item with confirmation state configuration - */ -export interface PopoverItemWithConfirmationParams extends PopoverItemDefaultBaseParams { - /** - * Popover item parameters that should be applied on item activation. - * May be used to ask user for confirmation before executing popover item activation handler. - */ - confirmation: PopoverItemDefaultParams; - - onActivate?: never; -} - -/** - * Represents popover item without confirmation state configuration - */ -export interface PopoverItemWithoutConfirmationParams extends PopoverItemDefaultBaseParams { - confirmation?: never; /** * Popover item activation handler @@ -131,29 +185,13 @@ export interface PopoverItemWithoutConfirmationParams extends PopoverItemDefault onActivate: (item: PopoverItemParams, event?: PointerEvent) => void; } - -/** - * Represents popover item with children (nested popover items) - */ -export interface PopoverItemWithChildrenParams extends PopoverItemDefaultBaseParams { - confirmation?: never; - onActivate?: never; - - /** - * Items of nested popover that should be open on the current item hover/click (depending on platform) - */ - children?: { - items: PopoverItemParams[] - } -} - /** - * Default, non-separator popover item type + * Default, non-separator and non-html popover items type */ export type PopoverItemDefaultParams = - PopoverItemWithConfirmationParams | - PopoverItemWithoutConfirmationParams | - PopoverItemWithChildrenParams; + PopoverItemDefaultBaseParams | + PopoverItemDefaultWithConfirmationParams | + WithChildren; /** * Represents single popover item @@ -161,7 +199,31 @@ export type PopoverItemDefaultParams = export type PopoverItemParams = PopoverItemDefaultParams | PopoverItemSeparatorParams | - PopoverItemHtmlParams; + PopoverItemHtmlParams | + WithChildren; + +/** + * Parameters of how to render hint for the popover item + */ +type PopoverItemHintRenderParams = { + /** + * Hint position relative to the item + */ + position?: HintPosition; + + /** + * Horizontal alignment of the hint content. + * 'start' by default. + */ + alignment?: HintTextAlignment; + + /** + * If false, hint will not be rendered. + * True by default. + * Used to disable hints on mobile popover + */ + enabled?: boolean; +}; /** @@ -169,22 +231,23 @@ export type PopoverItemParams = * The parameters that are not set by user via popover api but rather depend on technical implementation */ export type PopoverItemRenderParamsMap = { - [key in PopoverItemType.Default | PopoverItemType.Html]?: { + [PopoverItemType.Default]?: { + /** + * Wrapper tag for the item. + * Div by default + */ + wrapperTag?: 'div' | 'button'; + + /** + * Hint render params + */ + hint?: PopoverItemHintRenderParams + }; + + [PopoverItemType.Html]?: { /** * Hint render params */ - hint?: { - /** - * Hint position relative to the item - */ - position?: HintPosition; - - /** - * If false, hint will not be rendered. - * True by default. - * Used to disable hints on mobile popover - */ - enabled: boolean; - } + hint?: PopoverItemHintRenderParams }; }; diff --git a/src/components/utils/popover/index.ts b/src/components/utils/popover/index.ts index 6c2cbb265..d7834a95a 100644 --- a/src/components/utils/popover/index.ts +++ b/src/components/utils/popover/index.ts @@ -1,4 +1,5 @@ import { PopoverDesktop } from './popover-desktop'; +import { PopoverInline } from './popover-inline'; import { PopoverMobile } from './popover-mobile'; export * from './popover.types'; @@ -7,6 +8,6 @@ export * from './components/popover-item/popover-item.types'; /** * Union type for all popovers */ -export type Popover = PopoverDesktop | PopoverMobile; +export type Popover = PopoverDesktop | PopoverMobile | PopoverInline; -export { PopoverDesktop, PopoverMobile }; +export { PopoverDesktop, PopoverMobile, PopoverInline }; diff --git a/src/components/utils/popover/popover-abstract.ts b/src/components/utils/popover/popover-abstract.ts index 2bb096288..7b8e8f0a3 100644 --- a/src/components/utils/popover/popover-abstract.ts +++ b/src/components/utils/popover/popover-abstract.ts @@ -1,6 +1,6 @@ import { PopoverItem, PopoverItemDefault, PopoverItemRenderParamsMap, PopoverItemSeparator, PopoverItemType } from './components/popover-item'; import Dom from '../../dom'; -import { SearchInput, SearchInputEvent, SearchableItem } from './components/search-input'; +import { SearchInput } from './components/search-input'; import EventsDispatcher from '../events'; import Listeners from '../listeners'; import { PopoverEventMap, PopoverMessages, PopoverParams, PopoverEvent, PopoverNodes } from './popover.types'; @@ -39,11 +39,10 @@ export abstract class PopoverAbstract */ protected search: SearchInput | undefined; - /** * Messages that will be displayed in popover */ - private messages: PopoverMessages = { + protected messages: PopoverMessages = { nothingFound: 'Nothing found', search: 'Search', }; @@ -102,10 +101,6 @@ export abstract class PopoverAbstract ]); this.nodes.popover.appendChild(this.nodes.popoverContainer); - - if (params.searchable) { - this.addSearch(); - } } /** @@ -139,14 +134,28 @@ export abstract class PopoverAbstract this.search.clear(); } - this.emit(PopoverEvent.Close); + this.emit(PopoverEvent.Closed); } /** * Clears memory */ public destroy(): void { + this.items.forEach(item => item.destroy()); + this.nodes.popover.remove(); this.listeners.removeAll(); + this.search?.destroy(); + } + + /** + * Looks for the item by name and imitates click on it + * + * @param name - name of the item to activate + */ + public activateItemByName(name: string): void { + const foundItem = this.items.find(item => item.name === name); + + this.handleItemClick(foundItem); } /** @@ -162,7 +171,7 @@ export abstract class PopoverAbstract case PopoverItemType.Html: return new PopoverItemHtml(item, this.itemsRenderParams[PopoverItemType.Html]); default: - return new PopoverItemDefault(item, this.itemsRenderParams[PopoverItemType.Default]); + return new PopoverItemDefault(item, this.itemsRenderParams[PopoverItemType.Default]); } }); } @@ -172,80 +181,36 @@ export abstract class PopoverAbstract * * @param event - event to retrieve popover item from */ - protected getTargetItem(event: Event): PopoverItemDefault | undefined { - return this.itemsDefault.find(el => { - const itemEl = el.getElement(); - - if (itemEl === null) { - return false; - } - - return event.composedPath().includes(itemEl); - }); - } - - /** - * Handles input inside search field - * - * @param data - search input event data - * @param data.query - search query text - * @param data.result - search results - */ - private onSearch = (data: { query: string, items: SearchableItem[] }): void => { - const isEmptyQuery = data.query === ''; - const isNothingFound = data.items.length === 0; - - this.items - .forEach((item) => { - let isHidden = false; - - if (item instanceof PopoverItemDefault) { - isHidden = !data.items.includes(item); - } else if (item instanceof PopoverItemSeparator || item instanceof PopoverItemHtml) { - /** Should hide separators if nothing found message displayed or if there is some search query applied */ - isHidden = isNothingFound || !isEmptyQuery; + protected getTargetItem(event: Event): PopoverItemDefault | PopoverItemHtml | undefined { + return this.items + .filter(item => item instanceof PopoverItemDefault || item instanceof PopoverItemHtml) + .find(item => { + const itemEl = item.getElement(); + + if (itemEl === null) { + return false; } - item.toggleHidden(isHidden); - }); - this.toggleNothingFoundMessage(isNothingFound); - }; - - /** - * Adds search to the popover - */ - private addSearch(): void { - this.search = new SearchInput({ - items: this.itemsDefault, - placeholder: this.messages.search, - }); - - this.search.on(SearchInputEvent.Search, this.onSearch); - const searchElement = this.search.getElement(); - - searchElement.classList.add(css.search); - - this.nodes.popoverContainer.insertBefore(searchElement, this.nodes.popoverContainer.firstChild); + return event.composedPath().includes(itemEl); + }) as PopoverItemDefault | PopoverItemHtml | undefined; } /** - * Handles clicks inside popover + * Handles popover item click * - * @param event - item to handle click of + * @param item - item to handle click of */ - private handleClick(event: Event): void { - const item = this.getTargetItem(event); - - if (item === undefined) { + protected handleItemClick(item: PopoverItem): void { + if ('isDisabled' in item && item.isDisabled) { return; } - if (item.isDisabled) { - return; - } + if (item.hasChildren) { + this.showNestedItems(item as PopoverItemDefault | PopoverItemHtml); - if (item.children.length > 0) { - this.showNestedItems(item); + if ('handleClick' in item && typeof item.handleClick === 'function') { + item.handleClick(); + } return; } @@ -253,22 +218,32 @@ export abstract class PopoverAbstract /** Cleanup other items state */ this.itemsDefault.filter(x => x !== item).forEach(x => x.reset()); - item.handleClick(); + if ('handleClick' in item && typeof item.handleClick === 'function') { + item.handleClick(); + } this.toggleItemActivenessIfNeeded(item); if (item.closeOnActivate) { this.hide(); + + this.emit(PopoverEvent.ClosedOnActivate); } } /** - * Toggles nothing found message visibility + * Handles clicks inside popover * - * @param isDisplayed - true if the message should be displayed + * @param event - item to handle click of */ - private toggleNothingFoundMessage(isDisplayed: boolean): void { - this.nodes.nothingFoundMessage.classList.toggle(css.nothingFoundMessageDisplayed, isDisplayed); + private handleClick(event: Event): void { + const item = this.getTargetItem(event); + + if (item === undefined) { + return; + } + + this.handleItemClick(item); } /** @@ -279,7 +254,11 @@ export abstract class PopoverAbstract * * @param clickedItem - popover item that was clicked */ - private toggleItemActivenessIfNeeded(clickedItem: PopoverItemDefault): void { + private toggleItemActivenessIfNeeded(clickedItem: PopoverItem): void { + if (!(clickedItem instanceof PopoverItemDefault)) { + return; + } + if (clickedItem.toggle === true) { clickedItem.toggleActive(); } @@ -306,5 +285,5 @@ export abstract class PopoverAbstract * * @param item – item to show nested popover for */ - protected abstract showNestedItems(item: PopoverItemDefault): void; + protected abstract showNestedItems(item: PopoverItemDefault | PopoverItemHtml): void; } diff --git a/src/components/utils/popover/popover-desktop.ts b/src/components/utils/popover/popover-desktop.ts index 1570d2fe9..744806685 100644 --- a/src/components/utils/popover/popover-desktop.ts +++ b/src/components/utils/popover/popover-desktop.ts @@ -1,10 +1,10 @@ import Flipper from '../../flipper'; import { PopoverAbstract } from './popover-abstract'; -import { PopoverItem, css as popoverItemCls } from './components/popover-item'; -import { PopoverParams } from './popover.types'; +import { PopoverItem, PopoverItemRenderParamsMap, PopoverItemSeparator, WithChildren, css as popoverItemCls } from './components/popover-item'; +import { PopoverEvent, PopoverParams } from './popover.types'; import { keyCodes } from '../../utils'; -import { css } from './popover.const'; -import { SearchInputEvent, SearchableItem } from './components/search-input'; +import { CSSVariables, css } from './popover.const'; +import { SearchInput, SearchInputEvent, SearchableItem } from './components/search-input'; import { cacheable } from '../../utils'; import { PopoverItemDefault } from './components/popover-item'; import { PopoverItemHtml } from './components/popover-item/popover-item-html/popover-item-html'; @@ -21,11 +21,16 @@ export class PopoverDesktop extends PopoverAbstract { */ public flipper: Flipper; + /** + * Popover nesting level. 0 value means that it is a root popover + */ + public nestingLevel = 0; + /** * Reference to nested popover if exists. * Undefined by default, PopoverDesktop when exists and null after destroyed. */ - private nestedPopover: PopoverDesktop | undefined | null; + protected nestedPopover: PopoverDesktop | undefined | null; /** * Last hovered item inside popover. @@ -34,11 +39,6 @@ export class PopoverDesktop extends PopoverAbstract { */ private previouslyHoveredItem: PopoverItem | null = null; - /** - * Popover nesting level. 0 value means that it is a root popover - */ - private nestingLevel = 0; - /** * Element of the page that creates 'scope' of the popover. * If possible, popover will not cross specified element's borders when opening. @@ -49,9 +49,11 @@ export class PopoverDesktop extends PopoverAbstract { * Construct the instance * * @param params - popover params + * @param itemsRenderParams – popover item render params. + * The parameters that are not set by user via popover api but rather depend on technical implementation */ - constructor(params: PopoverParams) { - super(params); + constructor(params: PopoverParams, itemsRenderParams?: PopoverItemRenderParamsMap) { + super(params, itemsRenderParams); if (params.nestingLevel !== undefined) { this.nestingLevel = params.nestingLevel; @@ -69,6 +71,10 @@ export class PopoverDesktop extends PopoverAbstract { this.listeners.on(this.nodes.popoverContainer, 'mouseover', (event: Event) => this.handleHover(event)); } + if (params.searchable) { + this.addSearch(); + } + this.flipper = new Flipper({ items: this.flippableElements, focusedItemClass: popoverItemCls.focused, @@ -81,8 +87,6 @@ export class PopoverDesktop extends PopoverAbstract { }); this.flipper.onFlip(this.onFlip); - - this.search?.on(SearchInputEvent.Search, this.handleSearch); } /** @@ -122,7 +126,7 @@ export class PopoverDesktop extends PopoverAbstract { * Open popover */ public show(): void { - this.nodes.popover.style.setProperty('--popover-height', this.size.height + 'px'); + this.nodes.popover.style.setProperty(CSSVariables.PopoverHeight, this.size.height + 'px'); if (!this.shouldOpenBottom) { this.nodes.popover.classList.add(css.popoverOpenTop); @@ -139,7 +143,7 @@ export class PopoverDesktop extends PopoverAbstract { /** * Closes popover */ - public hide(): void { + public hide = (): void => { super.hide(); this.destroyNestedPopoverIfExists(); @@ -147,7 +151,7 @@ export class PopoverDesktop extends PopoverAbstract { this.flipper.deactivate(); this.previouslyHoveredItem = null; - } + }; /** * Clears memory @@ -162,7 +166,7 @@ export class PopoverDesktop extends PopoverAbstract { * * @param item – item to show nested popover for */ - protected override showNestedItems(item: PopoverItemDefault): void { + protected override showNestedItems(item: WithChildren | WithChildren): void { if (this.nestedPopover !== null && this.nestedPopover !== undefined) { return; } @@ -170,23 +174,100 @@ export class PopoverDesktop extends PopoverAbstract { } /** - * Additionaly handles input inside search field. - * Updates flipper items considering search query applied. + * Handles hover events inside popover items container * - * @param data - search event data - * @param data.query - search query text - * @param data.result - search results + * @param event - hover event data */ - private handleSearch = (data: { query: string, items: SearchableItem[] }): void => { - /** List of elements available for keyboard navigation considering search query applied */ - const flippableElements = data.query === '' ? this.flippableElements : data.items.map(item => (item as PopoverItem).getElement()); + protected handleHover(event: Event): void { + const item = this.getTargetItem(event); - if (this.flipper.isActivated) { - /** Update flipper items with only visible */ - this.flipper.deactivate(); - this.flipper.activate(flippableElements as HTMLElement[]); + if (item === undefined) { + return; } - }; + + if (this.previouslyHoveredItem === item) { + return; + } + + this.destroyNestedPopoverIfExists(); + + this.previouslyHoveredItem = item; + + if (!item.hasChildren) { + return; + } + + this.showNestedPopoverForItem(item); + } + + /** + * Sets CSS variable with position of item near which nested popover should be displayed. + * Is used for correct positioning of the nested popover + * + * @param nestedPopoverEl - nested popover element + * @param item – item near which nested popover should be displayed + */ + protected setTriggerItemPosition(nestedPopoverEl: HTMLElement, item: WithChildren | WithChildren): void { + const itemEl = item.getElement(); + const itemOffsetTop = (itemEl ? itemEl.offsetTop : 0) - this.scrollTop; + const topOffset = this.offsetTop + itemOffsetTop; + + nestedPopoverEl.style.setProperty(CSSVariables.TriggerItemTop, topOffset + 'px'); + } + + /** + * Destroys existing nested popover + */ + protected destroyNestedPopoverIfExists(): void { + if (this.nestedPopover === undefined || this.nestedPopover === null) { + return; + } + + this.nestedPopover.off(PopoverEvent.ClosedOnActivate, this.hide); + this.nestedPopover.hide(); + this.nestedPopover.destroy(); + this.nestedPopover.getElement().remove(); + this.nestedPopover = null; + this.flipper.activate(this.flippableElements); + + this.items.forEach(item => item.onChildrenClose()); + } + + /** + * Creates and displays nested popover for specified item. + * Is used only on desktop + * + * @param item - item to display nested popover by + */ + protected showNestedPopoverForItem(item: WithChildren | WithChildren): PopoverDesktop { + this.nestedPopover = new PopoverDesktop({ + searchable: item.isChildrenSearchable, + items: item.children, + nestingLevel: this.nestingLevel + 1, + }); + + item.onChildrenOpen(); + + /** + * Close nested popover when item with 'closeOnActivate' property set was clicked + * parent popover should also be closed + */ + this.nestedPopover.on(PopoverEvent.ClosedOnActivate, this.hide); + + const nestedPopoverEl = this.nestedPopover.getElement(); + + this.nodes.popover.appendChild(nestedPopoverEl); + + this.setTriggerItemPosition(nestedPopoverEl, item); + + /* We need nesting level value in CSS to calculate offset left for nested popover */ + nestedPopoverEl.style.setProperty(CSSVariables.NestingLevel, this.nestedPopover.nestingLevel.toString()); + + this.nestedPopover.show(); + this.flipper.deactivate(); + + return this.nestedPopover; + } /** * Checks if popover should be opened bottom. @@ -226,11 +307,11 @@ export class PopoverDesktop extends PopoverAbstract { } /** - * Helps to calculate size of popover while it is not displayed on screen. - * Renders invisible clone of popover to get actual size. + * Helps to calculate size of popover that is only resolved when popover is displayed on screen. + * Renders invisible clone of popover to get actual values. */ @cacheable - private get size(): {height: number; width: number} { + public get size(): { height: number; width: number } { const size = { height: 0, width: 0, @@ -250,36 +331,20 @@ export class PopoverDesktop extends PopoverAbstract { popoverClone.querySelector('.' + css.popoverNested)?.remove(); document.body.appendChild(popoverClone); - const container = popoverClone.querySelector('.' + css.popoverContainer) as HTMLElement; + const container = popoverClone.querySelector('.' + css.popoverContainer) as HTMLElement; size.height = container.offsetHeight; size.width = container.offsetWidth; - popoverClone.remove(); return size; } - /** - * Destroys existing nested popover - */ - private destroyNestedPopoverIfExists(): void { - if (this.nestedPopover === undefined || this.nestedPopover === null) { - return; - } - - this.nestedPopover.hide(); - this.nestedPopover.destroy(); - this.nestedPopover.getElement().remove(); - this.nestedPopover = null; - this.flipper.activate(this.flippableElements); - } - /** * Returns list of elements available for keyboard navigation. */ private get flippableElements(): HTMLElement[] { - const result = this.items + const result = this.items .map(item => { if (item instanceof PopoverItemDefault) { return item.getElement(); @@ -304,55 +369,64 @@ export class PopoverDesktop extends PopoverAbstract { }; /** - * Creates and displays nested popover for specified item. - * Is used only on desktop - * - * @param item - item to display nested popover by + * Adds search to the popover */ - private showNestedPopoverForItem(item: PopoverItemDefault): void { - this.nestedPopover = new PopoverDesktop({ - items: item.children, - nestingLevel: this.nestingLevel + 1, + private addSearch(): void { + this.search = new SearchInput({ + items: this.itemsDefault, + placeholder: this.messages.search, }); - const nestedPopoverEl = this.nestedPopover.getElement(); + this.search.on(SearchInputEvent.Search, this.onSearch); - this.nodes.popover.appendChild(nestedPopoverEl); - const itemEl = item.getElement(); - const itemOffsetTop = (itemEl ? itemEl.offsetTop : 0) - this.scrollTop; - const topOffset = this.offsetTop + itemOffsetTop; + const searchElement = this.search.getElement(); - nestedPopoverEl.style.setProperty('--trigger-item-top', topOffset + 'px'); - nestedPopoverEl.style.setProperty('--nesting-level', this.nestedPopover.nestingLevel.toString()); + searchElement.classList.add(css.search); - this.nestedPopover.show(); - this.flipper.deactivate(); + this.nodes.popoverContainer.insertBefore(searchElement, this.nodes.popoverContainer.firstChild); } /** - * Handles hover events inside popover items container + * Handles input inside search field * - * @param event - hover event data + * @param data - search input event data + * @param data.query - search query text + * @param data.result - search results */ - private handleHover(event: Event): void { - const item = this.getTargetItem(event); + private onSearch = (data: { query: string, items: SearchableItem[] }): void => { + const isEmptyQuery = data.query === ''; + const isNothingFound = data.items.length === 0; - if (item === undefined) { - return; - } - - if (this.previouslyHoveredItem === item) { - return; - } + this.items + .forEach((item) => { + let isHidden = false; - this.destroyNestedPopoverIfExists(); + if (item instanceof PopoverItemDefault) { + isHidden = !data.items.includes(item); + } else if (item instanceof PopoverItemSeparator || item instanceof PopoverItemHtml) { + /** Should hide separators if nothing found message displayed or if there is some search query applied */ + isHidden = isNothingFound || !isEmptyQuery; + } + item.toggleHidden(isHidden); + }); + this.toggleNothingFoundMessage(isNothingFound); - this.previouslyHoveredItem = item; + /** List of elements available for keyboard navigation considering search query applied */ + const flippableElements = data.query === '' ? this.flippableElements : data.items.map(item => (item as PopoverItem).getElement()); - if (item.children.length === 0) { - return; + if (this.flipper.isActivated) { + /** Update flipper items with only visible */ + this.flipper.deactivate(); + this.flipper.activate(flippableElements as HTMLElement[]); } + }; - this.showNestedPopoverForItem(item); + /** + * Toggles nothing found message visibility + * + * @param isDisplayed - true if the message should be displayed + */ + private toggleNothingFoundMessage(isDisplayed: boolean): void { + this.nodes.nothingFoundMessage.classList.toggle(css.nothingFoundMessageDisplayed, isDisplayed); } } diff --git a/src/components/utils/popover/popover-inline.ts b/src/components/utils/popover/popover-inline.ts new file mode 100644 index 000000000..444f46d0c --- /dev/null +++ b/src/components/utils/popover/popover-inline.ts @@ -0,0 +1,186 @@ +import { isMobileScreen } from '../../utils'; +import { PopoverItem, PopoverItemDefault, PopoverItemType, WithChildren } from './components/popover-item'; +import { PopoverItemHtml } from './components/popover-item/popover-item-html/popover-item-html'; +import { PopoverDesktop } from './popover-desktop'; +import { CSSVariables, css } from './popover.const'; +import { PopoverParams } from './popover.types'; + +/** + * Horizontal popover that is displayed inline with the content + */ +export class PopoverInline extends PopoverDesktop { + /** + * Item nested popover is displayed for + */ + private nestedPopoverTriggerItem: PopoverItemDefault | PopoverItemHtml | null = null; + + /** + * Constructs the instance + * + * @param params - instance parameters + */ + constructor(params: PopoverParams) { + const isHintEnabled = !isMobileScreen(); + + super( + { + ...params, + class: css.popoverInline, + }, + { + [PopoverItemType.Default]: { + /** + * We use button instead of div here to fix bug associated with focus loss (which leads to selection change) on click in safari + * + * @todo figure out better way to solve the issue + */ + wrapperTag: 'button', + hint: { + position: 'top', + alignment: 'center', + enabled: isHintEnabled, + }, + }, + [PopoverItemType.Html]: { + hint: { + position: 'top', + alignment: 'center', + enabled: isHintEnabled, + }, + }, + } + ); + + /** + * If active popover item has children, show them. + * This is needed to display link url text (which is displayed as a nested popover content) + * once you select tag content in text + */ + this.items + .forEach((item) => { + if (!(item instanceof PopoverItemDefault) && !(item instanceof PopoverItemHtml)) { + return; + } + + if (item.hasChildren && item.isChildrenOpen) { + this.showNestedItems(item); + } + }); + } + + /** + * Returns visible element offset top + */ + public get offsetLeft(): number { + if (this.nodes.popoverContainer === null) { + return 0; + } + + return this.nodes.popoverContainer.offsetLeft; + } + + /** + * Open popover + */ + public override show(): void { + /** + * If this is not a nested popover, set CSS variable with width of the popover + */ + if (this.nestingLevel === 0) { + this.nodes.popover.style.setProperty( + CSSVariables.InlinePopoverWidth, + this.size.width + 'px' + ); + } + super.show(); + } + + /** + * Disable hover event handling. + * Overrides parent's class behavior + */ + protected override handleHover(): void { + return; + } + + /** + * Sets CSS variable with position of item near which nested popover should be displayed. + * Is used to position nested popover right below clicked item + * + * @param nestedPopoverEl - nested popover element + * @param item – item near which nested popover should be displayed + */ + protected override setTriggerItemPosition( + nestedPopoverEl: HTMLElement, + item: PopoverItemDefault + ): void { + const itemEl = item.getElement(); + const itemOffsetLeft = itemEl ? itemEl.offsetLeft : 0; + const totalLeftOffset = this.offsetLeft + itemOffsetLeft; + + nestedPopoverEl.style.setProperty( + CSSVariables.TriggerItemLeft, + totalLeftOffset + 'px' + ); + } + + /** + * Handles displaying nested items for the item. + * Overriding in order to add toggling behaviour + * + * @param item – item to toggle nested popover for + */ + protected override showNestedItems(item: PopoverItemDefault | PopoverItemHtml): void { + if (this.nestedPopoverTriggerItem === item) { + this.nestedPopoverTriggerItem = null; + this.destroyNestedPopoverIfExists(); + + return; + } + + this.nestedPopoverTriggerItem = item; + super.showNestedItems(item); + } + + /** + * Creates and displays nested popover for specified item. + * Is used only on desktop + * + * @param item - item to display nested popover by + */ + protected showNestedPopoverForItem(item: WithChildren | WithChildren): PopoverDesktop { + const nestedPopover = super.showNestedPopoverForItem(item); + const nestedPopoverEl = nestedPopover.getElement(); + + /** + * We need to add class with nesting level, shich will help position nested popover. + * Currently only '.ce-popover--nested-level-1' class is used + */ + nestedPopoverEl.classList.add(css.getPopoverNestedClass(nestedPopover.nestingLevel)); + + return nestedPopover; + } + + /** + * Overrides default item click handling. + * Helps to close nested popover once other item is clicked. + * + * @param item - clicked item + */ + protected override handleItemClick(item: PopoverItem): void { + if (item !== this.nestedPopoverTriggerItem) { + /** + * In case tool had special handling for toggling button (like link tool which modifies selection) + * we need to call handleClick on nested popover trigger item + */ + this.nestedPopoverTriggerItem?.handleClick(); + + /** + * Then close the nested popover + */ + super.destroyNestedPopoverIfExists(); + } + + super.handleItemClick(item); + } +} diff --git a/src/components/utils/popover/popover-mobile.ts b/src/components/utils/popover/popover-mobile.ts index 2bf849ee5..fb599199a 100644 --- a/src/components/utils/popover/popover-mobile.ts +++ b/src/components/utils/popover/popover-mobile.ts @@ -48,6 +48,11 @@ export class PopoverMobile extends PopoverAbstract { enabled: false, }, }, + [PopoverItemType.Html]: { + hint: { + enabled: false, + }, + }, }); this.nodes.overlay = Dom.make('div', [css.overlay, css.overlayHidden]); diff --git a/src/components/utils/popover/popover.const.ts b/src/components/utils/popover/popover.const.ts index 2f6808124..bbd546fbe 100644 --- a/src/components/utils/popover/popover.const.ts +++ b/src/components/utils/popover/popover.const.ts @@ -21,5 +21,37 @@ export const css = { overlay: className('overlay'), overlayHidden: className('overlay', 'hidden'), popoverNested: className(null, 'nested'), + getPopoverNestedClass: (level: number) => className(null, `nested-level-${level.toString()}` ), + popoverInline: className(null, 'inline'), popoverHeader: className('header'), }; + +/** + * CSS variables names to be used in popover + */ +export enum CSSVariables { + /** + * Stores nesting level of the popover + */ + NestingLevel = '--nesting-level', + + /** + * Stores actual popover height. Used for desktop popovers + */ + PopoverHeight = '--popover-height', + + /** + * Width of the inline popover + */ + InlinePopoverWidth = '--inline-popover-width', + + /** + * Offset from left of the inline popover item click on which triggers the nested popover opening + */ + TriggerItemLeft = '--trigger-item-left', + + /** + * Offset from top of the desktop popover item click on which triggers the nested popover opening + */ + TriggerItemTop = '--trigger-item-top', +} diff --git a/src/components/utils/popover/popover.types.ts b/src/components/utils/popover/popover.types.ts index 9f1dcd730..8e28df71d 100644 --- a/src/components/utils/popover/popover.types.ts +++ b/src/components/utils/popover/popover.types.ts @@ -36,6 +36,7 @@ export interface PopoverParams { nestingLevel?: number; } + /** * Texts used inside popover */ @@ -54,7 +55,12 @@ export enum PopoverEvent { /** * When popover closes */ - Close = 'close' + Closed = 'closed', + + /** + * When it closes because item with 'closeOnActivate' property set was clicked + */ + ClosedOnActivate = 'closed-on-activate', } /** @@ -64,7 +70,13 @@ export interface PopoverEventMap { /** * Fired when popover closes */ - [PopoverEvent.Close]: undefined; + [PopoverEvent.Closed]: undefined; + + /** + * Fired when popover closes because item with 'closeOnActivate' property set was clicked + * Value is the item that was clicked + */ + [PopoverEvent.ClosedOnActivate]: undefined; } /** diff --git a/src/components/utils/tools.ts b/src/components/utils/tools.ts new file mode 100644 index 000000000..2defc4912 --- /dev/null +++ b/src/components/utils/tools.ts @@ -0,0 +1,18 @@ +import BlockTool from '../tools/block'; +import { isFunction, isString } from '../utils'; + +/** + * Check if tool has valid conversion config for export or import. + * + * @param tool - tool to check + * @param direction - export for tool to merge from, import for tool to merge to + */ +export function isToolConvertable(tool: BlockTool, direction: 'export' | 'import'): boolean { + if (!tool.conversionConfig) { + return false; + } + + const conversionProp = tool.conversionConfig[direction]; + + return isFunction(conversionProp) || isString(conversionProp); +} diff --git a/src/styles/conversion-toolbar.css b/src/styles/conversion-toolbar.css deleted file mode 100644 index 49e400de1..000000000 --- a/src/styles/conversion-toolbar.css +++ /dev/null @@ -1,96 +0,0 @@ -.ce-conversion-toolbar { - @apply --overlay-pane; - - opacity: 0; - visibility: hidden; - will-change: transform, opacity; - transition: transform 100ms ease, opacity 100ms ease; - transform: translateY(-8px); - left: -1px; - width: 190px; - margin-top: 5px; - box-sizing: content-box; - - &--showed { - opacity: 1; - visibility: visible; - transform: none; - } - - [hidden] { - display: none !important; - } - - &__buttons { - display: flex; - } - - &__label { - color: var(--grayText); - font-size: 11px; - font-weight: 500; - letter-spacing: 0.33px; - padding: 10px 10px 5px; - text-transform: uppercase; - } -} - -.ce-conversion-tool { - display: flex; - padding: 5px 10px; - font-size: 14px; - line-height: 20px; - font-weight: 500; - cursor: pointer; - align-items: center; - - &--hidden { - display: none; - } - - &--focused { - box-shadow: inset 0 0 0px 1px rgba(7, 161, 227, 0.08); - background: rgba(34, 186, 255, 0.08) !important; - - &-animated { - animation-name: buttonClicked; - animation-duration: 250ms; - } - } - - &:hover { - background: var(--bg-light); - } - - &__icon { - @apply --tool-icon; - } - - &--last { - margin-right: 0 !important; - } - - &--active { - color: var(--color-active-icon) !important; - } - - &--active { - animation: bounceIn 0.75s 1; - animation-fill-mode: forwards; - } - - &__secondary-label { - color: var(--grayText); - font-size: 12px; - margin-left: auto; - white-space: nowrap; - letter-spacing: -0.1em; - padding-right: 5px; - margin-bottom: -2px; - opacity: 0.6; - - @media (--mobile){ - display: none; - } - } -} diff --git a/src/styles/export.css b/src/styles/export.css index 3e3bb5243..6a9df2d53 100644 --- a/src/styles/export.css +++ b/src/styles/export.css @@ -39,10 +39,30 @@ * @deprecated - use tunes config instead of creating html element with controls */ .cdx-settings-button { - @apply --toolbar-button; + display: inline-flex; + align-items: center; + justify-content: center; + + border-radius: 3px; + cursor: pointer; + border: 0; + outline: none; + background-color: transparent; + vertical-align: bottom; + color: inherit; + margin: 0; min-width: var(--toolbox-buttons-size); min-height: var(--toolbox-buttons-size); + &--focused { + @apply --button-focused; + + &-animated { + animation-name: buttonClicked; + animation-duration: 250ms; + } + } + &--active { color: var(--color-active-icon); } @@ -50,6 +70,11 @@ svg { width: auto; height: auto; + + @media (--mobile) { + width: var(--icon-size--mobile); + height: var(--icon-size--mobile); + } } @media (--mobile) { @@ -57,6 +82,12 @@ height: var(--toolbox-buttons-size--mobile); border-radius: 8px; } + + @media (--can-hover) { + &:hover { + background-color: var(--bg-light); + } + } } /** diff --git a/src/styles/inline-toolbar.css b/src/styles/inline-toolbar.css index f77476949..0dd700213 100644 --- a/src/styles/inline-toolbar.css +++ b/src/styles/inline-toolbar.css @@ -1,19 +1,20 @@ .ce-inline-toolbar { --y-offset: 8px; + /** These variables duplicate the ones defined in popover. @todo move them to single place */ + --color-background-icon-active: rgba(56, 138, 229, 0.1); + --color-text-icon-active: #388AE5; + --color-text-primary: black; + @apply --overlay-pane; - opacity: 0; visibility: hidden; transition: opacity 250ms ease; will-change: opacity, left, top; top: 0; left: 0; z-index: 3; - - &--showed { - opacity: 1; - visibility: visible; - } + opacity: 1; + visibility: visible; [hidden] { display: none !important; @@ -69,11 +70,41 @@ } .ce-inline-tool { - @apply --toolbar-button; + color: var(--color-text-primary); + display: flex; + justify-content: center; + align-items: center; - border-radius: 0; + border: 0; + border-radius: 4px; line-height: normal; + height: 100%; + padding: 0; + width: 28px; + background-color: transparent; + cursor: pointer; + + @media (--mobile) { + width: 36px; + height: 36px; + } + + @media (--can-hover) { + &:hover { + background-color: #F8F8F8; /* @todo replace with 'var(--color-background-item-hover)' */ + } + } + svg { + display: block; + width: var(--icon-size); + height: var(--icon-size); + + @media (--mobile) { + width: var(--icon-size--mobile); + height: var(--icon-size--mobile); + } + } &--link { .icon--unlink { @@ -92,17 +123,20 @@ } &-input { + background: #F8F8F8; + border: 1px solid rgba(226,226,229,0.20); + border-radius: 6px; + padding: 4px 8px; + font-size: 14px; + line-height: 22px; + + outline: none; - border: 0; - border-radius: 0 0 4px 4px; margin: 0; - font-size: 13px; - padding: 10px; width: 100%; box-sizing: border-box; display: none; font-weight: 500; - border-top: 1px solid rgba(201,201,204,.48); -webkit-appearance: none; font-family: inherit; @@ -119,4 +153,9 @@ display: block; } } + + &--active { + background: var(--color-background-icon-active); + color: var(--color-text-icon-active); + } } diff --git a/src/styles/input.css b/src/styles/input.css index d4b5f8193..6fb03c3a2 100644 --- a/src/styles/input.css +++ b/src/styles/input.css @@ -1,7 +1,7 @@ .cdx-search-field { --icon-margin-right: 10px; - background: rgba(232,232,235,0.49); + background: #F8F8F8; border: 1px solid rgba(226,226,229,0.20); border-radius: 6px; padding: 2px; @@ -24,7 +24,6 @@ } } - &__input { font-size: 14px; outline: none; diff --git a/src/styles/main.css b/src/styles/main.css index 250b1a83c..38eb723d1 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -3,8 +3,6 @@ @import './toolbar.css'; @import './toolbox.css'; @import './inline-toolbar.css'; -@import './conversion-toolbar.css'; -@import './settings.css'; @import './block.css'; @import './animations.css'; @import './export.css'; @@ -12,4 +10,5 @@ @import './rtl.css'; @import './input.css'; @import './popover.css'; +@import './popover-inline.css'; diff --git a/src/styles/popover-inline.css b/src/styles/popover-inline.css new file mode 100644 index 000000000..f34dddc69 --- /dev/null +++ b/src/styles/popover-inline.css @@ -0,0 +1,143 @@ +/** + * Styles overrides for inline popover + */ +.ce-popover--inline { + --height: 38px; + --height-mobile: 46px; + --container-padding: 4px; + + position: relative; + + .ce-popover__custom-content { + margin-bottom: 0; + } + + .ce-popover__items { + display: flex; + } + + .ce-popover__container { + flex-direction: row; + padding: var(--container-padding); + height: var(--height); + top: 0; + + min-width: max-content; + width: max-content; + animation: none; + + @media (--mobile) { + height: var(--height-mobile); + position: absolute; + } + } + + /** + * Popover item styles + */ + .ce-popover-item-separator { + padding: 0 4px; + + &__line { + height: 100%; + width: 1px; + } + } + + .ce-popover-item { + border-radius: 4px; + padding: 4px; + + &__icon--tool { + box-shadow: none; + background: transparent; + margin-right: 0; + } + + &__icon { + width: unset; + height: unset; + + svg { + width: var(--icon-size); + height: var(--icon-size); + + @media (--mobile) { + width: var(--icon-size--mobile); + height: var(--icon-size--mobile); + } + } + } + + &:not(:last-of-type) { + margin-bottom: unset; + } + } + + .ce-popover-item-html { + display: flex; + align-items: center; + } + + .ce-popover-item__icon--chevron-right { + transform: rotate(90deg); + } + + .ce-popover--nested-level-1 { + .ce-popover__container { + --offset: 3px; + + left: 0px; + top: calc(var(--height) + var(--offset)); + + @media (--mobile) { + top: calc(var(--height-mobile) + var(--offset)); + } + } + } + + /** + * Nested popovers should look like regular desktop popovers, hence these overrides + */ + .ce-popover--nested { + .ce-popover__container { + min-width: var(--width); + width: var(--width); + height: fit-content; + padding: 6px; + flex-direction: column; + } + + .ce-popover__items { + display: block; + width: 100%; + } + + .ce-popover-item { + border-radius: 6px; + padding: 3px; + + @media (--mobile) { + padding: 4px; + } + + &__icon--tool { + margin-right: 4px; + } + + &__icon { + width: 26px; + height: 26px; + } + } + + .ce-popover-item-separator { + padding: 4px 3px; + + &__line { + width: 100%; + height: 1px; + } + } + } +} diff --git a/src/styles/popover.css b/src/styles/popover.css index c24a3db25..4c371a8ec 100644 --- a/src/styles/popover.css +++ b/src/styles/popover.css @@ -10,8 +10,8 @@ --max-height: 270px; --padding: 6px; --offset-from-target: 8px; - --color-border: #e8e8eb; - --color-shadow: rgba(13,20,33,0.13); + --color-border: #EFF0F1; + --color-shadow: rgba(13, 20, 33, 0.10); --color-background: white; --color-text-primary: black; --color-text-secondary: #707684; @@ -21,7 +21,7 @@ --color-background-icon-active: rgba(56, 138, 229, 0.1); --color-background-item-focus: rgba(34, 186, 255, 0.08); --color-shadow-item-focus: rgba(7, 161, 227, 0.08); - --color-background-item-hover: #eff2f5; + --color-background-item-hover: #F8F8F8; --color-background-item-confirm: #E24A4A; --color-background-item-confirm-hover: #CE4343; --popover-top: calc(100% + var(--offset-from-target)); @@ -39,7 +39,7 @@ border-radius: var(--border-radius); overflow: hidden; box-sizing: border-box; - box-shadow: 0 3px 15px -3px var(--color-shadow); + box-shadow: 0px 3px 15px -3px var(--color-shadow); position: absolute; left: var(--popover-left); top: var(--popover-top); @@ -57,7 +57,7 @@ } &--opened { - .ce-popover__container { + & > .ce-popover__container { opacity: 1; padding: var(--padding); max-height: var(--max-height); @@ -69,7 +69,6 @@ animation: panelShowingMobile 250ms ease; } } - } &--open-top { @@ -124,10 +123,6 @@ top: auto; border-radius: 10px; } - - .ce-popover__search { - display: none; - } } &__search { @@ -182,29 +177,27 @@ /** * Popover item styles */ +.ce-popover-item-separator { + padding: 4px 3px; - - .ce-popover-item-separator { - padding: 4px 3px; - - &--hidden { + &--hidden { display: none; - } + } - &__line { - height: 1px; - background: var(--color-border); - width: 100%; - } - } + &__line { + height: 1px; + background: var(--color-border); + width: 100%; + } +} - .ce-popover-item-html { +.ce-popover-item-html { &--hidden { display: none; } } - .ce-popover-item { +.ce-popover-item { --border-radius: 6px; border-radius: var(--border-radius); display: flex; @@ -212,6 +205,8 @@ padding: var(--item-padding); color: var(--color-text-primary); user-select: none; + border: none; + background: transparent; @media (--mobile) { padding: 4px; @@ -246,10 +241,7 @@ } &__icon--tool { - border-radius: 5px; - box-shadow: 0 0 0 1px var(--color-border-icon); - background: #fff; - margin-right: 10px; + margin-right: 4px; } &__title { @@ -274,7 +266,6 @@ white-space: nowrap; letter-spacing: -0.1em; padding-right: 5px; - margin-bottom: -2px; opacity: 0.6; @media (--mobile){ @@ -285,25 +276,16 @@ &--active { background: var(--color-background-icon-active); color: var(--color-text-icon-active); - - .ce-popover-item__icon { - box-shadow: none; - } } &--disabled { color: var(--color-text-secondary); cursor: default; pointer-events: none; - - .ce-popover-item__icon { - box-shadow: 0 0 0 1px var(--color-border-icon-disabled); - } } &--focused { &:not(.ce-popover-item--no-focus) { - box-shadow: inset 0 0 0px 1px var(--color-shadow-item-focus); background: var(--color-background-item-focus) !important; } } @@ -319,21 +301,14 @@ &:not(.ce-popover-item--no-hover) { background-color: var(--color-background-item-hover); } - - .ce-popover-item__icon { - box-shadow: none; - } } } &--confirmation { background: var(--color-background-item-confirm); + .ce-popover-item__title, .ce-popover-item__icon { - color: var(--color-background-item-confirm); - } - - .ce-popover-item__title { color: white; } @@ -352,13 +327,6 @@ background: var(--color-background-item-confirm-hover) !important; } } - - } - - &--confirmation, &--active, &--focused { - .ce-popover-item__icon { - box-shadow: none; - } } } diff --git a/src/styles/settings.css b/src/styles/settings.css deleted file mode 100644 index e15eaac5d..000000000 --- a/src/styles/settings.css +++ /dev/null @@ -1,33 +0,0 @@ -.ce-settings { - &__button { - @apply --toolbar-button; - - &:not(:nth-child(3n+3)) { - margin-right: 3px; - } - - &:nth-child(n+4) { - margin-top: 3px; - } - - line-height: 32px; - - &--disabled { - cursor: not-allowed !important; - opacity: .3; - } - - &--selected { - color: var(--color-active-icon); - } - } -} - -.codex-editor--narrow .ce-settings { - @media (--not-mobile){ - .ce-popover { - right: 0; - left: unset; - } - } -} diff --git a/src/styles/variables.css b/src/styles/variables.css index 284a7f6f4..61d8ab2b3 100644 --- a/src/styles/variables.css +++ b/src/styles/variables.css @@ -142,54 +142,6 @@ } }; - /** - * Styles for Settings Button in Toolbar - */ - --toolbar-button { - display: inline-flex; - align-items: center; - justify-content: center; - - padding: 6px 1px; - border-radius: 3px; - cursor: pointer; - border: 0; - outline: none; - background-color: transparent; - vertical-align: bottom; - color: inherit; - margin: 0; - - svg { - width: var(--icon-size); - height: var(--icon-size); - - @media (--mobile) { - width: var(--icon-size--mobile); - height: var(--icon-size--mobile); - } - } - - @media (--can-hover) { - &:hover { - background-color: var(--bg-light); - } - } - - &--active { - color: var(--color-active-icon); - } - - &--focused { - @apply --button-focused; - - &-animated { - animation-name: buttonClicked; - animation-duration: 250ms; - } - } - }; - /** * Tool icon with border */ diff --git a/src/tools/paragraph b/src/tools/paragraph new file mode 160000 index 000000000..6e45413cc --- /dev/null +++ b/src/tools/paragraph @@ -0,0 +1 @@ +Subproject commit 6e45413ccdfd021f1800eb6e5bf7440184d5ab7c diff --git a/src/types-internal/editor-modules.d.ts b/src/types-internal/editor-modules.d.ts index 1211247a5..3e455c349 100644 --- a/src/types-internal/editor-modules.d.ts +++ b/src/types-internal/editor-modules.d.ts @@ -18,7 +18,6 @@ import UiAPI from '../components/modules/api/ui'; /** ./toolbar */ import BlockSettings from '../components/modules/toolbar/blockSettings'; -import ConversionToolbar from '../components/modules/toolbar/conversion'; import Toolbar from '../components/modules/toolbar/index'; import InlineToolbar from '../components/modules/toolbar/inline'; @@ -37,11 +36,13 @@ import Renderer from '../components/modules/renderer'; import Saver from '../components/modules/saver'; import Tools from '../components/modules/tools'; import UI from '../components/modules/ui'; +import ToolsAPI from '../components/modules/api/tools'; export interface EditorModules { // API Modules BlocksAPI: BlocksAPI, CaretAPI: CaretAPI, + ToolsAPI: ToolsAPI, EventsAPI: EventsAPI, I18nAPI: I18nAPI, API: API, @@ -59,7 +60,6 @@ export interface EditorModules { // Toolbar Modules BlockSettings: BlockSettings, - ConversionToolbar: ConversionToolbar, Toolbar: Toolbar, InlineToolbar: InlineToolbar, @@ -78,4 +78,4 @@ export interface EditorModules { Saver: Saver, Tools: Tools, UI: UI, -} \ No newline at end of file +} diff --git a/test/cypress/tests/api/tools.cy.ts b/test/cypress/tests/api/tools.cy.ts index 38073fff2..85bfae7e3 100644 --- a/test/cypress/tests/api/tools.cy.ts +++ b/test/cypress/tests/api/tools.cy.ts @@ -39,11 +39,11 @@ describe('Editor Tools Api', () => { .click(); cy.get('[data-cy=editorjs]') - .get('div.ce-popover-item[data-item-name=testTool]') + .get('.ce-popover-item[data-item-name=testTool]') .should('have.length', 1); cy.get('[data-cy=editorjs]') - .get('div.ce-popover-item[data-item-name=testTool] .ce-popover-item__icon') + .get('.ce-popover-item[data-item-name=testTool] .ce-popover-item__icon') .should('contain.html', TestTool.toolbox.icon); }); @@ -84,16 +84,16 @@ describe('Editor Tools Api', () => { .click(); cy.get('[data-cy=editorjs]') - .get('div.ce-popover-item[data-item-name=testTool]') + .get('.ce-popover-item[data-item-name=testTool]') .should('have.length', 2); cy.get('[data-cy=editorjs]') - .get('div.ce-popover-item[data-item-name=testTool]') + .get('.ce-popover-item[data-item-name=testTool]') .first() .should('contain.text', TestTool.toolbox[0].title); cy.get('[data-cy=editorjs]') - .get('div.ce-popover-item[data-item-name=testTool]') + .get('.ce-popover-item[data-item-name=testTool]') .last() .should('contain.text', TestTool.toolbox[1].title); }); @@ -173,7 +173,7 @@ describe('Editor Tools Api', () => { .click(); cy.get('[data-cy=editorjs]') - .get('div.ce-popover-item[data-item-name=testTool]') + .get('.ce-popover-item[data-item-name=testTool]') .click(); cy.get('[data-cy=editorjs]') diff --git a/test/cypress/tests/block-ids.cy.ts b/test/cypress/tests/block-ids.cy.ts index 3f4b001c1..140f940fb 100644 --- a/test/cypress/tests/block-ids.cy.ts +++ b/test/cypress/tests/block-ids.cy.ts @@ -26,7 +26,7 @@ describe('Block ids', () => { .click(); cy.get('[data-cy=editorjs]') - .get('div.ce-popover-item[data-item-name=header]') + .get('.ce-popover-item[data-item-name=header]') .click(); cy.get('[data-cy=editorjs]') diff --git a/test/cypress/tests/modules/InlineToolbar.cy.ts b/test/cypress/tests/modules/InlineToolbar.cy.ts index bc14ef5f3..b2510c6c5 100644 --- a/test/cypress/tests/modules/InlineToolbar.cy.ts +++ b/test/cypress/tests/modules/InlineToolbar.cy.ts @@ -19,7 +19,7 @@ describe('Inline Toolbar', () => { .find('.ce-paragraph') .selectText('block'); - cy.get('[data-cy="inline-toolbar"]') + cy.get('[data-cy="inline-toolbar"] .ce-popover__container') .should('be.visible') .then(($toolbar) => { const editorWindow = $toolbar.get(0).ownerDocument.defaultView; @@ -27,8 +27,8 @@ describe('Inline Toolbar', () => { const range = selection.getRangeAt(0); const rect = range.getBoundingClientRect(); - - expect($toolbar.offset().left).to.closeTo(rect.left, 1); + + expect($toolbar.offset().left).to.be.closeTo(rect.left, 1); }); }); @@ -61,17 +61,17 @@ describe('Inline Toolbar', () => { .selectTextByOffset([firstLineWrapIndex - 5, firstLineWrapIndex - 1]); }); - cy.get('[data-cy="inline-toolbar"]') + cy.get('[data-cy="inline-toolbar"] .ce-popover__container') .should('be.visible') .then(($toolbar) => { cy.get('@blockWrapper') .then(($blockWrapper) => { const blockWrapperRect = $blockWrapper.get(0).getBoundingClientRect(); - + /** * Toolbar should be aligned with right side of text column */ - expect($toolbar.offset().left + $toolbar.width()).to.closeTo(blockWrapperRect.right, 3); + expect($toolbar.offset().left + $toolbar.width()).to.closeTo(blockWrapperRect.right, 9); }); }); }); @@ -100,11 +100,12 @@ describe('Inline Toolbar', () => { .find('.ce-paragraph') .selectText('Some text'); - cy.get('[data-cy=conversion-toggler]') + cy.get('[data-item-name=convert-to]') .click(); cy.get('[data-cy=editorjs]') - .find('.ce-conversion-tool[data-tool=header]') + .get('.ce-inline-toolbar') + .find('.ce-popover-item[data-item-name=header]') .click(); cy.get('[data-cy=editorjs]') diff --git a/test/cypress/tests/onchange.cy.ts b/test/cypress/tests/onchange.cy.ts index ef26d1bd8..b7dbbc170 100644 --- a/test/cypress/tests/onchange.cy.ts +++ b/test/cypress/tests/onchange.cy.ts @@ -218,7 +218,7 @@ describe('onChange callback', () => { .click(); cy.get('[data-cy=editorjs]') - .get('div.ce-popover-item[data-item-name=delimiter]') + .get('.ce-popover-item[data-item-name=delimiter]') .click(); cy.get('@onChange').should('be.calledWithBatchedEvents', [ @@ -264,7 +264,7 @@ describe('onChange callback', () => { .click(); cy.get('[data-cy=editorjs]') - .get('div.ce-popover-item[data-item-name=header]') + .get('.ce-popover-item[data-item-name=header]') .click(); cy.get('@onChange').should('be.calledWithBatchedEvents', [ @@ -341,12 +341,12 @@ describe('onChange callback', () => { .click(); cy.get('[data-cy=editorjs]') - .get('div[data-item-name=delete]') + .get('[data-item-name=delete]') .click(); /** Second click for confirmation */ cy.get('[data-cy=editorjs]') - .get('div[data-item-name=delete]') + .get('[data-item-name=delete]') .click(); cy.get('@onChange').should('be.calledWithBatchedEvents', [ @@ -397,7 +397,7 @@ describe('onChange callback', () => { .click(); cy.get('[data-cy=editorjs]') - .get('div[data-item-name=move-up]') + .get('[data-item-name=move-up]') .click(); cy.get('@onChange').should('be.calledWithMatch', EditorJSApiMock, Cypress.sinon.match({ diff --git a/test/cypress/tests/sanitisation.cy.ts b/test/cypress/tests/sanitisation.cy.ts index 1c5a83ef3..313e7085c 100644 --- a/test/cypress/tests/sanitisation.cy.ts +++ b/test/cypress/tests/sanitisation.cy.ts @@ -34,7 +34,7 @@ describe('Sanitizing', () => { .type('This text should be bold.{selectall}'); cy.get('[data-cy=editorjs]') - .get('button.ce-inline-tool--bold') + .get('[data-item-name="bold"]') .click(); cy.get('[data-cy=editorjs]') diff --git a/test/cypress/tests/tools/BlockTool.cy.ts b/test/cypress/tests/tools/BlockTool.cy.ts index 91688dcc3..29df97712 100644 --- a/test/cypress/tests/tools/BlockTool.cy.ts +++ b/test/cypress/tests/tools/BlockTool.cy.ts @@ -71,12 +71,8 @@ describe('BlockTool', () => { }, }, api: { - getMethodsForTool(): object { - return { - prop1: 'prop1', - prop2: 'prop2', - }; - }, + prop1: 'prop1', + prop2: 'prop2', }, isDefault: false, isInternal: false, @@ -580,7 +576,7 @@ describe('BlockTool', () => { it('should return Tool instance with passed API object', () => { const instance = tool.create(data, blockAPI as any, false) as any; - expect(instance.api).to.be.deep.eq(options.api.getMethodsForTool()); + expect(instance.api).to.be.deep.eq(options.api); }); it('should return Tool instance with passed config', () => { diff --git a/test/cypress/tests/tools/BlockTune.cy.ts b/test/cypress/tests/tools/BlockTune.cy.ts index 5ab8777cc..3c8c14fa0 100644 --- a/test/cypress/tests/tools/BlockTune.cy.ts +++ b/test/cypress/tests/tools/BlockTune.cy.ts @@ -36,12 +36,8 @@ describe('BlockTune', () => { shortcut: 'CMD+SHIFT+B', }, api: { - getMethodsForTool(): object { - return { - prop1: 'prop1', - prop2: 'prop2', - }; - }, + prop1: 'prop1', + prop2: 'prop2', }, isDefault: false, isInternal: false, @@ -165,7 +161,7 @@ describe('BlockTune', () => { it('should return Tool instance with passed API object', () => { const instance = tool.create(data, blockAPI as any) as any; - expect(instance.api).to.be.deep.eq(options.api.getMethodsForTool()); + expect(instance.api).to.be.deep.eq(options.api); }); it('should return Tool instance with passed settings', () => { diff --git a/test/cypress/tests/tools/InlineTool.cy.ts b/test/cypress/tests/tools/InlineTool.cy.ts index 3090af562..656d0f37e 100644 --- a/test/cypress/tests/tools/InlineTool.cy.ts +++ b/test/cypress/tests/tools/InlineTool.cy.ts @@ -43,12 +43,8 @@ describe('InlineTool', () => { shortcut: 'CMD+SHIFT+B', }, api: { - getMethodsForTool(): object { - return { - prop1: 'prop1', - prop2: 'prop2', - }; - }, + prop1: 'prop1', + prop2: 'prop2', }, isDefault: false, isInternal: false, @@ -187,7 +183,7 @@ describe('InlineTool', () => { it('should return Tool instance with passed API object', () => { const instance = tool.create() as any; - expect(instance.api).to.be.deep.eq(options.api.getMethodsForTool()); + expect(instance.api).to.be.deep.eq(options.api); }); it('should return Tool instance with passed config', () => { diff --git a/test/cypress/tests/tools/ToolsFactory.cy.ts b/test/cypress/tests/tools/ToolsFactory.cy.ts index bf6d9837b..7a5ee338e 100644 --- a/test/cypress/tests/tools/ToolsFactory.cy.ts +++ b/test/cypress/tests/tools/ToolsFactory.cy.ts @@ -28,7 +28,14 @@ describe('ToolsFactory', (): void => { placeholder: 'Placeholder', defaultBlock: 'paragraph', } as any, - {} as any + { + getMethodsForTool(): object { + return { + prop1: 'prop1', + prop2: 'prop2', + }; + } + } as any ); }); diff --git a/test/cypress/tests/ui/BlockTunes.cy.ts b/test/cypress/tests/ui/BlockTunes.cy.ts index 3ee359b51..d09370829 100644 --- a/test/cypress/tests/ui/BlockTunes.cy.ts +++ b/test/cypress/tests/ui/BlockTunes.cy.ts @@ -1,7 +1,7 @@ import { selectionChangeDebounceTimeout } from '../../../../src/components/constants'; import Header from '@editorjs/header'; import { ToolboxConfig } from '../../../../types'; -import { TunesMenuConfig } from '../../../../types/tools'; +import { MenuConfig } from '../../../../types/tools'; describe('BlockTunes', function () { @@ -381,10 +381,12 @@ describe('BlockTunes', function () { /** * */ - public renderSettings(): TunesMenuConfig { + public renderSettings(): MenuConfig { return { icon: 'Icon', title: 'Tune', + // eslint-disable-next-line @typescript-eslint/no-empty-function + onActivate: () => {} }; } diff --git a/test/cypress/tests/ui/InlineToolbar.cy.ts b/test/cypress/tests/ui/InlineToolbar.cy.ts new file mode 100644 index 000000000..d10ed29b9 --- /dev/null +++ b/test/cypress/tests/ui/InlineToolbar.cy.ts @@ -0,0 +1,175 @@ + +import Header from '@editorjs/header'; +import { MenuConfig } from '../../../../types/tools'; + +describe('Inline Toolbar', () => { + describe('Separators', () => { + it('should have a separator after the first item if it has children', () => { + cy.createEditor({ + tools: { + header: { + class: Header, + }, + }, + data: { + blocks: [ + { + type: 'paragraph', + data: { + text: 'First block text', + }, + }, + ], + }, + }); + + /** Open Inline Toolbar */ + cy.get('[data-cy=editorjs]') + .find('.ce-paragraph') + .selectText('block'); + + /** Check that first item (which is convert-to and has children) has a separator after it */ + cy.get('[data-cy=editorjs]') + .get('[data-cy=inline-toolbar] .ce-popover__items') + .children() + .first() + .should('have.attr', 'data-item-name', 'convert-to'); + + cy.get('[data-cy=editorjs]') + .get('[data-cy=inline-toolbar] .ce-popover__items') + .children() + .eq(1) + .should('have.class', 'ce-popover-item-separator'); + }); + + it('should have separators from both sides of item if it is in the middle and has children', () => { + cy.createEditor({ + tools: { + header: { + class: Header, + inlineToolbar: ['bold', 'testTool', 'link'] + + }, + testTool: { + class: class { + public static isInline = true; + public render(): MenuConfig { + return { + icon: 'n', + title: 'Test Tool', + name: 'test-tool', + children: { + items: [ + { + icon: 'm', + title: 'Test Tool Item', + // eslint-disable-next-line @typescript-eslint/no-empty-function + onActivate: () => {} + } + ] + } + }; + } + } + } + }, + data: { + blocks: [ + { + type: 'header', + data: { + text: 'First block text', + }, + }, + ], + }, + }); + + /** Open Inline Toolbar */ + cy.get('[data-cy=editorjs]') + .find('.ce-header') + .selectText('block'); + + /** Check that item with children is surrounded by separators */ + cy.get('[data-cy=editorjs]') + .get('[data-cy=inline-toolbar] .ce-popover__items') + .children() + .eq(3) + .should('have.class', 'ce-popover-item-separator'); + + cy.get('[data-cy=editorjs]') + .get('[data-cy=inline-toolbar] .ce-popover__items') + .children() + .eq(4) + .should('have.attr', 'data-item-name', 'test-tool'); + + cy.get('[data-cy=editorjs]') + .get('[data-cy=inline-toolbar] .ce-popover__items') + .children() + .eq(5) + .should('have.class', 'ce-popover-item-separator'); + }); + + it('should have separator before the item with children if it is the last of all items', () => { + cy.createEditor({ + tools: { + header: { + class: Header, + inlineToolbar: ['bold', 'testTool'] + + }, + testTool: { + class: class { + public static isInline = true; + public render(): MenuConfig { + return { + icon: 'n', + title: 'Test Tool', + name: 'test-tool', + children: { + items: [ + { + icon: 'm', + title: 'Test Tool Item', + // eslint-disable-next-line @typescript-eslint/no-empty-function + onActivate: () => {} + } + ] + } + }; + } + } + } + }, + data: { + blocks: [ + { + type: 'header', + data: { + text: 'First block text', + }, + }, + ], + }, + }); + + /** Open Inline Toolbar */ + cy.get('[data-cy=editorjs]') + .find('.ce-header') + .selectText('block'); + + /** Check that item with children is surrounded by separators */ + cy.get('[data-cy=editorjs]') + .get('[data-cy=inline-toolbar] .ce-popover__items') + .children() + .eq(3) + .should('have.class', 'ce-popover-item-separator'); + + cy.get('[data-cy=editorjs]') + .get('[data-cy=inline-toolbar] .ce-popover__items') + .children() + .eq(4) + .should('have.attr', 'data-item-name', 'test-tool'); + }); + }); +}); diff --git a/test/cypress/tests/utils/popover.cy.ts b/test/cypress/tests/utils/popover.cy.ts index 1fe4f3885..49fa92901 100644 --- a/test/cypress/tests/utils/popover.cy.ts +++ b/test/cypress/tests/utils/popover.cy.ts @@ -1,6 +1,7 @@ import { PopoverDesktop as Popover, PopoverItemType } from '../../../../src/components/utils/popover'; import { PopoverItemParams } from '../../../../types'; -import { TunesMenuConfig } from '../../../../types/tools'; +import { MenuConfig } from '../../../../types/tools'; +import Header from '@editorjs/header'; /* eslint-disable @typescript-eslint/no-empty-function */ @@ -118,6 +119,7 @@ describe('Popover', () => { if (items[0].type !== PopoverItemType.Default) { return; } + // Check onActivate callback has never been called expect(items[0].onActivate).to.have.not.been.called; }); @@ -395,7 +397,7 @@ describe('Popover', () => { public static isTune = true; /** Tool data displayed in block tunes popover */ - public render(): TunesMenuConfig { + public render(): MenuConfig { return { icon: 'Icon', title: 'Title', @@ -407,6 +409,7 @@ describe('Popover', () => { icon: 'Icon', title: 'Title', name: 'nested-test-item', + onActivate: (): void => {}, }, ], }, @@ -464,14 +467,13 @@ describe('Popover', () => { .should('be.visible'); }); - it('should display children items, back button and item header and correctly switch between parent and child states (mobile)', () => { /** Tool class to test how it is displayed inside block tunes popover */ class TestTune { public static isTune = true; /** Tool data displayed in block tunes popover */ - public render(): TunesMenuConfig { + public render(): MenuConfig { return { icon: 'Icon', title: 'Tune', @@ -483,6 +485,7 @@ describe('Popover', () => { icon: 'Icon', title: 'Title', name: 'nested-test-item', + onActivate: (): void => {}, }, ], }, @@ -579,7 +582,7 @@ describe('Popover', () => { public static isTune = true; /** Tool data displayed in block tunes popover */ - public render(): TunesMenuConfig { + public render(): MenuConfig { return { onActivate: (): void => {}, icon: 'Icon', @@ -630,7 +633,7 @@ describe('Popover', () => { public static isTune = true; /** Tool data displayed in block tunes popover */ - public render(): TunesMenuConfig { + public render(): MenuConfig { return [ { onActivate: (): void => {}, @@ -693,7 +696,7 @@ describe('Popover', () => { public static isTune = true; /** Tool data displayed in block tunes popover */ - public render(): TunesMenuConfig { + public render(): MenuConfig { return [ { onActivate: (): void => {}, @@ -780,7 +783,7 @@ describe('Popover', () => { public static isTune = true; /** Tool data displayed in block tunes popover */ - public render(): TunesMenuConfig { + public render(): MenuConfig { return [ { onActivate: (): void => {}, @@ -877,4 +880,169 @@ describe('Popover', () => { .get('[data-item-name="test-item-2"].ce-popover-item--focused') .should('exist'); }); + + describe('Inline Popover', () => { + it('should open nested popover on click instead of hover', () => { + cy.createEditor({ + tools: { + header: { + class: Header, + }, + }, + data: { + blocks: [ + { + type: 'paragraph', + data: { + text: 'First block text', + }, + }, + ], + }, + }); + + /** Open Inline Toolbar */ + cy.get('[data-cy=editorjs]') + .find('.ce-paragraph') + .selectText('block'); + + /** Hover Convert To item which has nested popover */ + cy.get('[data-cy=editorjs]') + .get('[data-item-name=convert-to]') + .trigger('mouseover'); + + /** Check nested popover didn't open */ + cy.get('[data-cy=editorjs]') + .get('.ce-popover--nested .ce-popover__container') + .should('not.exist'); + + /** Click Convert To item which has nested popover */ + cy.get('[data-cy=editorjs]') + .get('[data-item-name=convert-to]') + .click(); + + /** Check nested popover opened */ + cy.get('[data-cy=editorjs]') + .get('.ce-popover--nested .ce-popover__container') + .should('exist'); + }); + + it('should support keyboard nevigation between items', () => { + cy.createEditor({ + tools: { + header: { + class: Header, + }, + }, + data: { + blocks: [ + { + type: 'paragraph', + data: { + text: 'First block text', + }, + }, + ], + }, + }); + + /** Open Inline Toolbar */ + cy.get('[data-cy=editorjs]') + .find('.ce-paragraph') + .selectText('block'); + + /** Check Inline Popover opened */ + cy.get('[data-cy=editorjs]') + .get('.ce-inline-toolbar .ce-popover__container') + .should('be.visible'); + + /** Check first item is NOT focused */ + cy.get('[data-cy=editorjs]') + .get('.ce-inline-toolbar .ce-popover__container') + .get('[data-item-name="convert-to"].ce-popover-item--focused') + .should('not.exist'); + + /** Press Tab */ + cy.tab(); + + /** Check first item became focused after tab */ + cy.get('[data-cy=editorjs]') + .get('.ce-inline-toolbar .ce-popover__container') + .get('[data-item-name="convert-to"].ce-popover-item--focused') + .should('exist'); + + /** Check second item is NOT focused */ + cy.get('[data-cy=editorjs]') + .get('.ce-inline-toolbar .ce-popover__container') + .get('[data-item-name="link"] .ce-popover-item--focused') + .should('not.exist'); + + /** Press Tab */ + cy.tab(); + + /** Check second item became focused after tab */ + cy.get('[data-cy=editorjs]') + .get('.ce-inline-toolbar .ce-popover__container') + .get('[data-item-name="link"] .ce-popover-item--focused') + .should('exist'); + }); + + it.only('should allow to reach nested popover via keyboard', () => { + cy.createEditor({ + tools: { + header: { + class: Header, + }, + }, + data: { + blocks: [ + { + type: 'paragraph', + data: { + text: 'First block text', + }, + }, + ], + }, + }); + + /** Open Inline Toolbar */ + cy.get('[data-cy=editorjs]') + .find('.ce-paragraph') + .selectText('block'); + + /** Check Inline Popover opened */ + cy.get('[data-cy=editorjs]') + .get('.ce-inline-toolbar .ce-popover__container') + .should('be.visible'); + + /** Press Tab */ + cy.tab(); + + /** Press Tab */ + cy.get('[data-item-name="convert-to"]') + .type('{enter}'); + + /** Check Inline Popover opened */ + cy.get('[data-cy=editorjs]') + .get('.ce-inline-toolbar .ce-popover--nested .ce-popover__container') + .should('be.visible'); + + /** Check first item is NOT focused */ + cy.get('[data-cy=editorjs]') + .get('.ce-popover__container') + .get('[data-item-name="header"].ce-popover-item--focused') + .should('not.exist'); + + /** Press Tab */ + // eslint-disable-next-line cypress/require-data-selectors -- cy.tab() not working here + cy.get('body').tab(); + + /** Check first item is focused */ + cy.get('[data-cy=editorjs]') + .get('.ce-popover__container') + .get('[data-item-name="header"].ce-popover-item--focused') + .should('exist'); + }); + }); }); diff --git a/types/api/block.d.ts b/types/api/block.d.ts index f44d67388..7b3131c5e 100644 --- a/types/api/block.d.ts +++ b/types/api/block.d.ts @@ -1,4 +1,4 @@ -import {BlockToolData, ToolConfig} from '../tools'; +import {BlockToolData, ToolConfig, ToolboxConfigEntry} from '../tools'; import {SavedData} from '../data-formats'; /** @@ -78,4 +78,10 @@ export interface BlockAPI { * Can be useful for block changes invisible for editor core. */ dispatchChange(): void; + + /** + * Tool could specify several entries to be displayed at the Toolbox (for example, "Heading 1", "Heading 2", "Heading 3") + * This method returns the entry that is related to the Block (depended on the Block data) + */ + getActiveToolboxEntry(): Promise } diff --git a/types/api/blocks.d.ts b/types/api/blocks.d.ts index fd05be104..5085e99b8 100644 --- a/types/api/blocks.d.ts +++ b/types/api/blocks.d.ts @@ -1,3 +1,4 @@ +import Block from '../../src/components/block'; import {OutputBlockData, OutputData} from '../data-formats/output-data'; import {BlockToolData, ToolConfig} from '../tools'; import {BlockAPI} from './block'; @@ -71,6 +72,13 @@ export interface Blocks { */ getBlockIndex(blockId: string): number; + /** + * Get Block API object by html element + * + * @param element - html element to get Block by + */ + getBlockByElement(element: HTMLElement): BlockAPI | undefined; + /** * Mark Block as stretched * @param {number} index - Block to mark diff --git a/types/api/index.d.ts b/types/api/index.d.ts index 9df8461b7..2b6f51a33 100644 --- a/types/api/index.d.ts +++ b/types/api/index.d.ts @@ -14,3 +14,4 @@ export * from './block'; export * from './readonly'; export * from './i18n'; export * from './ui'; +export * from './tools'; diff --git a/types/api/selection.d.ts b/types/api/selection.d.ts index ba7b2bd77..606d03733 100644 --- a/types/api/selection.d.ts +++ b/types/api/selection.d.ts @@ -15,4 +15,27 @@ export interface Selection { * @param {HTMLElement} node - tag that should contain selection */ expandToTag(node: HTMLElement): void; + + /** + * Sets fake background. + * Allows to immitate selection while focus moved away + */ + setFakeBackground(): void; + + /** + * Removes fake background + */ + removeFakeBackground(): void; + + /** + * Save selection range. + * Allows to save selection to be able to temporally move focus away. + * Might be usefull for inline tools + */ + save(): void; + + /** + * Restore saved selection range + */ + restore(): void; } diff --git a/types/api/tools.d.ts b/types/api/tools.d.ts new file mode 100644 index 000000000..c578451f7 --- /dev/null +++ b/types/api/tools.d.ts @@ -0,0 +1,11 @@ +import BlockTool from "../../src/components/tools/block"; + +/** + * Describes methods for accessing installed Editor tools + */ +export interface Tools { + /** + * Returns all available Block Tools + */ + getBlockTools(): BlockTool[]; +} diff --git a/types/block-tunes/block-tune.d.ts b/types/block-tunes/block-tune.d.ts index 70169d82e..9ef43d306 100644 --- a/types/block-tunes/block-tune.d.ts +++ b/types/block-tunes/block-tune.d.ts @@ -1,15 +1,16 @@ import {API, BlockAPI, SanitizerConfig, ToolConfig} from '../index'; import { BlockTuneData } from './block-tune-data'; -import { TunesMenuConfig } from '../tools'; +import { MenuConfig } from '../tools'; /** * Describes BLockTune blueprint */ export interface BlockTune { /** - * Returns block tune HTMLElement + * Returns BlockTune's UI. + * Either HTMLELement (@deprecated) or MenuConfig (@see https://editorjs.io/menu-config/) */ - render(): HTMLElement | TunesMenuConfig; + render(): HTMLElement | MenuConfig; /** * Method called on Tool render. Pass Tool content as an argument. diff --git a/types/index.d.ts b/types/index.d.ts index fc38802b9..1a7e83bf0 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -28,6 +28,7 @@ import { Tooltip, I18n, Ui, + Tools, } from './api'; import { OutputData } from './data-formats'; @@ -82,8 +83,6 @@ export { export { PopoverItemParams, PopoverItemDefaultParams, - PopoverItemWithConfirmationParams, - PopoverItemWithoutConfirmationParams } from '../src/components/utils/popover'; export { OutputData, OutputBlockData} from './data-formats/output-data'; @@ -110,6 +109,7 @@ export { export interface API { blocks: Blocks; caret: Caret; + tools: Tools; events: Events; listeners: Listeners; notifier: Notifier; diff --git a/types/tools/block-tool.d.ts b/types/tools/block-tool.d.ts index bac653226..ae02161b5 100644 --- a/types/tools/block-tool.d.ts +++ b/types/tools/block-tool.d.ts @@ -5,7 +5,7 @@ import { ToolConfig } from './tool-config'; import { API, BlockAPI, ToolboxConfig } from '../index'; import { PasteEvent } from './paste-events'; import { MoveEvent } from './hook-events'; -import { TunesMenuConfig } from './tool-settings'; +import { MenuConfig } from './menu-config'; /** * Describe Block Tool object @@ -27,7 +27,7 @@ export interface BlockTool extends BaseTool { /** * Create Block's settings block */ - renderSettings?(): HTMLElement | TunesMenuConfig; + renderSettings?(): HTMLElement | MenuConfig; /** * Validate Block's data diff --git a/types/tools/index.d.ts b/types/tools/index.d.ts index b50dd40e2..7bfefb984 100644 --- a/types/tools/index.d.ts +++ b/types/tools/index.d.ts @@ -10,6 +10,7 @@ export * from './tool-config'; export * from './tool-settings'; export * from './paste-events'; export * from './hook-events'; +export * from './menu-config'; export type Tool = BlockTool | InlineTool | BlockTune; export type ToolConstructable = BlockToolConstructable | InlineToolConstructable | BlockTuneConstructable; diff --git a/types/tools/inline-tool.d.ts b/types/tools/inline-tool.d.ts index 00b96e272..170779b0b 100644 --- a/types/tools/inline-tool.d.ts +++ b/types/tools/inline-tool.d.ts @@ -1,9 +1,10 @@ import {BaseTool, BaseToolConstructable} from './tool'; import {API, ToolConfig} from '../index'; +import { MenuConfig } from './menu-config'; /** * Base structure for the Inline Toolbar Tool */ -export interface InlineTool extends BaseTool { +export interface InlineTool extends BaseTool { /** * Shortcut for Tool * @type {string} @@ -13,19 +14,22 @@ export interface InlineTool extends BaseTool { /** * Method that accepts selected range and wrap it somehow * @param {Range} range - selection's range + * @deprecated use {@link MenuConfig} item onActivate property instead */ - surround(range: Range): void; + surround?(range: Range): void; /** * Get SelectionUtils and detect if Tool was applied * For example, after that Tool can highlight button or show some details * @param {Selection} selection - current Selection + * @deprecated use {@link MenuConfig} item isActive property instead */ - checkState(selection: Selection): boolean; + checkState?(selection: Selection): boolean; /** * Make additional element with actions * For example, input for the 'link' tool or textarea for the 'comment' tool + * @deprecated use {@link MenuConfig} item children to set item actions instead */ renderActions?(): HTMLElement; diff --git a/types/tools/menu-config.d.ts b/types/tools/menu-config.d.ts new file mode 100644 index 000000000..8f616ef25 --- /dev/null +++ b/types/tools/menu-config.d.ts @@ -0,0 +1,54 @@ +import { PopoverItemDefaultBaseParams, PopoverItemHtmlParams, PopoverItemParams, PopoverItemSeparatorParams, WithChildren } from "../configs"; + +/** + * Menu configuration format. + * Is used for defining Block Tunes Menu items via Block Tool's renderSettings(), Block Tune's render() and Inline Tool's render(). + */ +export type MenuConfig = MenuConfigItem | MenuConfigItem[]; + +/** + * Common parameters for all kinds of default Menu Config items: with or without confirmation + */ +type MenuConfigDefaultBaseParams = PopoverItemDefaultBaseParams & { + /** + * Displayed text. + * Alias for title property + * + * @deprecated - use title property instead + */ + label?: string +}; + +/** + * Menu Config item with confirmation + */ +type MenuConfigItemDefaultWithConfirmationParams = Omit & { + /** + * Items with confirmation should not have onActivate handler + */ + onActivate?: never; + + /** + * Menu Config item parameters that should be applied on item activation. + * May be used to ask user for confirmation before executing item activation handler. + */ + confirmation: MenuConfigDefaultBaseParams; + +} + +/** + * Default, non-separator and non-html Menu Config items type + */ +type MenuConfigItemDefaultParams = + MenuConfigItemDefaultWithConfirmationParams | + MenuConfigDefaultBaseParams | + WithChildren; + +/** + * Single Menu Config item + */ +type MenuConfigItem = + MenuConfigItemDefaultParams | + PopoverItemSeparatorParams | + PopoverItemHtmlParams | + WithChildren; diff --git a/types/tools/tool-settings.d.ts b/types/tools/tool-settings.d.ts index 021ec4093..67935c11e 100644 --- a/types/tools/tool-settings.d.ts +++ b/types/tools/tool-settings.d.ts @@ -1,6 +1,5 @@ import { ToolConfig } from './tool-config'; -import { ToolConstructable, BlockToolData } from './index'; -import { PopoverItemDefaultParams, PopoverItemSeparatorParams, PopoverItemHtmlParams } from '../configs'; +import { ToolConstructable, BlockToolData, MenuConfig, MenuConfigItem } from './index'; /** * Tool may specify its toolbox configuration @@ -28,51 +27,6 @@ export interface ToolboxConfigEntry { data?: BlockToolData } -/** - * Represents single interactive (non-separator) Tunes Menu item - */ -export type TunesMenuConfigDefaultItem = PopoverItemDefaultParams & { - /** - * Tune displayed text. - */ - title?: string; - - /** - * Tune displayed text. - * Alias for title property - * - * @deprecated - use title property instead - */ - label?: string - - /** - * Menu item parameters that should be applied on item activation. - * May be used to ask user for confirmation before executing menu item activation handler. - */ - confirmation?: TunesMenuConfigDefaultItem; -} - -/** - * Represents single separator Tunes Menu item - */ -export type TunesMenuConfigSeparatorItem = PopoverItemSeparatorParams; - -/** - * Represents single Tunes Menu item with custom HTML contect - */ -export type TunesMenuConfigHtmlItem = PopoverItemHtmlParams; - -/** - * Union of all Tunes Menu item types - */ -export type TunesMenuConfigItem = TunesMenuConfigDefaultItem | TunesMenuConfigSeparatorItem | TunesMenuConfigHtmlItem; - -/** - * Tool may specify its tunes configuration - * that can contain either one or multiple entries - */ -export type TunesMenuConfig = TunesMenuConfigItem | TunesMenuConfigItem[]; - /** * Object passed to the Tool's constructor by {@link EditorConfig#tools} * @@ -114,6 +68,18 @@ export interface ExternalToolSettings { toolbox?: ToolboxConfig | false; } +/** + * Tool's tunes configuration. + * @deprecated use {@link MenuConfig} type instead + */ +export type TunesMenuConfig = MenuConfig; + +/** + * Single Tunes Menu Config item + * @deprecated use {@link MenuConfigItem} type instead + */ +export type TunesMenuConfigItem = MenuConfigItem; + /** * For internal Tools 'class' property is optional */ diff --git a/types/tools/tool.d.ts b/types/tools/tool.d.ts index bd18fc239..184000eba 100644 --- a/types/tools/tool.d.ts +++ b/types/tools/tool.d.ts @@ -1,17 +1,21 @@ import {API} from '../index'; import {ToolConfig} from './tool-config'; import {SanitizerConfig} from '../configs'; +import {MenuConfig} from './menu-config'; /** * Abstract interface of all Tools */ -export interface BaseTool { +export interface BaseTool { /** * Tool`s render method - * For inline Tools returns inline toolbar button - * For block Tools returns tool`s wrapper + * + * For Inline Tools may return either HTMLElement (deprecated) or {@link MenuConfig} + * @see https://editorjs.io/menu-config + * + * For Block Tools returns tool`s wrapper html element */ - render(): HTMLElement; + render(): RenderReturnType | Promise; } export interface BaseToolConstructable { @@ -26,7 +30,8 @@ export interface BaseToolConstructable { sanitize?: SanitizerConfig; /** - * Title of Inline Tool + * Title of Inline Tool. + * @deprecated use {@link MenuConfig} item title instead */ title?: string; From e631597ee9292416095888e9fe5dc89cf88a8c8f Mon Sep 17 00:00:00 2001 From: Peter Savchenko Date: Sun, 30 Jun 2024 23:33:45 +0300 Subject: [PATCH 33/61] chore(submodules): update submodules (#2752) * Remove stale submodule src/tools/paragraph * update modules --- example/tools/checklist | 2 +- example/tools/code | 2 +- example/tools/delimiter | 2 +- example/tools/embed | 2 +- example/tools/header | 2 +- example/tools/image | 2 +- example/tools/inline-code | 2 +- example/tools/link | 2 +- example/tools/list | 2 +- example/tools/marker | 2 +- example/tools/nested-list | 2 +- example/tools/quote | 2 +- example/tools/raw | 2 +- example/tools/table | 2 +- example/tools/text-variant-tune | 2 +- example/tools/warning | 2 +- src/tools/paragraph | 1 - 17 files changed, 16 insertions(+), 17 deletions(-) delete mode 160000 src/tools/paragraph diff --git a/example/tools/checklist b/example/tools/checklist index b1367277e..1c116d5e0 160000 --- a/example/tools/checklist +++ b/example/tools/checklist @@ -1 +1 @@ -Subproject commit b1367277e070bbbf80b7b14b1963845ba9a71d8c +Subproject commit 1c116d5e09e19951948d6166047aa2f30877aaf9 diff --git a/example/tools/code b/example/tools/code index 193f5f6f0..f281996f8 160000 --- a/example/tools/code +++ b/example/tools/code @@ -1 +1 @@ -Subproject commit 193f5f6f00288679a97bfe620a4d811e5acd9b16 +Subproject commit f281996f82c7ac676172757e45687cae27443427 diff --git a/example/tools/delimiter b/example/tools/delimiter index 86e8c5501..4ca1c1c97 160000 --- a/example/tools/delimiter +++ b/example/tools/delimiter @@ -1 +1 @@ -Subproject commit 86e8c5501dcbb8eaaeec756e1145db49b8339160 +Subproject commit 4ca1c1c972261f47dd34f6b8754763a4a79a4866 diff --git a/example/tools/embed b/example/tools/embed index 23de06be6..f2585abb9 160000 --- a/example/tools/embed +++ b/example/tools/embed @@ -1 +1 @@ -Subproject commit 23de06be69bb9e636a2278b0d54f8c2d85d7ae13 +Subproject commit f2585abb9019abf93c18f1dcfa63b07a3dd08318 diff --git a/example/tools/header b/example/tools/header index 5118ce87a..477853c16 160000 --- a/example/tools/header +++ b/example/tools/header @@ -1 +1 @@ -Subproject commit 5118ce87a752515fb6b31325f234f4ccd62f42c9 +Subproject commit 477853c1646ae479867603847e49071438ffd80c diff --git a/example/tools/image b/example/tools/image index 72c651da4..25d46cd8d 160000 --- a/example/tools/image +++ b/example/tools/image @@ -1 +1 @@ -Subproject commit 72c651da48cf15907de155ce987606e062caaf0a +Subproject commit 25d46cd8d3930851b14ddc26ee80fb5b485e1496 diff --git a/example/tools/inline-code b/example/tools/inline-code index 7cc94718e..dcd4c1774 160000 --- a/example/tools/inline-code +++ b/example/tools/inline-code @@ -1 +1 @@ -Subproject commit 7cc94718e4c20d6f9db2c236a60b119c39d389e0 +Subproject commit dcd4c17740c9ba636140751596aff1e9f6ef6b01 diff --git a/example/tools/link b/example/tools/link index 861de29b1..aaa69d540 160000 --- a/example/tools/link +++ b/example/tools/link @@ -1 +1 @@ -Subproject commit 861de29b1d553bb9377dcbaf451af605b28b57bd +Subproject commit aaa69d5408bad34027d6252a3892d40f9fa121be diff --git a/example/tools/list b/example/tools/list index f0e9f0110..a6dc6a692 160000 --- a/example/tools/list +++ b/example/tools/list @@ -1 +1 @@ -Subproject commit f0e9f0110983cd973a1345f2885b18db4fd54636 +Subproject commit a6dc6a692b88c9eff3d87223b239e7517b160c67 diff --git a/example/tools/marker b/example/tools/marker index 13e0b1cf7..8d6897fca 160000 --- a/example/tools/marker +++ b/example/tools/marker @@ -1 +1 @@ -Subproject commit 13e0b1cf72cfa706dc236e617683a5e349a021f5 +Subproject commit 8d6897fca43e387bcdf4a681380be975fe8f2a07 diff --git a/example/tools/nested-list b/example/tools/nested-list index 53a12f3d8..95b37462d 160000 --- a/example/tools/nested-list +++ b/example/tools/nested-list @@ -1 +1 @@ -Subproject commit 53a12f3d816630e071ef8230c4a5870a7c0d0551 +Subproject commit 95b37462dc93c19b83f0481f509034a40d436cf2 diff --git a/example/tools/quote b/example/tools/quote index 02e0db32a..9377ca713 160000 --- a/example/tools/quote +++ b/example/tools/quote @@ -1 +1 @@ -Subproject commit 02e0db32a101ec5cfa61210de45be7de647c40c6 +Subproject commit 9377ca713f552576b8b11f77cf371b67261ec00b diff --git a/example/tools/raw b/example/tools/raw index b4164eac4..cae470fde 160000 --- a/example/tools/raw +++ b/example/tools/raw @@ -1 +1 @@ -Subproject commit b4164eac4d81259a15368d7681884e3554554662 +Subproject commit cae470fded570ef9a82a45734526ccf45959e204 diff --git a/example/tools/table b/example/tools/table index b6290a2c4..2948cd759 160000 --- a/example/tools/table +++ b/example/tools/table @@ -1 +1 @@ -Subproject commit b6290a2c4c668476d5b83c454e1bf04487f317cf +Subproject commit 2948cd7595e632f7555e2dc09e6bac050a2b87ea diff --git a/example/tools/text-variant-tune b/example/tools/text-variant-tune index 02538b1da..a80eefb40 160000 --- a/example/tools/text-variant-tune +++ b/example/tools/text-variant-tune @@ -1 +1 @@ -Subproject commit 02538b1daea722c854cc61b6fbca01b746c21717 +Subproject commit a80eefb4007c85a52085897398dd28afc5bfd81f diff --git a/example/tools/warning b/example/tools/warning index e2726a7b3..e63e91aa8 160000 --- a/example/tools/warning +++ b/example/tools/warning @@ -1 +1 @@ -Subproject commit e2726a7b301c960d318aa1ec73bac97f474e3d68 +Subproject commit e63e91aa833d774be9bf4a76013b1025a009989d diff --git a/src/tools/paragraph b/src/tools/paragraph deleted file mode 160000 index 6e45413cc..000000000 --- a/src/tools/paragraph +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 6e45413ccdfd021f1800eb6e5bf7440184d5ab7c From 1c88d526de60e181bedf4f1a734ffa8a379ed124 Mon Sep 17 00:00:00 2001 From: Peter Savchenko Date: Mon, 1 Jul 2024 21:10:17 +0300 Subject: [PATCH 34/61] chore(toolbar): improve aligning with headings (#2748) * chore(toolbar): improve aligning with headings * fix eslint * Update index.ts * stash * toolbar aligning improved * improve case 2.1 * close toolbar after conversion * rm submodules change * Update index.html * improve util method * Update index.ts --- docs/CHANGELOG.md | 1 + src/components/dom.ts | 45 +++++++++++ .../modules/toolbar/blockSettings.ts | 4 +- src/components/modules/toolbar/index.ts | 75 ++++++++++++++++--- .../cypress/fixtures/tools/ContentlessTool.ts | 2 +- 5 files changed, 115 insertions(+), 12 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index dfc062f8e..eb1ef24f0 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -27,6 +27,7 @@ - `New` – *BlocksAPI* – Exposed `getBlockByElement()` method that helps find block by any child html element – `Fix` — Deleting whitespaces at the start/end of the block – `Improvement` — *Types* — `BlockToolConstructorOptions` type improved, `block` and `config` are not optional anymore +- `Improvement` - The Plus button and Block Tunes toggler are now better aligned with large line-height blocks, such as Headings ### 2.29.1 diff --git a/src/components/dom.ts b/src/components/dom.ts index c52938ae3..0dc2e19e1 100644 --- a/src/components/dom.ts +++ b/src/components/dom.ts @@ -617,3 +617,48 @@ export function isCollapsedWhitespaces(textContent: string): boolean { */ return !/[^\t\n\r ]/.test(textContent); } + +/** + * Calculates the Y coordinate of the text baseline from the top of the element's margin box, + * + * The calculation formula is as follows: + * + * 1. Calculate the baseline offset: + * - Typically, the baseline is about 80% of the `fontSize` from the top of the text, as this is a common average for many fonts. + * + * 2. Calculate the additional space due to `lineHeight`: + * - If the `lineHeight` is greater than the `fontSize`, the extra space is evenly distributed above and below the text. This extra space is `(lineHeight - fontSize) / 2`. + * + * 3. Calculate the total baseline Y coordinate: + * - Sum of `marginTop`, `borderTopWidth`, `paddingTop`, the extra space due to `lineHeight`, and the baseline offset. + * + * @param element - The element to calculate the baseline for. + * @returns {number} - The Y coordinate of the text baseline from the top of the element's margin box. + */ +export function calculateBaseline(element: Element): number { + const style = window.getComputedStyle(element); + const fontSize = parseFloat(style.fontSize); + // eslint-disable-next-line @typescript-eslint/no-magic-numbers + const lineHeight = parseFloat(style.lineHeight) || fontSize * 1.2; // default line-height if not set + const paddingTop = parseFloat(style.paddingTop); + const borderTopWidth = parseFloat(style.borderTopWidth); + const marginTop = parseFloat(style.marginTop); + + /** + * Typically, the baseline is about 80% of the `fontSize` from the top of the text, as this is a common average for many fonts. + */ + // eslint-disable-next-line @typescript-eslint/no-magic-numbers + const baselineOffset = fontSize * 0.8; + + /** + * If the `lineHeight` is greater than the `fontSize`, the extra space is evenly distributed above and below the text. This extra space is `(lineHeight - fontSize) / 2`. + */ + const extraLineHeight = (lineHeight - fontSize) / 2; + + /** + * Calculate the total baseline Y coordinate from the top of the margin box + */ + const baselineY = marginTop + borderTopWidth + paddingTop + extraLineHeight + baselineOffset; + + return baselineY; +} diff --git a/src/components/modules/toolbar/blockSettings.ts b/src/components/modules/toolbar/blockSettings.ts index 828850d09..7f3a67fe0 100644 --- a/src/components/modules/toolbar/blockSettings.ts +++ b/src/components/modules/toolbar/blockSettings.ts @@ -223,10 +223,12 @@ export default class BlockSettings extends Module { name: tool.name, closeOnActivate: true, onActivate: async () => { - const { BlockManager, Caret } = this.Editor; + const { BlockManager, Caret, Toolbar } = this.Editor; const newBlock = await BlockManager.convert(currentBlock, tool.name, toolboxItem.data); + Toolbar.close(); + Caret.setToBlock(newBlock, Caret.positions.END); }, }); diff --git a/src/components/modules/toolbar/index.ts b/src/components/modules/toolbar/index.ts index aaeceba87..b03a69745 100644 --- a/src/components/modules/toolbar/index.ts +++ b/src/components/modules/toolbar/index.ts @@ -1,5 +1,5 @@ import Module from '../../__module'; -import $ from '../../dom'; +import $, { calculateBaseline } from '../../dom'; import * as _ from '../../utils'; import I18n from '../../i18n'; import { I18nInternalNS } from '../../i18n/namespace-internal'; @@ -276,28 +276,83 @@ export default class Toolbar extends Module { const targetBlockHolder = block.holder; const { isMobile } = this.Editor.UI; - const renderedContent = block.pluginsContent; - const renderedContentStyle = window.getComputedStyle(renderedContent); - const blockRenderedElementPaddingTop = parseInt(renderedContentStyle.paddingTop, 10); - const blockHeight = targetBlockHolder.offsetHeight; + + /** + * 1. Mobile: + * - Toolbar at the bottom of the block + * + * 2. Desktop: + * There are two cases of a toolbar position: + * 2.1 Toolbar is moved to the top of the block (+ padding top of the block) + * - when the first input is far from the top of the block, for example in Image tool + * - when block has no inputs + * 2.2 Toolbar is moved to the baseline of the first input + * - when the first input is close to the top of the block + */ let toolbarY; + const MAX_OFFSET = 20; + + /** + * Compute first input position + */ + const firstInput = block.firstInput; + const targetBlockHolderRect = targetBlockHolder.getBoundingClientRect(); + const firstInputRect = firstInput !== undefined ? firstInput.getBoundingClientRect() : null; + + /** + * Compute the offset of the first input from the top of the block + */ + const firstInputOffset = firstInputRect !== null ? firstInputRect.top - targetBlockHolderRect.top : null; + + /** + * Check if the first input is far from the top of the block + */ + const isFirstInputFarFromTop = firstInputOffset !== null ? firstInputOffset > MAX_OFFSET : undefined; /** + * Case 1. * On mobile — Toolbar at the bottom of Block - * On Desktop — Toolbar should be moved to the first line of block text - * To do that, we compute the block offset and the padding-top of the plugin content */ if (isMobile) { - toolbarY = targetBlockHolder.offsetTop + blockHeight; + toolbarY = targetBlockHolder.offsetTop + targetBlockHolder.offsetHeight; + + /** + * Case 2.1 + * On Desktop — without inputs or with the first input far from the top of the block + * Toolbar should be moved to the top of the block + */ + } else if (firstInput === undefined || isFirstInputFarFromTop) { + const pluginContentOffset = parseInt(window.getComputedStyle(block.pluginsContent).paddingTop); + + const paddingTopBasedY = targetBlockHolder.offsetTop + pluginContentOffset; + + toolbarY = paddingTopBasedY; + + /** + * Case 2.2 + * On Desktop — Toolbar should be moved to the baseline of the first input + */ } else { - toolbarY = targetBlockHolder.offsetTop + blockRenderedElementPaddingTop; + const baseline = calculateBaseline(firstInput); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const toolbarActionsHeight = parseInt(window.getComputedStyle(this.nodes.plusButton!).height, 10); + /** + * Visual padding inside the SVG icon + */ + const toolbarActionsPaddingBottom = 8; + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const baselineBasedY = targetBlockHolder.offsetTop + baseline - toolbarActionsHeight + toolbarActionsPaddingBottom + firstInputOffset!; + + toolbarY = baselineBasedY; } /** * Move Toolbar to the Top coordinate of Block */ - this.nodes.wrapper.style.top = `${Math.floor(toolbarY)}px`; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this.nodes.wrapper!.style.top = `${Math.floor(toolbarY)}px`; /** * Do not show Block Tunes Toggler near single and empty block diff --git a/test/cypress/fixtures/tools/ContentlessTool.ts b/test/cypress/fixtures/tools/ContentlessTool.ts index 4ca3ca2ac..49b137772 100644 --- a/test/cypress/fixtures/tools/ContentlessTool.ts +++ b/test/cypress/fixtures/tools/ContentlessTool.ts @@ -1,4 +1,4 @@ -import { BlockTool } from "../../../../types"; +import { BlockTool } from '../../../../types'; /** * In the simplest Contentless Tool (eg. Delimiter) there is no data to save From e40768f8c0701c3230809614220001c6b547ad03 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 1 Jul 2024 23:01:06 +0300 Subject: [PATCH 35/61] Bump version (#2751) Co-authored-by: github-actions --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 357141103..b47b77f04 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@editorjs/editorjs", - "version": "2.30.0-rc.12", + "version": "2.30.0-rc.13", "description": "Editor.js — Native JS, based on API and Open Source", "main": "dist/editorjs.umd.js", "module": "dist/editorjs.mjs", From bbb5dc579a49211712869b795412dc9359876c6c Mon Sep 17 00:00:00 2001 From: Vasily Naumkin Date: Tue, 2 Jul 2024 17:03:27 +0700 Subject: [PATCH 36/61] Creating links on Android devices: now the mobile keyboard will have an "Enter" key for accepting the inserted link. (#2746) --- docs/CHANGELOG.md | 1 + src/components/inline-tools/inline-tool-link.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index eb1ef24f0..624cc800b 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -28,6 +28,7 @@ – `Fix` — Deleting whitespaces at the start/end of the block – `Improvement` — *Types* — `BlockToolConstructorOptions` type improved, `block` and `config` are not optional anymore - `Improvement` - The Plus button and Block Tunes toggler are now better aligned with large line-height blocks, such as Headings +- `Improvement` — Creating links on Android devices: now the mobile keyboard will have an "Enter" key for accepting the inserted link. ### 2.29.1 diff --git a/src/components/inline-tools/inline-tool-link.ts b/src/components/inline-tools/inline-tool-link.ts index 19a370d21..f66cf79a6 100644 --- a/src/components/inline-tools/inline-tool-link.ts +++ b/src/components/inline-tools/inline-tool-link.ts @@ -134,6 +134,7 @@ export default class LinkInlineTool implements InlineTool { public renderActions(): HTMLElement { this.nodes.input = document.createElement('input') as HTMLInputElement; this.nodes.input.placeholder = this.i18n.t('Add a link'); + this.nodes.input.enterKeyHint = 'done'; this.nodes.input.classList.add(this.CSS.input); this.nodes.input.addEventListener('keydown', (event: KeyboardEvent) => { if (event.keyCode === this.ENTER_KEY) { From a6473877f62d54e6e084bbb403546a5ff1a82282 Mon Sep 17 00:00:00 2001 From: Tatiana Fomina Date: Wed, 3 Jul 2024 19:22:47 +0300 Subject: [PATCH 37/61] Disable flipper in inline toolbar actions (#2756) --- src/components/modules/toolbar/inline.ts | 10 +++-- .../components/popover-item/popover-item.ts | 19 ++++++++++ .../popover-item/popover-item.types.ts | 6 +++ .../utils/popover/popover-desktop.ts | 37 ++++++++++--------- src/components/utils/popover/popover.types.ts | 6 +++ 5 files changed, 57 insertions(+), 21 deletions(-) diff --git a/src/components/modules/toolbar/inline.ts b/src/components/modules/toolbar/inline.ts index 3192c7f04..f3150329b 100644 --- a/src/components/modules/toolbar/inline.ts +++ b/src/components/modules/toolbar/inline.ts @@ -53,7 +53,7 @@ export default class InlineToolbar extends Module { /** * Currently visible tools instances */ - private toolsInstances: Map = new Map(); + private toolsInstances: Map | null = new Map(); /** * @param moduleConfiguration - Module Configuration @@ -384,7 +384,9 @@ export default class InlineToolbar extends Module { const actions = instance.renderActions(); (popoverItem as WithChildren).children = { - isOpen: instance.checkState(SelectionUtils.get()), + isOpen: instance.checkState?.(SelectionUtils.get()), + /** Disable keyboard navigation in actions, as it might conflict with enter press handling */ + isFlippable: false, items: [ { type: PopoverItemType.Html, @@ -396,7 +398,7 @@ export default class InlineToolbar extends Module { /** * Legacy inline tools might perform some UI mutating logic in checkState method, so, call it just in case */ - instance.checkState(SelectionUtils.get()); + instance.checkState?.(SelectionUtils.get()); } popoverItems.push(popoverItem); @@ -534,7 +536,7 @@ export default class InlineToolbar extends Module { * Check Tools` state by selection */ private checkToolsState(): void { - this.toolsInstances.forEach((toolInstance) => { + this.toolsInstances?.forEach((toolInstance) => { toolInstance.checkState?.(SelectionUtils.get()); }); } diff --git a/src/components/utils/popover/components/popover-item/popover-item.ts b/src/components/utils/popover/components/popover-item/popover-item.ts index 5e0c10db0..44c370cc2 100644 --- a/src/components/utils/popover/components/popover-item/popover-item.ts +++ b/src/components/utils/popover/components/popover-item/popover-item.ts @@ -107,6 +107,25 @@ export abstract class PopoverItem { return this.params !== undefined && 'children' in this.params && this.params.children?.isOpen === true; } + /** + * True if item children items should be navigatable via keyboard + */ + public get isChildrenFlippable(): boolean { + if (this.params === undefined) { + return false; + } + + if (!('children' in this.params)) { + return false; + } + + if (this.params.children?.isFlippable === false) { + return false; + } + + return true; + } + /** * Returns true if item has children that should be searchable */ diff --git a/src/components/utils/popover/components/popover-item/popover-item.types.ts b/src/components/utils/popover/components/popover-item/popover-item.types.ts index b90966055..9f7a64011 100644 --- a/src/components/utils/popover/components/popover-item/popover-item.types.ts +++ b/src/components/utils/popover/components/popover-item/popover-item.types.ts @@ -30,6 +30,12 @@ export interface PopoverItemChildren { */ isOpen?: boolean; + /** + * False if keyboard navigation should be disabled in the children popover. + * True by default + */ + isFlippable?: boolean; + /** * Items of nested popover that should be open on the current item hover/click (depending on platform) */ diff --git a/src/components/utils/popover/popover-desktop.ts b/src/components/utils/popover/popover-desktop.ts index 744806685..1cce489b3 100644 --- a/src/components/utils/popover/popover-desktop.ts +++ b/src/components/utils/popover/popover-desktop.ts @@ -19,7 +19,7 @@ export class PopoverDesktop extends PopoverAbstract { /** * Flipper - module for keyboard iteration between elements */ - public flipper: Flipper; + public flipper: Flipper | undefined; /** * Popover nesting level. 0 value means that it is a root popover @@ -75,18 +75,20 @@ export class PopoverDesktop extends PopoverAbstract { this.addSearch(); } - this.flipper = new Flipper({ - items: this.flippableElements, - focusedItemClass: popoverItemCls.focused, - allowedKeys: [ - keyCodes.TAB, - keyCodes.UP, - keyCodes.DOWN, - keyCodes.ENTER, - ], - }); + if (params.flippable !== false) { + this.flipper = new Flipper({ + items: this.flippableElements, + focusedItemClass: popoverItemCls.focused, + allowedKeys: [ + keyCodes.TAB, + keyCodes.UP, + keyCodes.DOWN, + keyCodes.ENTER, + ], + }); - this.flipper.onFlip(this.onFlip); + this.flipper.onFlip(this.onFlip); + } } /** @@ -137,7 +139,7 @@ export class PopoverDesktop extends PopoverAbstract { } super.show(); - this.flipper.activate(this.flippableElements); + this.flipper?.activate(this.flippableElements); } /** @@ -148,7 +150,7 @@ export class PopoverDesktop extends PopoverAbstract { this.destroyNestedPopoverIfExists(); - this.flipper.deactivate(); + this.flipper?.deactivate(); this.previouslyHoveredItem = null; }; @@ -228,7 +230,7 @@ export class PopoverDesktop extends PopoverAbstract { this.nestedPopover.destroy(); this.nestedPopover.getElement().remove(); this.nestedPopover = null; - this.flipper.activate(this.flippableElements); + this.flipper?.activate(this.flippableElements); this.items.forEach(item => item.onChildrenClose()); } @@ -244,6 +246,7 @@ export class PopoverDesktop extends PopoverAbstract { searchable: item.isChildrenSearchable, items: item.children, nestingLevel: this.nestingLevel + 1, + flippable: item.isChildrenFlippable, }); item.onChildrenOpen(); @@ -264,7 +267,7 @@ export class PopoverDesktop extends PopoverAbstract { nestedPopoverEl.style.setProperty(CSSVariables.NestingLevel, this.nestedPopover.nestingLevel.toString()); this.nestedPopover.show(); - this.flipper.deactivate(); + this.flipper?.deactivate(); return this.nestedPopover; } @@ -414,7 +417,7 @@ export class PopoverDesktop extends PopoverAbstract { /** List of elements available for keyboard navigation considering search query applied */ const flippableElements = data.query === '' ? this.flippableElements : data.items.map(item => (item as PopoverItem).getElement()); - if (this.flipper.isActivated) { + if (this.flipper?.isActivated) { /** Update flipper items with only visible */ this.flipper.deactivate(); this.flipper.activate(flippableElements as HTMLElement[]); diff --git a/src/components/utils/popover/popover.types.ts b/src/components/utils/popover/popover.types.ts index 8e28df71d..0af9a11f1 100644 --- a/src/components/utils/popover/popover.types.ts +++ b/src/components/utils/popover/popover.types.ts @@ -20,6 +20,12 @@ export interface PopoverParams { */ searchable?: boolean; + /** + * False if keyboard navigation should be disabled. + * True by default + */ + flippable?: boolean; + /** * Popover texts overrides */ From c3c1651092beb943938bc716479a7504d7487960 Mon Sep 17 00:00:00 2001 From: Tatiana Fomina Date: Wed, 3 Jul 2024 19:40:55 +0300 Subject: [PATCH 38/61] fix(inline-toolbar): remove white dot visible in dark theme (#2755) * Fix white dot visible in dark theme * Fix test --------- Co-authored-by: Peter Savchenko --- src/styles/inline-toolbar.css | 2 +- test/cypress/tests/modules/InlineToolbar.cy.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/styles/inline-toolbar.css b/src/styles/inline-toolbar.css index 0dd700213..f7ba7a30e 100644 --- a/src/styles/inline-toolbar.css +++ b/src/styles/inline-toolbar.css @@ -6,7 +6,7 @@ --color-text-icon-active: #388AE5; --color-text-primary: black; - @apply --overlay-pane; + position: absolute; visibility: hidden; transition: opacity 250ms ease; will-change: opacity, left, top; diff --git a/test/cypress/tests/modules/InlineToolbar.cy.ts b/test/cypress/tests/modules/InlineToolbar.cy.ts index b2510c6c5..8d005fabf 100644 --- a/test/cypress/tests/modules/InlineToolbar.cy.ts +++ b/test/cypress/tests/modules/InlineToolbar.cy.ts @@ -71,7 +71,7 @@ describe('Inline Toolbar', () => { /** * Toolbar should be aligned with right side of text column */ - expect($toolbar.offset().left + $toolbar.width()).to.closeTo(blockWrapperRect.right, 9); + expect($toolbar.offset().left + $toolbar.width()).to.closeTo(blockWrapperRect.right, 10); }); }); }); From fb3089cd0d6068ebe2fd2a46f864f42f1fe6c624 Mon Sep 17 00:00:00 2001 From: Tatiana Fomina Date: Wed, 3 Jul 2024 20:39:13 +0300 Subject: [PATCH 39/61] Fix fast toggling of link tool in safari (#2757) --- .../inline-tools/inline-tool-convert.ts | 11 ++++++++--- src/components/modules/toolbar/inline.ts | 4 ---- .../popover-item-html/popover-item-html.ts | 7 ------- .../components/popover-item/popover-item.ts | 18 +++++++++++++++++- .../utils/popover/popover-desktop.ts | 18 +++++++++++++----- src/components/utils/popover/popover-inline.ts | 10 ++-------- types/tools/inline-tool.d.ts | 4 ++-- 7 files changed, 42 insertions(+), 30 deletions(-) diff --git a/src/components/inline-tools/inline-tool-convert.ts b/src/components/inline-tools/inline-tool-convert.ts index ccc66b7df..053a14a4a 100644 --- a/src/components/inline-tools/inline-tool-convert.ts +++ b/src/components/inline-tools/inline-tool-convert.ts @@ -1,6 +1,6 @@ import { IconReplace } from '@codexteam/icons'; import { InlineTool, API } from '../../../types'; -import { MenuConfig } from '../../../types/tools'; +import { MenuConfig, MenuConfigItem } from '../../../types/tools'; import * as _ from '../utils'; import { Blocks, Selection, Tools, I18n, Caret } from '../../../types/api'; import SelectionUtils from '../selection'; @@ -57,6 +57,11 @@ export default class ConvertInlineTool implements InlineTool { public async render(): Promise { const currentSelection = SelectionUtils.get(); const currentBlock = this.blocksAPI.getBlockByElement(currentSelection.anchorNode as HTMLElement); + + if (currentBlock === undefined) { + return []; + } + const allBlockTools = this.toolsAPI.getBlockTools(); const convertibleTools = await getConvertibleToolsForBlock(currentBlock, allBlockTools); @@ -64,8 +69,8 @@ export default class ConvertInlineTool implements InlineTool { return []; } - const convertToItems = convertibleTools.reduce((result, tool) => { - tool.toolbox.forEach((toolboxItem) => { + const convertToItems = convertibleTools.reduce((result, tool) => { + tool.toolbox?.forEach((toolboxItem) => { result.push({ icon: toolboxItem.icon, title: toolboxItem.title, diff --git a/src/components/modules/toolbar/inline.ts b/src/components/modules/toolbar/inline.ts index f3150329b..350c75084 100644 --- a/src/components/modules/toolbar/inline.ts +++ b/src/components/modules/toolbar/inline.ts @@ -524,10 +524,6 @@ export default class InlineToolbar extends Module { private toolClicked(tool: IInlineTool): void { const range = SelectionUtils.range; - if (range === null) { - return; - } - tool.surround?.(range); this.checkToolsState(); } diff --git a/src/components/utils/popover/components/popover-item/popover-item-html/popover-item-html.ts b/src/components/utils/popover/components/popover-item/popover-item-html/popover-item-html.ts index d911c138e..d06210ebf 100644 --- a/src/components/utils/popover/components/popover-item/popover-item-html/popover-item-html.ts +++ b/src/components/utils/popover/components/popover-item/popover-item-html/popover-item-html.ts @@ -67,11 +67,4 @@ export class PopoverItemHtml extends PopoverItem { return Array.from(controls); } - - /** - * Called on popover item click - */ - public handleClick(): void { - this.params.onActivate?.(this.params); - } } diff --git a/src/components/utils/popover/components/popover-item/popover-item.ts b/src/components/utils/popover/components/popover-item/popover-item.ts index 44c370cc2..9fc59b563 100644 --- a/src/components/utils/popover/components/popover-item/popover-item.ts +++ b/src/components/utils/popover/components/popover-item/popover-item.ts @@ -58,6 +58,21 @@ export abstract class PopoverItem { } } + /** + * Called on popover item click + */ + public handleClick(): void { + if (this.params === undefined) { + return; + } + + if (!('onActivate' in this.params)) { + return; + } + + this.params.onActivate?.(this.params); + } + /** * Adds hint to the item element if hint data is provided * @@ -147,8 +162,9 @@ export abstract class PopoverItem { if (this.params === undefined) { return false; } + if (!('isActive' in this.params)) { - return; + return false; } if (typeof this.params.isActive === 'function') { diff --git a/src/components/utils/popover/popover-desktop.ts b/src/components/utils/popover/popover-desktop.ts index 1cce489b3..73cd10303 100644 --- a/src/components/utils/popover/popover-desktop.ts +++ b/src/components/utils/popover/popover-desktop.ts @@ -1,6 +1,6 @@ import Flipper from '../../flipper'; import { PopoverAbstract } from './popover-abstract'; -import { PopoverItem, PopoverItemRenderParamsMap, PopoverItemSeparator, WithChildren, css as popoverItemCls } from './components/popover-item'; +import { PopoverItem, PopoverItemRenderParamsMap, PopoverItemSeparator, css as popoverItemCls } from './components/popover-item'; import { PopoverEvent, PopoverParams } from './popover.types'; import { keyCodes } from '../../utils'; import { CSSVariables, css } from './popover.const'; @@ -32,6 +32,11 @@ export class PopoverDesktop extends PopoverAbstract { */ protected nestedPopover: PopoverDesktop | undefined | null; + /** + * Item nested popover is displayed for + */ + protected nestedPopoverTriggerItem: PopoverItem | null = null; + /** * Last hovered item inside popover. * Is used to determine if cursor is moving inside one item or already moved away to another one. @@ -168,10 +173,13 @@ export class PopoverDesktop extends PopoverAbstract { * * @param item – item to show nested popover for */ - protected override showNestedItems(item: WithChildren | WithChildren): void { + protected override showNestedItems(item: PopoverItem): void { if (this.nestedPopover !== null && this.nestedPopover !== undefined) { return; } + + this.nestedPopoverTriggerItem = item; + this.showNestedPopoverForItem(item); } @@ -209,7 +217,7 @@ export class PopoverDesktop extends PopoverAbstract { * @param nestedPopoverEl - nested popover element * @param item – item near which nested popover should be displayed */ - protected setTriggerItemPosition(nestedPopoverEl: HTMLElement, item: WithChildren | WithChildren): void { + protected setTriggerItemPosition(nestedPopoverEl: HTMLElement, item: PopoverItem): void { const itemEl = item.getElement(); const itemOffsetTop = (itemEl ? itemEl.offsetTop : 0) - this.scrollTop; const topOffset = this.offsetTop + itemOffsetTop; @@ -232,7 +240,7 @@ export class PopoverDesktop extends PopoverAbstract { this.nestedPopover = null; this.flipper?.activate(this.flippableElements); - this.items.forEach(item => item.onChildrenClose()); + this.nestedPopoverTriggerItem?.onChildrenClose(); } /** @@ -241,7 +249,7 @@ export class PopoverDesktop extends PopoverAbstract { * * @param item - item to display nested popover by */ - protected showNestedPopoverForItem(item: WithChildren | WithChildren): PopoverDesktop { + protected showNestedPopoverForItem(item: PopoverItem): PopoverDesktop { this.nestedPopover = new PopoverDesktop({ searchable: item.isChildrenSearchable, items: item.children, diff --git a/src/components/utils/popover/popover-inline.ts b/src/components/utils/popover/popover-inline.ts index 444f46d0c..78d75a30e 100644 --- a/src/components/utils/popover/popover-inline.ts +++ b/src/components/utils/popover/popover-inline.ts @@ -1,5 +1,5 @@ import { isMobileScreen } from '../../utils'; -import { PopoverItem, PopoverItemDefault, PopoverItemType, WithChildren } from './components/popover-item'; +import { PopoverItem, PopoverItemDefault, PopoverItemType } from './components/popover-item'; import { PopoverItemHtml } from './components/popover-item/popover-item-html/popover-item-html'; import { PopoverDesktop } from './popover-desktop'; import { CSSVariables, css } from './popover.const'; @@ -9,11 +9,6 @@ import { PopoverParams } from './popover.types'; * Horizontal popover that is displayed inline with the content */ export class PopoverInline extends PopoverDesktop { - /** - * Item nested popover is displayed for - */ - private nestedPopoverTriggerItem: PopoverItemDefault | PopoverItemHtml | null = null; - /** * Constructs the instance * @@ -138,7 +133,6 @@ export class PopoverInline extends PopoverDesktop { return; } - this.nestedPopoverTriggerItem = item; super.showNestedItems(item); } @@ -148,7 +142,7 @@ export class PopoverInline extends PopoverDesktop { * * @param item - item to display nested popover by */ - protected showNestedPopoverForItem(item: WithChildren | WithChildren): PopoverDesktop { + protected showNestedPopoverForItem(item: PopoverItem): PopoverDesktop { const nestedPopover = super.showNestedPopoverForItem(item); const nestedPopoverEl = nestedPopover.getElement(); diff --git a/types/tools/inline-tool.d.ts b/types/tools/inline-tool.d.ts index 170779b0b..42ebc3f28 100644 --- a/types/tools/inline-tool.d.ts +++ b/types/tools/inline-tool.d.ts @@ -13,10 +13,10 @@ export interface InlineTool extends BaseTool { /** * Method that accepts selected range and wrap it somehow - * @param {Range} range - selection's range + * @param range - selection's range. If no active selection, range is null * @deprecated use {@link MenuConfig} item onActivate property instead */ - surround?(range: Range): void; + surround?(range: Range | null): void; /** * Get SelectionUtils and detect if Tool was applied From 9c1e2e59ba7a287322924d7c7411b1f744662836 Mon Sep 17 00:00:00 2001 From: Peter Savchenko Date: Thu, 4 Jul 2024 14:55:01 +0300 Subject: [PATCH 40/61] feat(ui): placeholders updated (#2758) * data-empty mark * Update CHANGELOG.md * lint * tests added * Update DataEmpty.cy.ts * lint * fix tests * Update Placeholders.cy.ts * upd paragraph * rm redundant test * lint fix * disable test for firefox --- docs/CHANGELOG.md | 3 + index.html | 3 + package.json | 2 +- src/components/block/index.ts | 27 +++++-- src/components/dom.ts | 10 +++ src/components/modules/blockManager.ts | 8 +- src/components/modules/ui.ts | 38 ++++++++- src/components/utils/mutations.ts | 7 ++ src/styles/main.css | 1 + src/styles/placeholders.css | 45 +++++++++++ test/cypress/support/commands.ts | 15 ++++ test/cypress/support/index.d.ts | 7 ++ .../utils/createEditorWithTextBlocks.ts | 8 +- .../cypress/tests/modules/InlineToolbar.cy.ts | 4 +- test/cypress/tests/tools/ToolsFactory.cy.ts | 2 +- test/cypress/tests/ui/BlockTunes.cy.ts | 2 +- test/cypress/tests/ui/DataEmpty.cy.ts | 71 ++++++++++++++++ test/cypress/tests/ui/InlineToolbar.cy.ts | 30 +++---- test/cypress/tests/ui/Placeholders.cy.ts | 80 +++++++++++++++++++ yarn.lock | 8 +- 20 files changed, 329 insertions(+), 42 deletions(-) create mode 100644 src/styles/placeholders.css create mode 100644 test/cypress/tests/ui/DataEmpty.cy.ts create mode 100644 test/cypress/tests/ui/Placeholders.cy.ts diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 624cc800b..589741bc0 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -29,6 +29,9 @@ – `Improvement` — *Types* — `BlockToolConstructorOptions` type improved, `block` and `config` are not optional anymore - `Improvement` - The Plus button and Block Tunes toggler are now better aligned with large line-height blocks, such as Headings - `Improvement` — Creating links on Android devices: now the mobile keyboard will have an "Enter" key for accepting the inserted link. +- `Improvement` — Placeholders will stay visible on inputs focus. +- `New` — Editor.js now supports contenteditable placeholders out of the box. Just add `data-placeholder` or `data-placeholder-active` attribute to make it work. The first one will work like native placeholder while the second one will show placeholder only when block is current. +- `Improvement` — Now Paragraph placeholder will be shown for the current paragraph, not the only first one. ### 2.29.1 diff --git a/index.html b/index.html index ddbba1f57..6f8ad4bb7 100644 --- a/index.html +++ b/index.html @@ -203,6 +203,9 @@ */ // defaultBlock: 'paragraph', + placeholder: 'Write something or press / to select a tool', + autofocus: true, + /** * Initial Editor data */ diff --git a/package.json b/package.json index b47b77f04..535e749b0 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "@editorjs/code": "^2.7.0", "@editorjs/delimiter": "^1.2.0", "@editorjs/header": "^2.7.0", - "@editorjs/paragraph": "^2.11.4", + "@editorjs/paragraph": "^2.11.6", "@editorjs/simple-image": "^1.4.1", "@types/node": "^18.15.11", "chai-subset": "^1.6.0", diff --git a/src/components/block/index.ts b/src/components/block/index.ts index decc9e064..33c54b7b1 100644 --- a/src/components/block/index.ts +++ b/src/components/block/index.ts @@ -10,7 +10,7 @@ import { } from '../../../types'; import { SavedData } from '../../../types/data-formats'; -import $ from '../dom'; +import $, { toggleEmptyMark } from '../dom'; import * as _ from '../utils'; import ApiModules from '../modules/api'; import BlockAPI from './api'; @@ -183,11 +183,6 @@ export default class Block extends EventsDispatcher { */ private unavailableTunesData: { [name: string]: BlockTuneData } = {}; - /** - * Editor`s API module - */ - private readonly api: ApiModules; - /** * Focused input index * @@ -223,7 +218,6 @@ export default class Block extends EventsDispatcher { id = _.generateBlockId(), data, tool, - api, readOnly, tunesData, }: BlockConstructorOptions, eventBus?: EventsDispatcher) { @@ -232,7 +226,6 @@ export default class Block extends EventsDispatcher { this.id = id; this.settings = tool.settings; this.config = tool.settings.config || {}; - this.api = api; this.editorEventBus = eventBus || null; this.blockAPI = new BlockAPI(this); @@ -262,6 +255,12 @@ export default class Block extends EventsDispatcher { * so we need to track focus events to update current input and clear cache. */ this.addInputEvents(); + + /** + * We mark inputs with [data-empty] attribute + * It can be useful for developers, for example for correct placeholder behavior + */ + this.toggleInputsEmptyMark(); }); } @@ -938,6 +937,11 @@ export default class Block extends EventsDispatcher { */ this.updateCurrentInput(); + /** + * We mark inputs with 'data-empty' attribute, so new inputs should be marked as well + */ + this.toggleInputsEmptyMark(); + this.call(BlockToolAPI.UPDATED); /** @@ -1000,4 +1004,11 @@ export default class Block extends EventsDispatcher { private dropInputsCache(): void { this.cachedInputs = []; } + + /** + * Mark inputs with 'data-empty' attribute with the empty state + */ + private toggleInputsEmptyMark(): void { + this.inputs.forEach(toggleEmptyMark); + } } diff --git a/src/components/dom.ts b/src/components/dom.ts index 0dc2e19e1..e61269f36 100644 --- a/src/components/dom.ts +++ b/src/components/dom.ts @@ -662,3 +662,13 @@ export function calculateBaseline(element: Element): number { return baselineY; } + +/** + * Toggles the [data-empty] attribute on element depending on its emptiness + * Used to mark empty inputs with a special attribute for placeholders feature + * + * @param element - The element to toggle the [data-empty] attribute on + */ +export function toggleEmptyMark(element: HTMLElement): void { + element.dataset.empty = Dom.isEmpty(element) ? 'true' : 'false'; +} diff --git a/src/components/modules/blockManager.ts b/src/components/modules/blockManager.ts index 2ab4a5f82..3442c0bdd 100644 --- a/src/components/modules/blockManager.ts +++ b/src/components/modules/blockManager.ts @@ -544,7 +544,7 @@ export default class BlockManager extends Module { * If first Block was removed, insert new Initial Block and set focus on it`s first input */ if (!this.blocks.length) { - this.currentBlockIndex = -1; + this.unsetCurrentBlock(); if (addLastBlock) { this.insert(); @@ -591,7 +591,7 @@ export default class BlockManager extends Module { this._blocks.remove(index); } - this.currentBlockIndex = -1; + this.unsetCurrentBlock(); this.insert(); this.currentBlock.firstInput.focus(); } @@ -873,7 +873,7 @@ export default class BlockManager extends Module { * Sets current Block Index -1 which means unknown * and clear highlights */ - public dropPointer(): void { + public unsetCurrentBlock(): void { this.currentBlockIndex = -1; } @@ -895,7 +895,7 @@ export default class BlockManager extends Module { await queue.completed; - this.dropPointer(); + this.unsetCurrentBlock(); if (needToAddDefaultBlock) { this.insert(); diff --git a/src/components/modules/ui.ts b/src/components/modules/ui.ts index 60c2a5f47..f9c3cbef6 100644 --- a/src/components/modules/ui.ts +++ b/src/components/modules/ui.ts @@ -5,7 +5,7 @@ * @type {UI} */ import Module from '../__module'; -import $ from '../dom'; +import $, { toggleEmptyMark } from '../dom'; import * as _ from '../utils'; import Selection from '../selection'; @@ -380,6 +380,12 @@ export default class UI extends Module { * Start watching 'block-hovered' events that is used by Toolbar for moving */ this.watchBlockHoveredEvents(); + + /** + * We have custom logic for providing placeholders for contenteditable elements. + * To make it work, we need to have data-empty mark on empty inputs. + */ + this.enableInputsEmptyMark(); } @@ -498,7 +504,7 @@ export default class UI extends Module { /** * Remove all highlights and remove caret */ - this.Editor.BlockManager.dropPointer(); + this.Editor.BlockManager.unsetCurrentBlock(); /** * Close Toolbar @@ -645,12 +651,12 @@ export default class UI extends Module { if (!clickedInsideOfEditor) { /** - * Clear highlights and pointer on BlockManager + * Clear pointer on BlockManager * * Current page might contain several instances * Click between instances MUST clear focus, pointers and close toolbars */ - this.Editor.BlockManager.dropPointer(); + this.Editor.BlockManager.unsetCurrentBlock(); this.Editor.Toolbar.close(); } @@ -874,4 +880,28 @@ export default class UI extends Module { this.Editor.InlineToolbar.tryToShow(true); } + + /** + * Editor.js provides and ability to show placeholders for empty contenteditable elements + * + * This method watches for input and focus events and toggles 'data-empty' attribute + * to workaroud the case, when inputs contains only
s and has no visible content + * Then, CSS could rely on this attribute to show placeholders + */ + private enableInputsEmptyMark(): void { + /** + * Toggle data-empty attribute on input depending on its emptiness + * + * @param event - input or focus event + */ + function handleInputOrFocusChange(event: Event): void { + const input = event.target as HTMLElement; + + toggleEmptyMark(input); + } + + this.readOnlyMutableListeners.on(this.nodes.wrapper, 'input', handleInputOrFocusChange); + this.readOnlyMutableListeners.on(this.nodes.wrapper, 'focusin', handleInputOrFocusChange); + this.readOnlyMutableListeners.on(this.nodes.wrapper, 'focusout', handleInputOrFocusChange); + } } diff --git a/src/components/utils/mutations.ts b/src/components/utils/mutations.ts index 6e98456df..3d5e8f25d 100644 --- a/src/components/utils/mutations.ts +++ b/src/components/utils/mutations.ts @@ -7,6 +7,13 @@ export function isMutationBelongsToElement(mutationRecord: MutationRecord, element: Element): boolean { const { type, target, addedNodes, removedNodes } = mutationRecord; + /** + * Skip own technical mutations, for example, data-empty attribute changes + */ + if (mutationRecord.type === 'attributes' && mutationRecord.attributeName === 'data-empty') { + return false; + } + /** * Covers all types of mutations happened to the element or it's descendants with the only one exception - removing/adding the element itself; */ diff --git a/src/styles/main.css b/src/styles/main.css index 38eb723d1..d0005e75c 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -11,4 +11,5 @@ @import './input.css'; @import './popover.css'; @import './popover-inline.css'; +@import './placeholders.css'; diff --git a/src/styles/placeholders.css b/src/styles/placeholders.css new file mode 100644 index 000000000..08fea51c4 --- /dev/null +++ b/src/styles/placeholders.css @@ -0,0 +1,45 @@ + +/** + * We support two types of placeholders for contenteditable: + * + * 1. Regular-like placeholders. Will be visible when element is empty. + -- Best choice for rare-used blocks like Headings. + * 2. Current-block placeholders. Will be visible when element is empty and the block is focused. + -- Best choice for common-used blocks like Paragraphs. + */ + :root { + --placeholder { + pointer-events: none; + color: var(--grayText); + cursor: text; + } + } + +.codex-editor { + /** + * Use [data-placeholder="..."] to always show a placeholder on empty contenteditable. + */ + [data-placeholder]:empty, + [data-placeholder][data-empty="true"] { + &::before { + @apply --placeholder; + + content: attr(data-placeholder); + } + } + + /** + * Use [data-placeholder-active="..."] to show a placeholder on empty contenteditable in current block. + */ + [data-placeholder-active]:empty, + [data-placeholder-active][data-empty="true"] { + /* Paragraph tool shows the placeholder for the first block, event it is not focused, so we need to prepare styles for it */ + &::before { + @apply --placeholder; + } + + &:focus::before { + content: attr(data-placeholder-active); + } + } +} diff --git a/test/cypress/support/commands.ts b/test/cypress/support/commands.ts index 09a52db81..35392fe2f 100644 --- a/test/cypress/support/commands.ts +++ b/test/cypress/support/commands.ts @@ -261,3 +261,18 @@ Cypress.Commands.add('keydown', { return cy.wrap(subject); }); + +/** + * Extract content of pseudo element + * + * @example cy.get('element').getPseudoElementContent('::before').should('eq', 'my-test-string') + */ +Cypress.Commands.add('getPseudoElementContent', { + prevSubject: true, +}, (subject, pseudoElement: 'string') => { + const win = subject[0].ownerDocument.defaultView; + const computedStyle = win.getComputedStyle(subject[0], pseudoElement); + const content = computedStyle.getPropertyValue('content'); + + return content.replace(/['"]/g, ''); // Remove quotes around the content +}); diff --git a/test/cypress/support/index.d.ts b/test/cypress/support/index.d.ts index 89468b813..ee8a49544 100644 --- a/test/cypress/support/index.d.ts +++ b/test/cypress/support/index.d.ts @@ -93,6 +93,13 @@ declare global { * @param keyCode - key code to dispatch */ keydown(keyCode: number): Chainable; + + /** + * Extract content of pseudo element + * + * @example cy.get('element').getPseudoElementContent('::before').should('eq', 'my-test-string') + */ + getPseudoElementContent(pseudoElement: string): Chainable; } interface ApplicationWindow { diff --git a/test/cypress/support/utils/createEditorWithTextBlocks.ts b/test/cypress/support/utils/createEditorWithTextBlocks.ts index f5797856d..95deb66bb 100644 --- a/test/cypress/support/utils/createEditorWithTextBlocks.ts +++ b/test/cypress/support/utils/createEditorWithTextBlocks.ts @@ -1,3 +1,4 @@ +import { EditorConfig } from '../../../../types/index'; import Chainable = Cypress.Chainable; import type EditorJS from '../../../../types/index'; @@ -6,9 +7,10 @@ import type EditorJS from '../../../../types/index'; * Creates Editor instance with list of Paragraph blocks of passed texts * * @param textBlocks - list of texts for Paragraph blocks + * @param editorConfig - config to pass to the editor */ -export function createEditorWithTextBlocks(textBlocks: string[]): Chainable { - return cy.createEditor({ +export function createEditorWithTextBlocks(textBlocks: string[], editorConfig?: Omit): Chainable { + return cy.createEditor(Object.assign(editorConfig || {}, { data: { blocks: textBlocks.map((text) => ({ type: 'paragraph', @@ -17,5 +19,5 @@ export function createEditorWithTextBlocks(textBlocks: string[]): Chainable { const range = selection.getRangeAt(0); const rect = range.getBoundingClientRect(); - + expect($toolbar.offset().left).to.be.closeTo(rect.left, 1); }); }); @@ -67,7 +67,7 @@ describe('Inline Toolbar', () => { cy.get('@blockWrapper') .then(($blockWrapper) => { const blockWrapperRect = $blockWrapper.get(0).getBoundingClientRect(); - + /** * Toolbar should be aligned with right side of text column */ diff --git a/test/cypress/tests/tools/ToolsFactory.cy.ts b/test/cypress/tests/tools/ToolsFactory.cy.ts index 7a5ee338e..a645c7366 100644 --- a/test/cypress/tests/tools/ToolsFactory.cy.ts +++ b/test/cypress/tests/tools/ToolsFactory.cy.ts @@ -34,7 +34,7 @@ describe('ToolsFactory', (): void => { prop1: 'prop1', prop2: 'prop2', }; - } + }, } as any ); }); diff --git a/test/cypress/tests/ui/BlockTunes.cy.ts b/test/cypress/tests/ui/BlockTunes.cy.ts index d09370829..fddbf7332 100644 --- a/test/cypress/tests/ui/BlockTunes.cy.ts +++ b/test/cypress/tests/ui/BlockTunes.cy.ts @@ -386,7 +386,7 @@ describe('BlockTunes', function () { icon: 'Icon', title: 'Tune', // eslint-disable-next-line @typescript-eslint/no-empty-function - onActivate: () => {} + onActivate: () => {}, }; } diff --git a/test/cypress/tests/ui/DataEmpty.cy.ts b/test/cypress/tests/ui/DataEmpty.cy.ts new file mode 100644 index 000000000..51ab5614f --- /dev/null +++ b/test/cypress/tests/ui/DataEmpty.cy.ts @@ -0,0 +1,71 @@ +import { createEditorWithTextBlocks } from '../../support/utils/createEditorWithTextBlocks'; + +describe('inputs [data-empty] mark', function () { + it('should be added to inputs of editor on initialization', function () { + createEditorWithTextBlocks([ + 'First', // not empty block + '', // empty block + ]); + + cy.get('[data-cy=editorjs]') + .find('.ce-paragraph') + .first() + .should('have.attr', 'data-empty', 'false'); + + cy.get('[data-cy=editorjs]') + .find('.ce-paragraph') + .last() + .should('have.attr', 'data-empty', 'true'); + }); + + it('should be added as "false" to the input on typing', function () { + createEditorWithTextBlocks([ + 'First', // not empty block + '', // empty block + ]); + + cy.get('[data-cy=editorjs]') + .find('.ce-paragraph') + .last() + .type('Some text'); + + cy.get('[data-cy=editorjs]') + .find('.ce-paragraph') + .last() + .should('have.attr', 'data-empty', 'false'); + }); + + it('should be added as "true" to the input on chars removal', function () { + createEditorWithTextBlocks([ + '', // empty block + 'Some text', // not empty block + ]); + + cy.get('[data-cy=editorjs]') + .find('.ce-paragraph') + .last() + .type('{selectall}{backspace}'); + + cy.get('[data-cy=editorjs]') + .find('.ce-paragraph') + .last() + .should('have.attr', 'data-empty', 'true'); + }); + + it('should be added to the new block inputs', function () { + createEditorWithTextBlocks([ + 'First', // not empty block + '', // empty block + ]); + + cy.get('[data-cy=editorjs]') + .find('.ce-paragraph') + .last() + .type('{enter}'); + + cy.get('[data-cy=editorjs]') + .find('.ce-paragraph') + .last() + .should('have.attr', 'data-empty', 'true'); + }); +}); diff --git a/test/cypress/tests/ui/InlineToolbar.cy.ts b/test/cypress/tests/ui/InlineToolbar.cy.ts index d10ed29b9..1b243cfb4 100644 --- a/test/cypress/tests/ui/InlineToolbar.cy.ts +++ b/test/cypress/tests/ui/InlineToolbar.cy.ts @@ -47,12 +47,13 @@ describe('Inline Toolbar', () => { tools: { header: { class: Header, - inlineToolbar: ['bold', 'testTool', 'link'] + inlineToolbar: ['bold', 'testTool', 'link'], }, testTool: { class: class { public static isInline = true; + // eslint-disable-next-line jsdoc/require-jsdoc public render(): MenuConfig { return { icon: 'n', @@ -64,14 +65,14 @@ describe('Inline Toolbar', () => { icon: 'm', title: 'Test Tool Item', // eslint-disable-next-line @typescript-eslint/no-empty-function - onActivate: () => {} - } - ] - } + onActivate: () => {}, + }, + ], + }, }; } - } - } + }, + }, }, data: { blocks: [ @@ -115,12 +116,13 @@ describe('Inline Toolbar', () => { tools: { header: { class: Header, - inlineToolbar: ['bold', 'testTool'] + inlineToolbar: ['bold', 'testTool'], }, testTool: { class: class { public static isInline = true; + // eslint-disable-next-line jsdoc/require-jsdoc public render(): MenuConfig { return { icon: 'n', @@ -132,14 +134,14 @@ describe('Inline Toolbar', () => { icon: 'm', title: 'Test Tool Item', // eslint-disable-next-line @typescript-eslint/no-empty-function - onActivate: () => {} - } - ] - } + onActivate: () => {}, + }, + ], + }, }; } - } - } + }, + }, }, data: { blocks: [ diff --git a/test/cypress/tests/ui/Placeholders.cy.ts b/test/cypress/tests/ui/Placeholders.cy.ts new file mode 100644 index 000000000..79f19e699 --- /dev/null +++ b/test/cypress/tests/ui/Placeholders.cy.ts @@ -0,0 +1,80 @@ +/** + * Text will be passed as a placeholder to the editor + */ +const PLACEHOLDER_TEXT = 'Write something or press / to select a tool'; + +describe('Placeholders', function () { + /** + * There is no ability to get pseudo elements content in Firefox + * It will return CSS-bases value (attr(data-placeholder) instead of DOM-based + */ + if (Cypress.browser.family === 'firefox') { + return; + } + + it('should be shown near first block if passed via editor config', function () { + cy.createEditor({ + placeholder: PLACEHOLDER_TEXT, + }); + + cy.get('[data-cy=editorjs]') + .find('.ce-paragraph') + .getPseudoElementContent('::before') + .should('eq', PLACEHOLDER_TEXT); + }); + + it('should be shown when editor is autofocusable', function () { + cy.createEditor({ + placeholder: PLACEHOLDER_TEXT, + autofocus: true, + }); + + cy.get('[data-cy=editorjs]') + .find('.ce-paragraph') + .getPseudoElementContent('::before') + .should('eq', PLACEHOLDER_TEXT); + }); + + it('should be shown event if input is focused', function () { + cy.createEditor({ + placeholder: PLACEHOLDER_TEXT, + }); + + cy.get('[data-cy=editorjs]') + .find('.ce-paragraph') + .click() + .as('firstBlock') + .getPseudoElementContent('::before') + .should('eq', PLACEHOLDER_TEXT); + }); + + it('should be shown event when user removes all text by cmd+a and delete', function () { + cy.createEditor({ + placeholder: PLACEHOLDER_TEXT, + }); + + cy.get('[data-cy=editorjs]') + .find('.ce-paragraph') + .type('aaa') + .type('{selectall}{backspace}') + .getPseudoElementContent('::before') + .should('eq', PLACEHOLDER_TEXT); + }); + + it('should be hidden when user starts typing', function () { + cy.createEditor({ + placeholder: PLACEHOLDER_TEXT, + }); + + cy.get('[data-cy=editorjs]') + .find('.ce-paragraph') + .as('firstBlock') + .getPseudoElementContent('::before') + .should('eq', PLACEHOLDER_TEXT); + + cy.get('@firstBlock') + .type('a') + .getPseudoElementContent('::before') + .should('eq', 'none'); + }); +}); diff --git a/yarn.lock b/yarn.lock index 6b998a7b5..df7d4b53a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -571,10 +571,10 @@ dependencies: "@codexteam/icons" "^0.0.5" -"@editorjs/paragraph@^2.11.4": - version "2.11.4" - resolved "https://registry.yarnpkg.com/@editorjs/paragraph/-/paragraph-2.11.4.tgz#ee91fc2d97f0aa9790860854fd804ef5d83d988e" - integrity sha512-OuTINHoHrJwKxlpTm6FtiXazwagALJbP49hfbQWBOLTNiBICncqPe1hdGfgDpeEgH9ZEGZsJelhEDxw2iwcmPA== +"@editorjs/paragraph@^2.11.6": + version "2.11.6" + resolved "https://registry.yarnpkg.com/@editorjs/paragraph/-/paragraph-2.11.6.tgz#011444187a74dc603201dce37d2fc6d054022407" + integrity sha512-i9B50Ylvh+0ZzUGWIba09PfUXsA00Y//zFZMwqsyaXXKxMluSIJ6ADFJbbK0zaV9Ijx49Xocrlg+CEPRqATk9w== dependencies: "@codexteam/icons" "^0.0.4" From eb97c49dbf67357b48bd1ed5343457ed74d3314b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 4 Jul 2024 15:05:30 +0300 Subject: [PATCH 41/61] Bump version (#2754) Co-authored-by: github-actions --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 535e749b0..89de03c08 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@editorjs/editorjs", - "version": "2.30.0-rc.13", + "version": "2.30.0-rc.14", "description": "Editor.js — Native JS, based on API and Open Source", "main": "dist/editorjs.umd.js", "module": "dist/editorjs.mjs", From 0e8cc0d5be502e25bc0237201d4b5b0a241b212d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Pich=C3=A9?= Date: Sat, 6 Jul 2024 13:10:32 -0400 Subject: [PATCH 42/61] Fix import in types/events/block/index.ts (#2739) * Fix import in types/events/block/index.ts Fix wrong placement of the keyword type in the import lines of the types/events/block/index.ts introduced in PR-2491 and released in V2.29.0 preventing compilation. * Update CHANGELOG.md * Update CHANGELOG.md * Update CHANGELOG.md --------- Co-authored-by: Peter Savchenko --- docs/CHANGELOG.md | 1 + types/events/block/index.ts | 8 ++++---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 589741bc0..2dea81e0b 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -32,6 +32,7 @@ - `Improvement` — Placeholders will stay visible on inputs focus. - `New` — Editor.js now supports contenteditable placeholders out of the box. Just add `data-placeholder` or `data-placeholder-active` attribute to make it work. The first one will work like native placeholder while the second one will show placeholder only when block is current. - `Improvement` — Now Paragraph placeholder will be shown for the current paragraph, not the only first one. +- `Fix` — The problem caused by missed "import type" in block mutation event types resolved ### 2.29.1 diff --git a/types/events/block/index.ts b/types/events/block/index.ts index 3c4c20c1a..2851eba78 100644 --- a/types/events/block/index.ts +++ b/types/events/block/index.ts @@ -1,7 +1,7 @@ -import { type BlockAddedEvent, BlockAddedMutationType } from './BlockAdded'; -import { type BlockChangedEvent, BlockChangedMutationType } from './BlockChanged'; -import { type BlockMovedEvent, BlockMovedMutationType } from './BlockMoved'; -import { type BlockRemovedEvent, BlockRemovedMutationType } from './BlockRemoved'; +import type { BlockAddedEvent, BlockAddedMutationType } from './BlockAdded'; +import type { BlockChangedEvent, BlockChangedMutationType } from './BlockChanged'; +import type { BlockMovedEvent, BlockMovedMutationType } from './BlockMoved'; +import type { BlockRemovedEvent, BlockRemovedMutationType } from './BlockRemoved'; /** * Map for Custom Events related to block mutation types From 44c29dd645a03ab9b91e6f16f8249055be760b34 Mon Sep 17 00:00:00 2001 From: Thomas Brillion Date: Sun, 7 Jul 2024 00:27:47 +0700 Subject: [PATCH 43/61] feat(blocks-api): blocks.update() now can update `tunes` as well as `data` (#2720) * Update `tunes` data when new `tunes` data is provided AFAIK, when you update block using `editor.blocks.update` method, only `data` attribute is merged and updated. I believe `tunes` data should be updated if provided. * commit * edit parameter type, move test * restore package-lock and yarn lock * update in api docs * make data optional * edit changelog --------- Co-authored-by: Thomas Co-authored-by: Peter Savchenko --- docs/CHANGELOG.md | 1 + docs/api.md | 2 +- src/components/modules/api/blocks.ts | 10 +-- src/components/modules/blockManager.ts | 17 +++-- test/cypress/tests/api/blocks.cy.ts | 89 ++++++++++++++++++++++++++ types/api/blocks.d.ts | 6 +- 6 files changed, 113 insertions(+), 12 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 2dea81e0b..11a287123 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -4,6 +4,7 @@ - `New` – Block Tunes now supports nesting items - `New` – Block Tunes now supports separator items +- `Improvment` - The API `blocks.update` now accepts `tunes` data as optional third argument and makes `data` - block data as optional. - `New` – "Convert to" control is now also available in Block Tunes - `Improvement` — The ability to merge blocks of different types (if both tools provide the conversionConfig) - `Fix` — `onChange` will be called when removing the entire text within a descendant element of a block. diff --git a/docs/api.md b/docs/api.md index 3111cbf8d..4c7f708de 100644 --- a/docs/api.md +++ b/docs/api.md @@ -79,7 +79,7 @@ use 'move' instead) `insert(type?: string, data?: BlockToolData, config?: ToolConfig, index?: number, needToFocus?: boolean)` - insert new Block with passed parameters -`update(id: string, data: BlockToolData)` - updates data for the block with passed id +`update(id: string, data?: BlockToolData, tunes?: {[name: string]: BlockTuneData})` - updates block data and block tunes for the block with passed id #### SanitizerAPI diff --git a/src/components/modules/api/blocks.ts b/src/components/modules/api/blocks.ts index dbb7831a3..6c0c58696 100644 --- a/src/components/modules/api/blocks.ts +++ b/src/components/modules/api/blocks.ts @@ -4,7 +4,8 @@ import * as _ from './../../utils'; import BlockAPI from '../../block/api'; import Module from '../../__module'; import Block from '../../block'; -import { capitalize } from './../../utils'; +import { capitalize } from '../../utils'; +import { BlockTuneData } from '../../../../types/block-tunes/block-tune-data'; /** * @class BlocksAPI @@ -320,9 +321,10 @@ export default class BlocksAPI extends Module { * Updates block data by id * * @param id - id of the block to update - * @param data - the new data + * @param data - (optional) the new data + * @param tunes - (optional) tune data */ - public update = async (id: string, data: Partial): Promise => { + public update = async (id: string, data?: Partial, tunes?: {[name: string]: BlockTuneData}): Promise => { const { BlockManager } = this.Editor; const block = BlockManager.getBlockById(id); @@ -330,7 +332,7 @@ export default class BlocksAPI extends Module { throw new Error(`Block with id "${id}" not found`); } - const updatedBlock = await BlockManager.update(block, data); + const updatedBlock = await BlockManager.update(block, data, tunes); // we cast to any because our BlockAPI has no "new" signature // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/src/components/modules/blockManager.ts b/src/components/modules/blockManager.ts index 3442c0bdd..ec555f208 100644 --- a/src/components/modules/blockManager.ts +++ b/src/components/modules/blockManager.ts @@ -337,19 +337,26 @@ export default class BlockManager extends Module { * Update Block data. * * Currently we don't have an 'update' method in the Tools API, so we just create a new block with the same id and type - * Should not trigger 'block-removed' or 'block-added' events + * Should not trigger 'block-removed' or 'block-added' events. + * + * If neither data nor tunes is provided, return the provided block instead. * * @param block - block to update - * @param data - new data + * @param data - (optional) new data + * @param tunes - (optional) tune data */ - public async update(block: Block, data: Partial): Promise { + public async update(block: Block, data?: Partial, tunes?: {[name: string]: BlockTuneData}): Promise { + if (!data && !tunes) { + return block; + } + const existingData = await block.data; const newBlock = this.composeBlock({ id: block.id, tool: block.name, - data: Object.assign({}, existingData, data), - tunes: block.tunes, + data: Object.assign({}, existingData, data ?? {}), + tunes: tunes ?? block.tunes, }); const blockIndex = this.getBlockIndex(block); diff --git a/test/cypress/tests/api/blocks.cy.ts b/test/cypress/tests/api/blocks.cy.ts index 77f25e91e..66496bf71 100644 --- a/test/cypress/tests/api/blocks.cy.ts +++ b/test/cypress/tests/api/blocks.cy.ts @@ -1,6 +1,7 @@ import type EditorJS from '../../../../types/index'; import type { ConversionConfig, ToolboxConfig } from '../../../../types'; import ToolMock from '../../fixtures/tools/ToolMock'; +import {nanoid} from "nanoid"; /** * There will be described test cases of 'blocks.*' API @@ -102,6 +103,94 @@ describe('api.blocks', () => { }); }); + it('should update tune data when it is provided', () => { + /** + * Example Tune Class + */ + class ExampleTune { + + protected data: object; + /** + * + * @param data + */ + constructor({ data}) { + this.data = data; + } + + /** + * Tell editor.js that this Tool is a Block Tune + * + * @returns {boolean} + */ + public static get isTune(): boolean { + return true; + } + + /** + * Create Tunes controls wrapper that will be appended to the Block Tunes panel + * + * @returns {Element} + */ + public render(): Element { + return document.createElement('div'); + } + + /** + * CSS selectors used in Tune + */ + public static get CSS(): object { + return {}; + } + + /** + * Returns Tune state + * + * @returns {string} + */ + public save(): object | string { + return this.data || ''; + } + } + + + cy.createEditor({ + tools: { + exampleTune: ExampleTune, + }, + tunes: [ 'exampleTune' ], + data: { + blocks: [ + { + id: nanoid(), + type: 'paragraph', + data: { + text: 'First block', + }, + tunes: { + exampleTune: 'citation', + }, + }, + ], + }, + }).as('editorInstance'); + + // Update the tunes data of a block + // Check if it is updated + cy.get('@editorInstance') + .then(async (editor) => { + await editor.blocks.update(editor.blocks.getBlockByIndex(0).id, null, { + exampleTune: 'test', + }); + const data = await editor.save(); + + const actual = JSON.stringify(data.blocks[0].tunes); + const expected = JSON.stringify({ exampleTune: 'test' }); + + expect(actual).to.eq(expected); + }); + }); + /** * When incorrect id passed, editor should not update any block */ diff --git a/types/api/blocks.d.ts b/types/api/blocks.d.ts index 5085e99b8..7b9a0455e 100644 --- a/types/api/blocks.d.ts +++ b/types/api/blocks.d.ts @@ -2,6 +2,7 @@ import Block from '../../src/components/block'; import {OutputBlockData, OutputData} from '../data-formats/output-data'; import {BlockToolData, ToolConfig} from '../tools'; import {BlockAPI} from './block'; +import {BlockTuneData} from '../block-tunes/block-tune-data'; /** * Describes methods to manipulate with Editor`s blocks @@ -142,9 +143,10 @@ export interface Blocks { * Updates block data by id * * @param id - id of the block to update - * @param data - the new data. Can be partial. + * @param data - (optional) the new data. Can be partial. + * @param tunes - (optional) tune data */ - update(id: string, data: Partial): Promise; + update(id: string, data?: Partial, tunes?: {[name: string]: BlockTuneData}): Promise; /** * Converts block to another type. Both blocks should provide the conversionConfig. From ea5516532689affa4d73339fb986c6316735d694 Mon Sep 17 00:00:00 2001 From: Peter Date: Mon, 8 Jul 2024 22:09:45 +0300 Subject: [PATCH 44/61] chore(changelog): reorder changes in 2.30 (#2763) * Update CHANGELOG.md * Update CHANGELOG.md --- docs/CHANGELOG.md | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 11a287123..e284fcd02 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -4,35 +4,35 @@ - `New` – Block Tunes now supports nesting items - `New` – Block Tunes now supports separator items -- `Improvment` - The API `blocks.update` now accepts `tunes` data as optional third argument and makes `data` - block data as optional. -- `New` – "Convert to" control is now also available in Block Tunes -- `Improvement` — The ability to merge blocks of different types (if both tools provide the conversionConfig) -- `Fix` — `onChange` will be called when removing the entire text within a descendant element of a block. -- `Fix` - Unexpected new line on Enter press with selected block without caret -- `Fix` - Search input autofocus loosing after Block Tunes opening -- `Fix` - Block removing while Enter press on Block Tunes -- `Fix` – Unwanted scroll on first typing on iOS devices -- `Fix` - Unwanted soft line break on Enter press after period and space (". |") on iOS devices -- `Fix` - Caret lost after block conversion on mobile devices. -- `Fix` - Caret lost after Backspace at the start of block when previoius block is not convertable -- `Improvement` - The API `blocks.convert()` now returns the new block API -- `Improvement` - The API `caret.setToBlock()` now can accept either BlockAPI or block index or block id - `New` – *Menu Config* – New item type – HTML -– `Refactoring` – Switched to Vite as Cypress bundler – `New` – *Menu Config* – Default and HTML items now support hints - `New` – Inline Toolbar has new look 💅 - `New` – Inline Tool's `render()` now supports [Menu Config](https://editorjs.io/menu-config/) format - `New` – *ToolsAPI* – All installed block tools now accessible via ToolsAPI `getBlockTools()` method - `New` – *SelectionAPI* – Exposed methods `save()` and `restore()` that allow to save selection to be able to temporally move focus away, methods `setFakeBackground()` and `removeFakeBackground()` that allow to immitate selection while focus moved away -- `Impovement` – *MenuConfig* – TunesMenuConfig deprecated, use MenuConfig type instead - `New` – *BlocksAPI* – Exposed `getBlockByElement()` method that helps find block by any child html element -– `Fix` — Deleting whitespaces at the start/end of the block +- `New` – "Convert to" control is now also available in Block Tunes +- `New` — Editor.js now supports contenteditable placeholders out of the box. Just add `data-placeholder` or `data-placeholder-active` attribute to make it work. The first one will work like native placeholder while the second one will show placeholder only when block is current. +- `Improvement` — Now Paragraph placeholder will be shown for the current paragraph, not only the first one. +- `Improvment` - The API `blocks.update` now accepts `tunes` data as optional third argument and makes `data` - block data as optional. +- `Improvement` — The ability to merge blocks of different types (if both tools provide the conversionConfig) +- `Improvement` - The API `blocks.convert()` now returns the new block API +- `Improvement` - The API `caret.setToBlock()` now can accept either BlockAPI or block index or block id +- `Impovement` – *MenuConfig* – `TunesMenuConfig` type is deprecated, use the `MenuConfig` instead – `Improvement` — *Types* — `BlockToolConstructorOptions` type improved, `block` and `config` are not optional anymore - `Improvement` - The Plus button and Block Tunes toggler are now better aligned with large line-height blocks, such as Headings - `Improvement` — Creating links on Android devices: now the mobile keyboard will have an "Enter" key for accepting the inserted link. - `Improvement` — Placeholders will stay visible on inputs focus. -- `New` — Editor.js now supports contenteditable placeholders out of the box. Just add `data-placeholder` or `data-placeholder-active` attribute to make it work. The first one will work like native placeholder while the second one will show placeholder only when block is current. -- `Improvement` — Now Paragraph placeholder will be shown for the current paragraph, not the only first one. +– `Refactoring` – Switched to Vite as Cypress bundler +- `Fix` — `onChange` will be called when removing the entire text within a descendant element of a block. +- `Fix` - Unexpected new line on Enter press with selected block without caret +- `Fix` - Search input autofocus loosing after Block Tunes opening +- `Fix` - Block removing while Enter press on Block Tunes +- `Fix` – Unwanted scroll on first typing on iOS devices +- `Fix` - Unwanted soft line break on Enter press after period and space (". |") on iOS devices +- `Fix` - Caret lost after block conversion on mobile devices. +- `Fix` - Caret lost after Backspace at the start of block when previoius block is not convertable +– `Fix` — Deleting whitespaces at the start/end of the block - `Fix` — The problem caused by missed "import type" in block mutation event types resolved ### 2.29.1 From 54791d07b628bdaa3159ee676122034ed821ef89 Mon Sep 17 00:00:00 2001 From: Tatiana Fomina Date: Mon, 8 Jul 2024 23:02:42 +0300 Subject: [PATCH 45/61] Update readme (#2762) * Update readme * Update README.md --------- Co-authored-by: Peter --- README.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 2ea846ead..eff9df7e1 100644 --- a/README.md +++ b/README.md @@ -116,14 +116,17 @@ Take a look at the [example.html](example/example.html) to view more detailed ex -- Unified Toolbox +- Unified Toolbars - [x] Block Tunes moved left - [x] Toolbox becomes vertical - [x] Ability to display several Toolbox buttons by the single Tool - [x] Block Tunes become vertical - - [ ] Block Tunes support nested menus - - [ ] Conversion Toolbar uses Unified Toolbox - - [ ] Conversion Toolbar added to the Block Tunes + - [x] Block Tunes support nested menus + - [x] Block Tunes support separators + - [x] Conversion Menu added to the Block Tunes + - [x] Unified Toolbar supports hints + - [x] Conversion Toolbar uses Unified Toolbar + - [x] Inline Toolbar uses Unified Toolbar - Collaborative editing - [ ] Implement Inline Tools JSON format - [ ] Operations Observer, Executor, Manager, Transformer From 4a4981e1d8d2bb9f1a6cabde40b00444d830ecc7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 9 Jul 2024 12:11:41 +0300 Subject: [PATCH 46/61] Bump version up to 2.30.0 (#2761) * Bump version * Update package.json --------- Co-authored-by: github-actions Co-authored-by: Peter --- package.json | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 89de03c08..39a0eed18 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@editorjs/editorjs", - "version": "2.30.0-rc.14", - "description": "Editor.js — Native JS, based on API and Open Source", + "version": "2.30.0", + "description": "Editor.js — open source block-style WYSIWYG editor with JSON output", "main": "dist/editorjs.umd.js", "module": "dist/editorjs.mjs", "types": "./types/index.d.ts", @@ -10,7 +10,8 @@ "text editor", "editor", "editor.js", - "editorjs" + "editorjs", + "wysiwyg" ], "scripts": { "dev": "vite", From ba8fa7344a062e99c4f957cc26549d7a655188f0 Mon Sep 17 00:00:00 2001 From: Tatiana Fomina Date: Tue, 9 Jul 2024 21:46:42 +0300 Subject: [PATCH 47/61] fix(Inline-toolbar): fake selection stays on screen after toggling convert to (#2768) * Fix not removing fake selection on convert to toggle * Update version and changelog * Update docs/CHANGELOG.md Co-authored-by: Peter --------- Co-authored-by: Peter --- docs/CHANGELOG.md | 4 ++++ package.json | 2 +- src/components/utils/popover/popover-inline.ts | 3 ++- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index e284fcd02..a6adcff46 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +### 2.30.1 + +- `Fix` – Remove fake selection after multiple "convert to" inline tool toggles + ### 2.30.0 - `New` – Block Tunes now supports nesting items diff --git a/package.json b/package.json index 39a0eed18..7bd3d0257 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@editorjs/editorjs", - "version": "2.30.0", + "version": "2.30.1", "description": "Editor.js — open source block-style WYSIWYG editor with JSON output", "main": "dist/editorjs.umd.js", "module": "dist/editorjs.mjs", diff --git a/src/components/utils/popover/popover-inline.ts b/src/components/utils/popover/popover-inline.ts index 78d75a30e..a82db45cf 100644 --- a/src/components/utils/popover/popover-inline.ts +++ b/src/components/utils/popover/popover-inline.ts @@ -127,8 +127,9 @@ export class PopoverInline extends PopoverDesktop { */ protected override showNestedItems(item: PopoverItemDefault | PopoverItemHtml): void { if (this.nestedPopoverTriggerItem === item) { - this.nestedPopoverTriggerItem = null; this.destroyNestedPopoverIfExists(); + + this.nestedPopoverTriggerItem = null; return; } From 91959bba4397a024449955c377809ae6b7819196 Mon Sep 17 00:00:00 2001 From: Peter Date: Wed, 10 Jul 2024 19:16:36 +0300 Subject: [PATCH 48/61] fix(on-change): onchange callback wont be fired in readonly (#2773) * fix(on-change): onchange callback wont be fired in readonly * do not rerender blocks on initial call --- docs/CHANGELOG.md | 4 ++ src/components/constants.ts | 6 ++ .../modules/modificationsObserver.ts | 3 +- src/components/modules/readonly.ts | 21 +++++- test/cypress/tests/onchange.cy.ts | 67 +++++++++++++++++-- 5 files changed, 93 insertions(+), 8 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index a6adcff46..32cbd889b 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +### 2.30.2 + +- `Fix` – The onChange callback won't be fired when editor is initialized in the Read-Only mode + ### 2.30.1 - `Fix` – Remove fake selection after multiple "convert to" inline tool toggles diff --git a/src/components/constants.ts b/src/components/constants.ts index 0fe2aac08..494274548 100644 --- a/src/components/constants.ts +++ b/src/components/constants.ts @@ -3,3 +3,9 @@ * {@link modules/ui.ts} */ export const selectionChangeDebounceTimeout = 180; + +/** + * Timeout for batching of DOM changes used by the ModificationObserver + * {@link modules/modificationsObserver.ts} + */ +export const modificationsObserverBatchTimeout = 400; diff --git a/src/components/modules/modificationsObserver.ts b/src/components/modules/modificationsObserver.ts index 701bcb552..10e3aa566 100644 --- a/src/components/modules/modificationsObserver.ts +++ b/src/components/modules/modificationsObserver.ts @@ -2,6 +2,7 @@ import { BlockId } from '../../../types'; import { BlockMutationEvent, BlockMutationType } from '../../../types/events/block'; import { ModuleConfig } from '../../types-internal/module-config'; import Module from '../__module'; +import { modificationsObserverBatchTimeout } from '../constants'; import { BlockChanged, FakeCursorAboutToBeToggled, FakeCursorHaveBeenSet, RedactorDomChanged } from '../events'; import * as _ from '../utils'; @@ -39,7 +40,7 @@ export default class ModificationsObserver extends Module { /** * Fired onChange events will be batched by this time */ - private readonly batchTime = 400; + private readonly batchTime = modificationsObserverBatchTimeout; /** * Prepare the module diff --git a/src/components/modules/readonly.ts b/src/components/modules/readonly.ts index 973f459b8..cee5aa874 100644 --- a/src/components/modules/readonly.ts +++ b/src/components/modules/readonly.ts @@ -52,16 +52,17 @@ export default class ReadOnly extends Module { this.throwCriticalError(); } - this.toggle(this.config.readOnly); + this.toggle(this.config.readOnly, true); } /** * Set read-only mode or toggle current state * Call all Modules `toggleReadOnly` method and re-render Editor * - * @param {boolean} state - (optional) read-only state or toggle + * @param state - (optional) read-only state or toggle + * @param isInitial - (optional) true when editor is initializing */ - public async toggle(state = !this.readOnlyEnabled): Promise { + public async toggle(state = !this.readOnlyEnabled, isInitial = false): Promise { if (state && this.toolsDontSupportReadOnly.length > 0) { this.throwCriticalError(); } @@ -91,6 +92,18 @@ export default class ReadOnly extends Module { return this.readOnlyEnabled; } + /** + * Do not re-render blocks if it's initial call + */ + if (isInitial) { + return this.readOnlyEnabled; + } + + /** + * Mutex for modifications observer to prevent onChange call when read-only mode is enabled + */ + this.Editor.ModificationsObserver.disable(); + /** * Save current Editor Blocks and render again */ @@ -99,6 +112,8 @@ export default class ReadOnly extends Module { await this.Editor.BlockManager.clear(); await this.Editor.Renderer.render(savedBlocks.blocks); + this.Editor.ModificationsObserver.enable(); + return this.readOnlyEnabled; } diff --git a/test/cypress/tests/onchange.cy.ts b/test/cypress/tests/onchange.cy.ts index b7dbbc170..6727aea8d 100644 --- a/test/cypress/tests/onchange.cy.ts +++ b/test/cypress/tests/onchange.cy.ts @@ -7,6 +7,7 @@ import { BlockChangedMutationType } from '../../../types/events/block/BlockChang import { BlockRemovedMutationType } from '../../../types/events/block/BlockRemoved'; import { BlockMovedMutationType } from '../../../types/events/block/BlockMoved'; import type EditorJS from '../../../types/index'; +import { modificationsObserverBatchTimeout } from '../../../src/components/constants'; /** * EditorJS API is passed as the first parameter of the onChange callback @@ -455,7 +456,7 @@ describe('onChange callback', () => { .get('div.ce-block') .click(); - cy.wait(500).then(() => { + cy.wait(modificationsObserverBatchTimeout).then(() => { cy.get('@onChange').should('have.callCount', 0); }); }); @@ -540,7 +541,7 @@ describe('onChange callback', () => { /** * Check that onChange callback was not called */ - cy.wait(500).then(() => { + cy.wait(modificationsObserverBatchTimeout).then(() => { cy.get('@onChange').should('have.callCount', 0); }); }); @@ -607,7 +608,7 @@ describe('onChange callback', () => { /** * Check that onChange callback was not called */ - cy.wait(500).then(() => { + cy.wait(modificationsObserverBatchTimeout).then(() => { cy.get('@onChange').should('have.callCount', 0); }); }); @@ -678,7 +679,7 @@ describe('onChange callback', () => { /** * Check that onChange callback was not called */ - cy.wait(500).then(() => { + cy.wait(modificationsObserverBatchTimeout).then(() => { cy.get('@onChange').should('have.callCount', 0); }); }); @@ -747,6 +748,8 @@ describe('onChange callback', () => { })); }); + cy.wait(modificationsObserverBatchTimeout); + cy.get('@onChange').should('have.callCount', 0); }); @@ -845,4 +848,60 @@ describe('onChange callback', () => { }, })); }); + + it('should not be called when editor is initialized with readOnly mode', () => { + const config = { + readOnly: true, + onChange: (api, event): void => { + console.log('something changed', event); + }, + data: { + blocks: [ + { + type: 'paragraph', + data: { + text: 'The first paragraph', + }, + }, + ], + }, + }; + + cy.spy(config, 'onChange').as('onChange'); + + cy.createEditor(config); + + cy.wait(modificationsObserverBatchTimeout); + + cy.get('@onChange').should('have.callCount', 0); + }); + + it('should not be called when editor is switched to/from readOnly mode', () => { + createEditor([ + { + type: 'paragraph', + data: { + text: 'The first paragraph', + }, + }, + ]); + + cy.get('@editorInstance') + .then(async editor => { + editor.readOnly.toggle(true); + }); + + cy.wait(modificationsObserverBatchTimeout); + + cy.get('@onChange').should('have.callCount', 0); + + cy.get('@editorInstance') + .then(async editor => { + editor.readOnly.toggle(false); + }); + + cy.wait(modificationsObserverBatchTimeout); + + cy.get('@onChange').should('have.callCount', 0); + }); }); From 15a8fb4bec8e6ab3c071580dcfe65a9ea3f6e1ac Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 10 Jul 2024 19:26:56 +0300 Subject: [PATCH 49/61] Bump version (#2774) Co-authored-by: github-actions --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 7bd3d0257..0f7fba940 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@editorjs/editorjs", - "version": "2.30.1", + "version": "2.30.2-rc.0", "description": "Editor.js — open source block-style WYSIWYG editor with JSON output", "main": "dist/editorjs.umd.js", "module": "dist/editorjs.mjs", From b6674367a59f6325dbc01f0ef28b7a08a0085d2f Mon Sep 17 00:00:00 2001 From: Tatiana Fomina Date: Wed, 10 Jul 2024 23:13:37 +0300 Subject: [PATCH 50/61] Fix form submit on bold inline tool click (#2775) --- docs/CHANGELOG.md | 1 + .../popover-item-default.ts | 4 ++- .../cypress/tests/modules/InlineToolbar.cy.ts | 36 +++++++++++++++++++ 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 32cbd889b..da491a031 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -3,6 +3,7 @@ ### 2.30.2 - `Fix` – The onChange callback won't be fired when editor is initialized in the Read-Only mode +- `Fix` – Prevent form submit on inline tool click ### 2.30.1 diff --git a/src/components/utils/popover/components/popover-item/popover-item-default/popover-item-default.ts b/src/components/utils/popover/components/popover-item/popover-item-default/popover-item-default.ts index 1acd13f7e..df50e73e4 100644 --- a/src/components/utils/popover/components/popover-item/popover-item-default/popover-item-default.ts +++ b/src/components/utils/popover/components/popover-item/popover-item-default/popover-item-default.ts @@ -147,7 +147,9 @@ export class PopoverItemDefault extends PopoverItem { */ private make(params: PopoverItemDefaultParams, renderParams?: PopoverItemRenderParamsMap[PopoverItemType.Default]): HTMLElement { const tag = renderParams?.wrapperTag || 'div'; - const el = Dom.make(tag, css.container); + const el = Dom.make(tag, css.container, { + type: tag === 'button' ? 'button' : undefined, + }); if (params.name) { el.dataset.itemName = params.name; diff --git a/test/cypress/tests/modules/InlineToolbar.cy.ts b/test/cypress/tests/modules/InlineToolbar.cy.ts index dfd5c3968..bc8990bda 100644 --- a/test/cypress/tests/modules/InlineToolbar.cy.ts +++ b/test/cypress/tests/modules/InlineToolbar.cy.ts @@ -76,6 +76,42 @@ describe('Inline Toolbar', () => { }); }); + it('should not submit form nesting editor when inline tool clicked', () => { + cy.createEditor({ + data: { + blocks: [ + { + type: 'paragraph', + data: { + text: 'Some text', + }, + }, + ], + }, + }); + + const onSubmit = cy.stub(); + + cy.document().then(doc => { + const form = doc.createElement('form'); + + form.onsubmit = onSubmit; + doc.body.appendChild(form); + + /* Move editor to form */ + form.appendChild(doc.getElementById('editorjs')); + + cy.get('[data-cy=editorjs]') + .find('.ce-paragraph') + .selectText('Some text'); + + cy.get('[data-item-name=bold]') + .click(); + + expect(onSubmit).to.be.not.called; + }); + }); + describe('Conversion toolbar', () => { it('should restore caret after converting of a block', () => { cy.createEditor({ From 89e192a56d0107793209e246f92a0abd6ab1da39 Mon Sep 17 00:00:00 2001 From: Tatiana Fomina Date: Wed, 10 Jul 2024 23:26:42 +0300 Subject: [PATCH 51/61] fix(ConvertTo): i18n (#2776) * Fix i18n for convert to * Add tests * Update changelog --------- Co-authored-by: Peter --- docs/CHANGELOG.md | 1 + .../inline-tools/inline-tool-convert.ts | 6 +- .../modules/toolbar/blockSettings.ts | 3 +- test/cypress/tests/i18n.cy.ts | 98 +++++++++++++++++++ 4 files changed, 105 insertions(+), 3 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index da491a031..c2dec5f04 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -3,6 +3,7 @@ ### 2.30.2 - `Fix` – The onChange callback won't be fired when editor is initialized in the Read-Only mode +- `Fix` – Convert To supports i18n again - `Fix` – Prevent form submit on inline tool click ### 2.30.1 diff --git a/src/components/inline-tools/inline-tool-convert.ts b/src/components/inline-tools/inline-tool-convert.ts index 053a14a4a..71d2e5249 100644 --- a/src/components/inline-tools/inline-tool-convert.ts +++ b/src/components/inline-tools/inline-tool-convert.ts @@ -2,9 +2,11 @@ import { IconReplace } from '@codexteam/icons'; import { InlineTool, API } from '../../../types'; import { MenuConfig, MenuConfigItem } from '../../../types/tools'; import * as _ from '../utils'; -import { Blocks, Selection, Tools, I18n, Caret } from '../../../types/api'; +import { Blocks, Selection, Tools, Caret, I18n } from '../../../types/api'; import SelectionUtils from '../selection'; import { getConvertibleToolsForBlock } from '../utils/blocks'; +import I18nInternal from '../i18n'; +import { I18nInternalNS } from '../i18n/namespace-internal'; /** * Inline tools for converting blocks @@ -73,7 +75,7 @@ export default class ConvertInlineTool implements InlineTool { tool.toolbox?.forEach((toolboxItem) => { result.push({ icon: toolboxItem.icon, - title: toolboxItem.title, + title: I18nInternal.t(I18nInternalNS.toolNames, toolboxItem.title), name: tool.name, closeOnActivate: true, onActivate: async () => { diff --git a/src/components/modules/toolbar/blockSettings.ts b/src/components/modules/toolbar/blockSettings.ts index 7f3a67fe0..ef47da288 100644 --- a/src/components/modules/toolbar/blockSettings.ts +++ b/src/components/modules/toolbar/blockSettings.ts @@ -219,7 +219,7 @@ export default class BlockSettings extends Module { tool.toolbox.forEach((toolboxItem) => { result.push({ icon: toolboxItem.icon, - title: toolboxItem.title, + title: I18n.t(I18nInternalNS.toolNames, toolboxItem.title), name: tool.name, closeOnActivate: true, onActivate: async () => { @@ -240,6 +240,7 @@ export default class BlockSettings extends Module { if (convertToItems.length > 0) { items.push({ icon: IconReplace, + name: 'convert-to', title: I18n.ui(I18nInternalNS.ui.popover, 'Convert to'), children: { searchable: true, diff --git a/test/cypress/tests/i18n.cy.ts b/test/cypress/tests/i18n.cy.ts index cec4f2c04..b17df991e 100644 --- a/test/cypress/tests/i18n.cy.ts +++ b/test/cypress/tests/i18n.cy.ts @@ -142,4 +142,102 @@ describe('Editor i18n', () => { .should('contain.text', toolNamesDictionary.TestTool); }); }); + + context('Block Tunes', () => { + it('should translate tool name in Convert To', () => { + const toolNamesDictionary = { + Heading: 'Заголовок', + }; + + cy.createEditor({ + tools: { + header: Header, + }, + i18n: { + messages: { + toolNames: toolNamesDictionary, + }, + }, + data: { + blocks: [ + { + type: 'paragraph', + data: { + text: 'Some text', + level: 1, + }, + }, + ], + }, + }); + + cy.get('[data-cy=editorjs]') + .get('div.ce-block') + .click(); + + /** Open block tunes menu */ + cy.get('[data-cy=editorjs]') + .get('.ce-block') + .click(); + + cy.get('[data-cy=editorjs]') + .get('.ce-toolbar__settings-btn') + .click(); + + /** Open "Convert to" menu */ + cy.get('[data-cy=editorjs]') + .get('[data-item-name=convert-to]') + .click(); + + /** Check item in convert to menu is internationalized */ + cy.get('[data-cy=editorjs]') + .get('.ce-popover--nested .ce-popover-item[data-item-name=header]') + .should('contain.text', toolNamesDictionary.Heading); + }); + }); + + context('Inline Toolbar', () => { + it('should translate tool name in Convert To', () => { + const toolNamesDictionary = { + Heading: 'Заголовок', + }; + + cy.createEditor({ + tools: { + header: Header, + }, + i18n: { + messages: { + toolNames: toolNamesDictionary, + }, + }, + data: { + blocks: [ + { + type: 'paragraph', + data: { + text: 'Some text', + level: 1, + }, + }, + ], + }, + }); + + /** Open Inline Toolbar */ + cy.get('[data-cy=editorjs]') + .find('.ce-paragraph') + .selectText('Some text'); + + /** Open "Convert to" menu */ + cy.get('[data-cy=editorjs]') + .get('[data-item-name=convert-to]') + .click(); + + /** Check item in convert to menu is internationalized */ + cy.get('[data-cy=editorjs]') + .get('.ce-popover--nested .ce-popover-item[data-item-name=header]') + .should('contain.text', toolNamesDictionary.Heading); + }); + }); }); From 94109a8123b1ef1bd07f119d00ea6d4821c090dd Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 11 Jul 2024 13:23:15 +0300 Subject: [PATCH 52/61] Bump version up to 2.30.2 (#2777) * Bump version * Update package.json --------- Co-authored-by: github-actions Co-authored-by: Peter --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 0f7fba940..c6ffe9de8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@editorjs/editorjs", - "version": "2.30.2-rc.0", + "version": "2.30.2", "description": "Editor.js — open source block-style WYSIWYG editor with JSON output", "main": "dist/editorjs.umd.js", "module": "dist/editorjs.mjs", From 057bf17a6fc2d5e05c662107918d7c3e943d077c Mon Sep 17 00:00:00 2001 From: Tatiana Fomina Date: Thu, 11 Jul 2024 22:25:36 +0300 Subject: [PATCH 53/61] Fix i18n in nested popover (#2779) --- docs/CHANGELOG.md | 4 + package.json | 2 +- .../utils/popover/popover-desktop.ts | 1 + test/cypress/tests/utils/popover.cy.ts | 89 +++++++++++++++++++ 4 files changed, 95 insertions(+), 1 deletion(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index c2dec5f04..09401cceb 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +### 2.30.3 + +- `Fix` – I18n in nested popover + ### 2.30.2 - `Fix` – The onChange callback won't be fired when editor is initialized in the Read-Only mode diff --git a/package.json b/package.json index c6ffe9de8..c5c1bd3d3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@editorjs/editorjs", - "version": "2.30.2", + "version": "2.30.3", "description": "Editor.js — open source block-style WYSIWYG editor with JSON output", "main": "dist/editorjs.umd.js", "module": "dist/editorjs.mjs", diff --git a/src/components/utils/popover/popover-desktop.ts b/src/components/utils/popover/popover-desktop.ts index 73cd10303..7adff41f8 100644 --- a/src/components/utils/popover/popover-desktop.ts +++ b/src/components/utils/popover/popover-desktop.ts @@ -255,6 +255,7 @@ export class PopoverDesktop extends PopoverAbstract { items: item.children, nestingLevel: this.nestingLevel + 1, flippable: item.isChildrenFlippable, + messages: this.messages, }); item.onChildrenOpen(); diff --git a/test/cypress/tests/utils/popover.cy.ts b/test/cypress/tests/utils/popover.cy.ts index 49fa92901..748747ca1 100644 --- a/test/cypress/tests/utils/popover.cy.ts +++ b/test/cypress/tests/utils/popover.cy.ts @@ -881,6 +881,95 @@ describe('Popover', () => { .should('exist'); }); + it.only('shoould support i18n in nested popover', () => { + /** + * + */ + class TestTune { + public static isTune = true; + + /** Tool data displayed in block tunes popover */ + public render(): MenuConfig { + return { + icon: 'Icon', + title: 'Title', + toggle: 'key', + name: 'test-item', + children: { + searchable: true, + items: [ + { + icon: 'Icon', + title: 'Title', + name: 'nested-test-item', + onActivate: (): void => {}, + }, + ], + }, + }; + } + } + + /** Create editor instance */ + cy.createEditor({ + tools: { + testTool: TestTune, + }, + tunes: [ 'testTool' ], + data: { + blocks: [ + { + type: 'paragraph', + data: { + text: 'Hello', + }, + }, + ], + }, + i18n: { + messages: { + ui: { + popover: { + 'Filter': 'Искать', + // eslint-disable-next-line @typescript-eslint/naming-convention -- i18n + 'Nothing found': 'Ничего не найдено', + }, + }, + }, + }, + }); + + /** Open block tunes menu */ + cy.get('[data-cy=editorjs]') + .get('.cdx-block') + .click(); + + cy.get('[data-cy=editorjs]') + .get('.ce-toolbar__settings-btn') + .click(); + + /** Click the item */ + cy.get('[data-cy=editorjs]') + .get('[data-item-name="test-item"]') + .click(); + + /** Check nested popover search input has placeholder text with i18n */ + cy.get('[data-cy=editorjs]') + .get('[data-cy=block-tunes] .ce-popover--nested .cdx-search-field__input') + .invoke('attr', 'placeholder') + .should('eq', 'Искать'); + + /** Enter search query */ + cy.get('[data-cy=editorjs]') + .get('[data-cy=block-tunes] .ce-popover--nested .cdx-search-field__input') + .type('Some text'); + + /** Check nested popover has nothing found message with i18n */ + cy.get('[data-cy=editorjs]') + .get('[data-cy=block-tunes] .ce-popover--nested .ce-popover__nothing-found-message') + .should('have.text', 'Ничего не найдено'); + }); + describe('Inline Popover', () => { it('should open nested popover on click instead of hover', () => { cy.createEditor({ From 3d01be4a692218e0c73d3fd90ff909a247d114a6 Mon Sep 17 00:00:00 2001 From: Peter Date: Fri, 9 Aug 2024 18:04:57 +0300 Subject: [PATCH 54/61] fix(types): tools export types added (#2802) * fix types export * lint * changelog * tests fixed * fix tests imports * lint tests * upd submodule * Update yarn.lock * rename wrapper to factory * Update package.json * rename to adapters * Update tools.d.ts * BlockTool -> BlockToolAdapter etc * Update nested-list * Update collection.ts --- .eslintrc | 21 +++- cypress.config.ts | 8 +- docs/CHANGELOG.md | 4 + example/tools/nested-list | 2 +- example/tools/text-variant-tune | 2 +- package.json | 4 +- src/codex.ts | 2 +- src/components/__module.ts | 10 +- .../block-tunes/block-tune-delete.ts | 4 +- .../block-tunes/block-tune-move-down.ts | 4 +- .../block-tunes/block-tune-move-up.ts | 4 +- src/components/block/api.ts | 8 +- src/components/block/index.ts | 31 +++--- src/components/blocks.ts | 5 +- src/components/core.ts | 6 +- src/components/events/BlockChanged.ts | 2 +- src/components/events/index.ts | 17 ++-- src/components/i18n/index.ts | 4 +- src/components/i18n/namespace-internal.ts | 2 +- .../inline-tools/inline-tool-bold.ts | 4 +- .../inline-tools/inline-tool-convert.ts | 6 +- .../inline-tools/inline-tool-italic.ts | 2 +- .../inline-tools/inline-tool-link.ts | 4 +- src/components/modules/api/blocks.ts | 4 +- src/components/modules/api/caret.ts | 2 +- src/components/modules/api/events.ts | 2 +- src/components/modules/api/i18n.ts | 2 +- src/components/modules/api/index.ts | 2 +- src/components/modules/api/inlineToolbar.ts | 2 +- src/components/modules/api/listeners.ts | 2 +- src/components/modules/api/notifier.ts | 6 +- src/components/modules/api/readonly.ts | 2 +- src/components/modules/api/sanitizer.ts | 4 +- src/components/modules/api/saver.ts | 4 +- src/components/modules/api/selection.ts | 2 +- src/components/modules/api/styles.ts | 2 +- src/components/modules/api/toolbar.ts | 2 +- src/components/modules/api/tools.ts | 2 +- src/components/modules/api/tooltip.ts | 4 +- src/components/modules/api/ui.ts | 2 +- src/components/modules/blockManager.ts | 6 +- src/components/modules/blockSelection.ts | 4 +- src/components/modules/caret.ts | 2 +- src/components/modules/crossBlockSelection.ts | 2 +- .../modules/modificationsObserver.ts | 6 +- src/components/modules/paste.ts | 20 ++-- src/components/modules/renderer.ts | 6 +- src/components/modules/saver.ts | 6 +- .../modules/toolbar/blockSettings.ts | 11 ++- src/components/modules/toolbar/index.ts | 4 +- src/components/modules/toolbar/inline.ts | 7 +- src/components/modules/tools.ts | 32 +++---- src/components/tools/base.ts | 43 +++------ src/components/tools/block.ts | 20 ++-- src/components/tools/collection.ts | 30 +++--- src/components/tools/factory.ts | 22 ++--- src/components/tools/inline.ts | 10 +- src/components/tools/tune.ts | 12 ++- src/components/ui/toolbox.ts | 25 ++--- src/components/utils/api.ts | 4 +- src/components/utils/blocks.ts | 8 +- src/components/utils/notifier.ts | 3 +- .../utils/popover/components/hint/hint.ts | 2 +- .../utils/popover/components/hint/index.ts | 6 +- .../popover-header/popover-header.ts | 2 +- .../popover/components/popover-item/index.ts | 3 +- .../popover-item-default.ts | 4 +- .../popover-item-html/popover-item-html.ts | 2 +- .../components/popover-item/popover-item.ts | 2 +- .../components/search-input/search-input.ts | 3 +- src/components/utils/popover/index.ts | 4 +- .../utils/popover/popover-abstract.ts | 10 +- .../utils/popover/popover-desktop.ts | 9 +- .../utils/popover/popover-inline.ts | 7 +- .../utils/popover/popover-mobile.ts | 5 +- .../popover/utils/popover-states-history.ts | 6 +- src/components/utils/sanitizer.ts | 4 +- src/components/utils/tools.ts | 4 +- src/tools/stub/index.ts | 2 +- .../cypress/fixtures/tools/ContentlessTool.ts | 2 +- test/cypress/fixtures/tools/SimpleHeader.ts | 2 +- test/cypress/fixtures/tools/ToolMock.ts | 2 +- .../types/PartialBlockMutationEvent.ts | 2 +- test/cypress/plugins/index.ts | 15 --- test/cypress/support/e2e.ts | 3 +- .../utils/createEditorWithTextBlocks.ts | 2 +- test/cypress/tests/api/block.cy.ts | 2 +- test/cypress/tests/api/blocks.cy.ts | 5 +- test/cypress/tests/api/caret.cy.ts | 2 +- test/cypress/tests/api/toolbar.cy.ts | 2 +- test/cypress/tests/api/tools.cy.ts | 6 +- test/cypress/tests/api/tunes.cy.ts | 2 +- test/cypress/tests/copy-paste.cy.ts | 2 +- test/cypress/tests/i18n.cy.ts | 2 +- test/cypress/tests/modules/Tools.cy.ts | 6 +- test/cypress/tests/readOnly.cy.ts | 3 +- test/cypress/tests/sanitisation.cy.ts | 2 +- test/cypress/tests/tools/BlockTool.cy.ts | 96 +++++++++---------- test/cypress/tests/tools/BlockTune.cy.ts | 34 +++---- test/cypress/tests/tools/InlineTool.cy.ts | 40 ++++---- .../cypress/tests/tools/ToolsCollection.cy.ts | 22 ++--- test/cypress/tests/tools/ToolsFactory.cy.ts | 12 +-- test/cypress/tests/ui/BlockTunes.cy.ts | 4 +- test/cypress/tests/ui/InlineToolbar.cy.ts | 2 +- test/cypress/tests/ui/toolbox.cy.ts | 2 +- test/cypress/tests/utils/flipper.cy.ts | 2 +- test/cypress/tests/utils/popover.cy.ts | 4 +- test/cypress/tsconfig.json | 20 ++-- tsconfig.build.json | 2 +- tsconfig.json | 14 ++- types/api/blocks.d.ts | 1 - types/api/tools.d.ts | 4 +- types/block-tunes/block-tune.d.ts | 5 +- types/configs/index.d.ts | 1 - types/index.d.ts | 5 +- types/tools/adapters/base-tool-adapter.d.ts | 76 +++++++++++++++ types/tools/adapters/block-tool-adapter.d.ts | 78 +++++++++++++++ types/tools/adapters/block-tune-adapter.d.ts | 14 +++ types/tools/adapters/inline-tool-adapter.d.ts | 15 +++ types/tools/adapters/tool-factory.d.ts | 5 + types/tools/adapters/tool-type.ts | 18 ++++ types/tools/adapters/tools-collection.d.ts | 34 +++++++ .../utils/popover/hint.d.ts | 0 types/utils/popover/index.d.ts | 5 + types/utils/popover/popover-event.ts | 15 +++ types/utils/popover/popover-item-type.ts | 13 +++ .../utils/popover/popover-item.d.ts | 17 +--- .../utils/popover/popover.d.ts | 17 +--- vite.config.js | 6 ++ vite.config.test.js | 18 ++++ yarn.lock | 24 +++-- 131 files changed, 776 insertions(+), 471 deletions(-) delete mode 100644 test/cypress/plugins/index.ts create mode 100644 types/tools/adapters/base-tool-adapter.d.ts create mode 100644 types/tools/adapters/block-tool-adapter.d.ts create mode 100644 types/tools/adapters/block-tune-adapter.d.ts create mode 100644 types/tools/adapters/inline-tool-adapter.d.ts create mode 100644 types/tools/adapters/tool-factory.d.ts create mode 100644 types/tools/adapters/tool-type.ts create mode 100644 types/tools/adapters/tools-collection.d.ts rename src/components/utils/popover/components/hint/hint.types.ts => types/utils/popover/hint.d.ts (100%) create mode 100644 types/utils/popover/index.d.ts create mode 100644 types/utils/popover/popover-event.ts create mode 100644 types/utils/popover/popover-item-type.ts rename src/components/utils/popover/components/popover-item/popover-item.types.ts => types/utils/popover/popover-item.d.ts (93%) rename src/components/utils/popover/popover.types.ts => types/utils/popover/popover.d.ts (85%) create mode 100644 vite.config.test.js diff --git a/.eslintrc b/.eslintrc index 1548af3a4..4504754c2 100644 --- a/.eslintrc +++ b/.eslintrc @@ -36,6 +36,23 @@ "navigator": true }, "rules": { - "jsdoc/require-returns-type": "off" - } + "jsdoc/require-returns-type": "off", + "@typescript-eslint/strict-boolean-expressions": "warn", + "@typescript-eslint/consistent-type-imports": "error", + "@typescript-eslint/consistent-type-exports": "error" + }, + "overrides": [ + { + "files": [ + "tsconfig.json", + "package.json", + "tsconfig.*.json", + "tslint.json" + ], + "rules": { + "quotes": [1, "double"], + "semi": [1, "never"], + } + } + ] } diff --git a/cypress.config.ts b/cypress.config.ts index f0e4573a2..58f69cfe7 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -1,4 +1,6 @@ import { defineConfig } from 'cypress'; +import path from 'node:path'; +import vitePreprocessor from 'cypress-vite'; export default defineConfig({ env: { @@ -12,7 +14,9 @@ export default defineConfig({ // We've imported your old cypress plugins here. // You may want to clean this up later by importing these. setupNodeEvents(on, config) { - on('file:preprocessor', require('cypress-vite')(config)); + on('file:preprocessor', vitePreprocessor({ + configFile: path.resolve(__dirname, './vite.config.test.js'), + })); /** * Plugin for cypress that adds better terminal output for easier debugging. @@ -21,7 +25,7 @@ export default defineConfig({ */ require('cypress-terminal-report/src/installLogsPrinter')(on); - require('./test/cypress/plugins/index.ts')(on, config); + require('@cypress/code-coverage/task')(on, config); }, specPattern: 'test/cypress/tests/**/*.cy.{js,jsx,ts,tsx}', supportFile: 'test/cypress/support/index.ts', diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 09401cceb..6c6bf200b 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +### 2.30.4 + +- `Fix` - Tool's exporting types added + ### 2.30.3 - `Fix` – I18n in nested popover diff --git a/example/tools/nested-list b/example/tools/nested-list index 95b37462d..591bd2ca6 160000 --- a/example/tools/nested-list +++ b/example/tools/nested-list @@ -1 +1 @@ -Subproject commit 95b37462dc93c19b83f0481f509034a40d436cf2 +Subproject commit 591bd2ca6839f923e3d37a1ebaad6e5d15075a89 diff --git a/example/tools/text-variant-tune b/example/tools/text-variant-tune index a80eefb40..7f51a16d4 160000 --- a/example/tools/text-variant-tune +++ b/example/tools/text-variant-tune @@ -1 +1 @@ -Subproject commit a80eefb4007c85a52085897398dd28afc5bfd81f +Subproject commit 7f51a16d4ab46eff9364f09cea52e09518896d2a diff --git a/package.json b/package.json index c5c1bd3d3..b93c73292 100644 --- a/package.json +++ b/package.json @@ -40,12 +40,12 @@ }, "devDependencies": { "@babel/register": "^7.21.0", - "@codexteam/icons": "^0.3.0", + "@codexteam/icons": "0.3.2", "@codexteam/shortcuts": "^1.1.1", "@cypress/code-coverage": "^3.10.3", "@editorjs/code": "^2.7.0", "@editorjs/delimiter": "^1.2.0", - "@editorjs/header": "^2.7.0", + "@editorjs/header": "^2.8.7", "@editorjs/paragraph": "^2.11.6", "@editorjs/simple-image": "^1.4.1", "@types/node": "^18.15.11", diff --git a/src/codex.ts b/src/codex.ts index df3f56d61..27d50acdb 100644 --- a/src/codex.ts +++ b/src/codex.ts @@ -1,6 +1,6 @@ 'use strict'; -import { EditorConfig } from '../types'; +import type { EditorConfig } from '../types'; /** * Apply polyfills diff --git a/src/components/__module.ts b/src/components/__module.ts index 4ad521adf..a591c25c2 100644 --- a/src/components/__module.ts +++ b/src/components/__module.ts @@ -1,9 +1,9 @@ -import { EditorModules } from '../types-internal/editor-modules'; -import { EditorConfig } from '../../types'; -import { ModuleConfig } from '../types-internal/module-config'; +import type { EditorModules } from '../types-internal/editor-modules'; +import type { EditorConfig } from '../../types'; +import type { ModuleConfig } from '../types-internal/module-config'; import Listeners from './utils/listeners'; -import EventsDispatcher from './utils/events'; -import { EditorEventMap } from './events'; +import type EventsDispatcher from './utils/events'; +import type { EditorEventMap } from './events'; /** * The type of the Module generic. diff --git a/src/components/block-tunes/block-tune-delete.ts b/src/components/block-tunes/block-tune-delete.ts index 4f1258109..390ea76e1 100644 --- a/src/components/block-tunes/block-tune-delete.ts +++ b/src/components/block-tunes/block-tune-delete.ts @@ -3,9 +3,9 @@ * @classdesc Editor's default tune that moves up selected block * @copyright 2018 */ -import { API, BlockTune } from '../../../types'; +import type { API, BlockTune } from '../../../types'; import { IconCross } from '@codexteam/icons'; -import { MenuConfig } from '../../../types/tools/menu-config'; +import type { MenuConfig } from '../../../types/tools/menu-config'; /** * diff --git a/src/components/block-tunes/block-tune-move-down.ts b/src/components/block-tunes/block-tune-move-down.ts index 5809c0a07..0e7986a0d 100644 --- a/src/components/block-tunes/block-tune-move-down.ts +++ b/src/components/block-tunes/block-tune-move-down.ts @@ -4,9 +4,9 @@ * @copyright 2018 */ -import { API, BlockTune } from '../../../types'; +import type { API, BlockTune } from '../../../types'; import { IconChevronDown } from '@codexteam/icons'; -import { TunesMenuConfig } from '../../../types/tools'; +import type { TunesMenuConfig } from '../../../types/tools'; /** diff --git a/src/components/block-tunes/block-tune-move-up.ts b/src/components/block-tunes/block-tune-move-up.ts index 36f645f48..e87d31269 100644 --- a/src/components/block-tunes/block-tune-move-up.ts +++ b/src/components/block-tunes/block-tune-move-up.ts @@ -3,9 +3,9 @@ * @classdesc Editor's default tune that moves up selected block * @copyright 2018 */ -import { API, BlockTune } from '../../../types'; +import type { API, BlockTune } from '../../../types'; import { IconChevronUp } from '@codexteam/icons'; -import { TunesMenuConfig } from '../../../types/tools'; +import type { TunesMenuConfig } from '../../../types/tools'; /** * diff --git a/src/components/block/api.ts b/src/components/block/api.ts index 64a12b00d..4bd26adc5 100644 --- a/src/components/block/api.ts +++ b/src/components/block/api.ts @@ -1,7 +1,7 @@ -import Block from './index'; -import { BlockToolData, ToolConfig, ToolboxConfigEntry } from '../../../types/tools'; -import { SavedData } from '../../../types/data-formats'; -import { BlockAPI as BlockAPIInterface } from '../../../types/api'; +import type Block from './index'; +import type { BlockToolData, ToolConfig, ToolboxConfigEntry } from '../../../types/tools'; +import type { SavedData } from '../../../types/data-formats'; +import type { BlockAPI as BlockAPIInterface } from '../../../types/api'; /** * Constructs new BlockAPI object diff --git a/src/components/block/index.ts b/src/components/block/index.ts index 33c54b7b1..36c4aa2ae 100644 --- a/src/components/block/index.ts +++ b/src/components/block/index.ts @@ -1,4 +1,4 @@ -import { +import type { BlockAPI as BlockAPIInterface, BlockTool as IBlockTool, BlockToolData, @@ -9,24 +9,25 @@ import { PopoverItemParams } from '../../../types'; -import { SavedData } from '../../../types/data-formats'; +import type { SavedData } from '../../../types/data-formats'; import $, { toggleEmptyMark } from '../dom'; import * as _ from '../utils'; -import ApiModules from '../modules/api'; +import type ApiModules from '../modules/api'; import BlockAPI from './api'; import SelectionUtils from '../selection'; -import BlockTool from '../tools/block'; +import type BlockToolAdapter from '../tools/block'; -import BlockTune from '../tools/tune'; -import { BlockTuneData } from '../../../types/block-tunes/block-tune-data'; -import ToolsCollection from '../tools/collection'; +import type BlockTuneAdapter from '../tools/tune'; +import type { BlockTuneData } from '../../../types/block-tunes/block-tune-data'; +import type ToolsCollection from '../tools/collection'; import EventsDispatcher from '../utils/events'; -import { TunesMenuConfigItem } from '../../../types/tools'; +import type { TunesMenuConfigItem } from '../../../types/tools'; import { isMutationBelongsToElement } from '../utils/mutations'; -import { EditorEventMap, FakeCursorAboutToBeToggled, FakeCursorHaveBeenSet, RedactorDomChanged } from '../events'; -import { RedactorDomChangedPayload } from '../events/RedactorDomChanged'; +import type { EditorEventMap } from '../events'; +import { FakeCursorAboutToBeToggled, FakeCursorHaveBeenSet, RedactorDomChanged } from '../events'; +import type { RedactorDomChangedPayload } from '../events/RedactorDomChanged'; import { convertBlockDataToString, isSameBlockData } from '../utils/blocks'; -import { PopoverItemType } from '../utils/popover'; +import { PopoverItemType } from '@/types/utils/popover/popover-item-type'; /** * Interface describes Block class constructor argument @@ -45,7 +46,7 @@ interface BlockConstructorOptions { /** * Tool object */ - tool: BlockTool; + tool: BlockToolAdapter; /** * Editor's API methods @@ -130,7 +131,7 @@ export default class Block extends EventsDispatcher { /** * Instance of the Tool Block represents */ - public readonly tool: BlockTool; + public readonly tool: BlockToolAdapter; /** * User Tool configuration @@ -145,7 +146,7 @@ export default class Block extends EventsDispatcher { /** * Tunes used by Tool */ - public readonly tunes: ToolsCollection; + public readonly tunes: ToolsCollection; /** * Tool's user configuration @@ -233,7 +234,7 @@ export default class Block extends EventsDispatcher { this.toolInstance = tool.create(data, this.blockAPI, readOnly); /** - * @type {BlockTune[]} + * @type {BlockTuneAdapter[]} */ this.tunes = tool.tunes; diff --git a/src/components/blocks.ts b/src/components/blocks.ts index c0a4d9332..30334ae20 100644 --- a/src/components/blocks.ts +++ b/src/components/blocks.ts @@ -1,7 +1,8 @@ import * as _ from './utils'; import $ from './dom'; -import Block, { BlockToolAPI } from './block'; -import { MoveEvent } from '../../types/tools'; +import type Block from './block'; +import { BlockToolAPI } from './block'; +import type { MoveEvent } from '../../types/tools'; /** * @class Blocks diff --git a/src/components/core.ts b/src/components/core.ts index 65bea1c95..87f8beac7 100644 --- a/src/components/core.ts +++ b/src/components/core.ts @@ -1,12 +1,12 @@ import $ from './dom'; import * as _ from './utils'; -import { EditorConfig, SanitizerConfig } from '../../types'; -import { EditorModules } from '../types-internal/editor-modules'; +import type { EditorConfig, SanitizerConfig } from '../../types'; +import type { EditorModules } from '../types-internal/editor-modules'; import I18n from './i18n'; import { CriticalError } from './errors/critical'; import EventsDispatcher from './utils/events'; import Modules from './modules'; -import { EditorEventMap } from './events'; +import type { EditorEventMap } from './events'; /** * Editor.js core class. Bootstraps modules. diff --git a/src/components/events/BlockChanged.ts b/src/components/events/BlockChanged.ts index dfd83823a..3b8ff2e1b 100644 --- a/src/components/events/BlockChanged.ts +++ b/src/components/events/BlockChanged.ts @@ -1,4 +1,4 @@ -import { BlockMutationEvent } from '../../../types/events/block'; +import type { BlockMutationEvent } from '../../../types/events/block'; /** * Fired when some block state has changed diff --git a/src/components/events/index.ts b/src/components/events/index.ts index 15aac17da..10f82cdc1 100644 --- a/src/components/events/index.ts +++ b/src/components/events/index.ts @@ -1,9 +1,14 @@ -import { RedactorDomChanged, RedactorDomChangedPayload } from './RedactorDomChanged'; -import { BlockChanged, BlockChangedPayload } from './BlockChanged'; -import { BlockHovered, BlockHoveredPayload } from './BlockHovered'; -import { FakeCursorAboutToBeToggled, FakeCursorAboutToBeToggledPayload } from './FakeCursorAboutToBeToggled'; -import { FakeCursorHaveBeenSet, FakeCursorHaveBeenSetPayload } from './FakeCursorHaveBeenSet'; -import { EditorMobileLayoutToggled, EditorMobileLayoutToggledPayload } from './EditorMobileLayoutToggled'; +import type { RedactorDomChangedPayload } from './RedactorDomChanged'; +import { RedactorDomChanged } from './RedactorDomChanged'; +import type { BlockChangedPayload } from './BlockChanged'; +import { BlockChanged } from './BlockChanged'; +import type { BlockHovered, BlockHoveredPayload } from './BlockHovered'; +import type { FakeCursorAboutToBeToggledPayload } from './FakeCursorAboutToBeToggled'; +import { FakeCursorAboutToBeToggled } from './FakeCursorAboutToBeToggled'; +import type { FakeCursorHaveBeenSetPayload } from './FakeCursorHaveBeenSet'; +import { FakeCursorHaveBeenSet } from './FakeCursorHaveBeenSet'; +import type { EditorMobileLayoutToggledPayload } from './EditorMobileLayoutToggled'; +import { EditorMobileLayoutToggled } from './EditorMobileLayoutToggled'; /** * Events fired by Editor Event Dispatcher diff --git a/src/components/i18n/index.ts b/src/components/i18n/index.ts index b522a365a..feccdbb6f 100644 --- a/src/components/i18n/index.ts +++ b/src/components/i18n/index.ts @@ -1,6 +1,6 @@ import defaultDictionary from './locales/en/messages.json'; -import { I18nDictionary, Dictionary } from '../../../types/configs'; -import { LeavesDictKeys } from '../../types-internal/i18n-internal-namespace'; +import type { I18nDictionary, Dictionary } from '../../../types/configs'; +import type { LeavesDictKeys } from '../../types-internal/i18n-internal-namespace'; /** * Type for all available internal dictionary strings diff --git a/src/components/i18n/namespace-internal.ts b/src/components/i18n/namespace-internal.ts index 0a7b10a32..ed9520a59 100644 --- a/src/components/i18n/namespace-internal.ts +++ b/src/components/i18n/namespace-internal.ts @@ -1,5 +1,5 @@ import defaultDictionary from './locales/en/messages.json'; -import { DictNamespaces } from '../../types-internal/i18n-internal-namespace'; +import type { DictNamespaces } from '../../types-internal/i18n-internal-namespace'; import { isObject, isString } from '../utils'; /** diff --git a/src/components/inline-tools/inline-tool-bold.ts b/src/components/inline-tools/inline-tool-bold.ts index 9959011dd..c3a4d9d2b 100644 --- a/src/components/inline-tools/inline-tool-bold.ts +++ b/src/components/inline-tools/inline-tool-bold.ts @@ -1,6 +1,6 @@ -import { InlineTool, SanitizerConfig } from '../../../types'; +import type { InlineTool, SanitizerConfig } from '../../../types'; import { IconBold } from '@codexteam/icons'; -import { MenuConfig } from '../../../types/tools'; +import type { MenuConfig } from '../../../types/tools'; /** * Bold Tool diff --git a/src/components/inline-tools/inline-tool-convert.ts b/src/components/inline-tools/inline-tool-convert.ts index 71d2e5249..b0347a682 100644 --- a/src/components/inline-tools/inline-tool-convert.ts +++ b/src/components/inline-tools/inline-tool-convert.ts @@ -1,8 +1,8 @@ import { IconReplace } from '@codexteam/icons'; -import { InlineTool, API } from '../../../types'; -import { MenuConfig, MenuConfigItem } from '../../../types/tools'; +import type { InlineTool, API } from '../../../types'; +import type { MenuConfig, MenuConfigItem } from '../../../types/tools'; import * as _ from '../utils'; -import { Blocks, Selection, Tools, Caret, I18n } from '../../../types/api'; +import type { Blocks, Selection, Tools, Caret, I18n } from '../../../types/api'; import SelectionUtils from '../selection'; import { getConvertibleToolsForBlock } from '../utils/blocks'; import I18nInternal from '../i18n'; diff --git a/src/components/inline-tools/inline-tool-italic.ts b/src/components/inline-tools/inline-tool-italic.ts index e271bd457..526a56779 100644 --- a/src/components/inline-tools/inline-tool-italic.ts +++ b/src/components/inline-tools/inline-tool-italic.ts @@ -1,4 +1,4 @@ -import { InlineTool, SanitizerConfig } from '../../../types'; +import type { InlineTool, SanitizerConfig } from '../../../types'; import { IconItalic } from '@codexteam/icons'; /** diff --git a/src/components/inline-tools/inline-tool-link.ts b/src/components/inline-tools/inline-tool-link.ts index f66cf79a6..9b413a564 100644 --- a/src/components/inline-tools/inline-tool-link.ts +++ b/src/components/inline-tools/inline-tool-link.ts @@ -1,7 +1,7 @@ import SelectionUtils from '../selection'; import * as _ from '../utils'; -import { InlineTool, SanitizerConfig, API } from '../../../types'; -import { Notifier, Toolbar, I18n, InlineToolbar } from '../../../types/api'; +import type { InlineTool, SanitizerConfig, API } from '../../../types'; +import type { Notifier, Toolbar, I18n, InlineToolbar } from '../../../types/api'; import { IconLink, IconUnlink } from '@codexteam/icons'; /** diff --git a/src/components/modules/api/blocks.ts b/src/components/modules/api/blocks.ts index 6c0c58696..f9297d5d4 100644 --- a/src/components/modules/api/blocks.ts +++ b/src/components/modules/api/blocks.ts @@ -1,11 +1,11 @@ import type { BlockAPI as BlockAPIInterface, Blocks } from '../../../../types/api'; -import { BlockToolData, OutputBlockData, OutputData, ToolConfig } from '../../../../types'; +import type { BlockToolData, OutputBlockData, OutputData, ToolConfig } from '../../../../types'; import * as _ from './../../utils'; import BlockAPI from '../../block/api'; import Module from '../../__module'; import Block from '../../block'; import { capitalize } from '../../utils'; -import { BlockTuneData } from '../../../../types/block-tunes/block-tune-data'; +import type { BlockTuneData } from '../../../../types/block-tunes/block-tune-data'; /** * @class BlocksAPI diff --git a/src/components/modules/api/caret.ts b/src/components/modules/api/caret.ts index e889ea5f3..e5ca5af2e 100644 --- a/src/components/modules/api/caret.ts +++ b/src/components/modules/api/caret.ts @@ -1,4 +1,4 @@ -import { BlockAPI, Caret } from '../../../../types/api'; +import type { BlockAPI, Caret } from '../../../../types/api'; import Module from '../../__module'; import { resolveBlock } from '../../utils/api'; diff --git a/src/components/modules/api/events.ts b/src/components/modules/api/events.ts index 865b913e9..74484a239 100644 --- a/src/components/modules/api/events.ts +++ b/src/components/modules/api/events.ts @@ -1,5 +1,5 @@ import Module from '../../__module'; -import { Events } from '../../../../types/api'; +import type { Events } from '../../../../types/api'; /** * @class EventsAPI diff --git a/src/components/modules/api/i18n.ts b/src/components/modules/api/i18n.ts index 6d42314cc..25e353751 100644 --- a/src/components/modules/api/i18n.ts +++ b/src/components/modules/api/i18n.ts @@ -1,4 +1,4 @@ -import { I18n } from '../../../../types/api'; +import type { I18n } from '../../../../types/api'; import I18nInternal from '../../i18n'; import { logLabeled } from '../../utils'; import Module from '../../__module'; diff --git a/src/components/modules/api/index.ts b/src/components/modules/api/index.ts index e66e605c7..e334244de 100644 --- a/src/components/modules/api/index.ts +++ b/src/components/modules/api/index.ts @@ -6,7 +6,7 @@ * if you cant to read more about how API works, please see docs */ import Module from '../../__module'; -import { API as APIInterfaces } from '../../../../types'; +import type { API as APIInterfaces } from '../../../../types'; /** * @class API diff --git a/src/components/modules/api/inlineToolbar.ts b/src/components/modules/api/inlineToolbar.ts index dca74ca90..2091d9c01 100644 --- a/src/components/modules/api/inlineToolbar.ts +++ b/src/components/modules/api/inlineToolbar.ts @@ -1,4 +1,4 @@ -import { InlineToolbar } from '../../../../types/api/inline-toolbar'; +import type { InlineToolbar } from '../../../../types/api/inline-toolbar'; import Module from '../../__module'; /** diff --git a/src/components/modules/api/listeners.ts b/src/components/modules/api/listeners.ts index ec87e1bc4..b7092899a 100644 --- a/src/components/modules/api/listeners.ts +++ b/src/components/modules/api/listeners.ts @@ -1,4 +1,4 @@ -import { Listeners } from '../../../../types/api'; +import type { Listeners } from '../../../../types/api'; import Module from '../../__module'; /** diff --git a/src/components/modules/api/notifier.ts b/src/components/modules/api/notifier.ts index 85b457d43..3efcc7d85 100644 --- a/src/components/modules/api/notifier.ts +++ b/src/components/modules/api/notifier.ts @@ -1,8 +1,8 @@ -import { Notifier as INotifier } from '../../../../types/api'; +import type { Notifier as INotifier } from '../../../../types/api'; import Notifier from '../../utils/notifier'; -import { ConfirmNotifierOptions, NotifierOptions, PromptNotifierOptions } from 'codex-notifier'; +import type { ConfirmNotifierOptions, NotifierOptions, PromptNotifierOptions } from 'codex-notifier'; import Module from '../../__module'; -import { ModuleConfig } from '../../../types-internal/module-config'; +import type { ModuleConfig } from '../../../types-internal/module-config'; /** * diff --git a/src/components/modules/api/readonly.ts b/src/components/modules/api/readonly.ts index 5c5270527..78e0abb2d 100644 --- a/src/components/modules/api/readonly.ts +++ b/src/components/modules/api/readonly.ts @@ -1,4 +1,4 @@ -import { ReadOnly } from '../../../../types/api'; +import type { ReadOnly } from '../../../../types/api'; import Module from '../../__module'; /** diff --git a/src/components/modules/api/sanitizer.ts b/src/components/modules/api/sanitizer.ts index 1f1a259cc..723329879 100644 --- a/src/components/modules/api/sanitizer.ts +++ b/src/components/modules/api/sanitizer.ts @@ -1,5 +1,5 @@ -import { Sanitizer as ISanitizer } from '../../../../types/api'; -import { SanitizerConfig } from '../../../../types/configs'; +import type { Sanitizer as ISanitizer } from '../../../../types/api'; +import type { SanitizerConfig } from '../../../../types/configs'; import Module from '../../__module'; import { clean } from '../../utils/sanitizer'; diff --git a/src/components/modules/api/saver.ts b/src/components/modules/api/saver.ts index 5db9b88ad..ad0eac29c 100644 --- a/src/components/modules/api/saver.ts +++ b/src/components/modules/api/saver.ts @@ -1,5 +1,5 @@ -import { Saver } from '../../../../types/api'; -import { OutputData } from '../../../../types'; +import type { Saver } from '../../../../types/api'; +import type { OutputData } from '../../../../types'; import * as _ from '../../utils'; import Module from '../../__module'; diff --git a/src/components/modules/api/selection.ts b/src/components/modules/api/selection.ts index ba3087209..53d221fbd 100644 --- a/src/components/modules/api/selection.ts +++ b/src/components/modules/api/selection.ts @@ -1,5 +1,5 @@ import SelectionUtils from '../../selection'; -import { Selection as SelectionAPIInterface } from '../../../../types/api'; +import type { Selection as SelectionAPIInterface } from '../../../../types/api'; import Module from '../../__module'; /** diff --git a/src/components/modules/api/styles.ts b/src/components/modules/api/styles.ts index aced02a55..a75c71213 100644 --- a/src/components/modules/api/styles.ts +++ b/src/components/modules/api/styles.ts @@ -1,4 +1,4 @@ -import { Styles } from '../../../../types/api'; +import type { Styles } from '../../../../types/api'; import Module from '../../__module'; /** diff --git a/src/components/modules/api/toolbar.ts b/src/components/modules/api/toolbar.ts index 321cbf4ce..f94e71304 100644 --- a/src/components/modules/api/toolbar.ts +++ b/src/components/modules/api/toolbar.ts @@ -1,4 +1,4 @@ -import { Toolbar } from '../../../../types/api'; +import type { Toolbar } from '../../../../types/api'; import Module from '../../__module'; import * as _ from './../../utils'; /** diff --git a/src/components/modules/api/tools.ts b/src/components/modules/api/tools.ts index f120fa590..c4ce7cb6d 100644 --- a/src/components/modules/api/tools.ts +++ b/src/components/modules/api/tools.ts @@ -1,4 +1,4 @@ -import { Tools as ToolsAPIInterface } from '../../../../types/api'; +import type { Tools as ToolsAPIInterface } from '../../../../types/api'; import Module from '../../__module'; /** diff --git a/src/components/modules/api/tooltip.ts b/src/components/modules/api/tooltip.ts index 3b2b5ba15..37077d0c3 100644 --- a/src/components/modules/api/tooltip.ts +++ b/src/components/modules/api/tooltip.ts @@ -1,7 +1,7 @@ -import { Tooltip as ITooltip } from '../../../../types/api'; +import type { Tooltip as ITooltip } from '../../../../types/api'; import type { TooltipOptions, TooltipContent } from 'codex-tooltip/types'; import Module from '../../__module'; -import { ModuleConfig } from '../../../types-internal/module-config'; +import type { ModuleConfig } from '../../../types-internal/module-config'; import * as tooltip from '../../utils/tooltip'; /** * @class TooltipAPI diff --git a/src/components/modules/api/ui.ts b/src/components/modules/api/ui.ts index b1e42d18b..3bd867cca 100644 --- a/src/components/modules/api/ui.ts +++ b/src/components/modules/api/ui.ts @@ -1,5 +1,5 @@ import Module from '../../__module'; -import { Ui, UiNodes } from '../../../../types/api'; +import type { Ui, UiNodes } from '../../../../types/api'; /** * API module allowing to access some Editor UI elements diff --git a/src/components/modules/blockManager.ts b/src/components/modules/blockManager.ts index ec555f208..fd06dd71b 100644 --- a/src/components/modules/blockManager.ts +++ b/src/components/modules/blockManager.ts @@ -9,10 +9,10 @@ import Module from '../__module'; import $ from '../dom'; import * as _ from '../utils'; import Blocks from '../blocks'; -import { BlockToolData, PasteEvent } from '../../../types'; -import { BlockTuneData } from '../../../types/block-tunes/block-tune-data'; +import type { BlockToolData, PasteEvent } from '../../../types'; +import type { BlockTuneData } from '../../../types/block-tunes/block-tune-data'; import BlockAPI from '../block/api'; -import { BlockMutationEventMap, BlockMutationType } from '../../../types/events/block'; +import type { BlockMutationEventMap, BlockMutationType } from '../../../types/events/block'; import { BlockRemovedMutationType } from '../../../types/events/block/BlockRemoved'; import { BlockAddedMutationType } from '../../../types/events/block/BlockAdded'; import { BlockMovedMutationType } from '../../../types/events/block/BlockMoved'; diff --git a/src/components/modules/blockSelection.ts b/src/components/modules/blockSelection.ts index f6844bdcc..a6edcb8c8 100644 --- a/src/components/modules/blockSelection.ts +++ b/src/components/modules/blockSelection.ts @@ -5,13 +5,13 @@ * @version 1.0.0 */ import Module from '../__module'; -import Block from '../block'; +import type Block from '../block'; import * as _ from '../utils'; import $ from '../dom'; import Shortcuts from '../utils/shortcuts'; import SelectionUtils from '../selection'; -import { SanitizerConfig } from '../../../types/configs'; +import type { SanitizerConfig } from '../../../types/configs'; import { clean } from '../utils/sanitizer'; /** diff --git a/src/components/modules/caret.ts b/src/components/modules/caret.ts index 5193daa3a..276eef4b0 100644 --- a/src/components/modules/caret.ts +++ b/src/components/modules/caret.ts @@ -1,6 +1,6 @@ import Selection from '../selection'; import Module from '../__module'; -import Block from '../block'; +import type Block from '../block'; import * as caretUtils from '../utils/caret'; import $ from '../dom'; diff --git a/src/components/modules/crossBlockSelection.ts b/src/components/modules/crossBlockSelection.ts index bcebfa4f3..1b18b8e6d 100644 --- a/src/components/modules/crossBlockSelection.ts +++ b/src/components/modules/crossBlockSelection.ts @@ -1,5 +1,5 @@ import Module from '../__module'; -import Block from '../block'; +import type Block from '../block'; import SelectionUtils from '../selection'; import * as _ from '../utils'; diff --git a/src/components/modules/modificationsObserver.ts b/src/components/modules/modificationsObserver.ts index 10e3aa566..6185d7d5f 100644 --- a/src/components/modules/modificationsObserver.ts +++ b/src/components/modules/modificationsObserver.ts @@ -1,6 +1,6 @@ -import { BlockId } from '../../../types'; -import { BlockMutationEvent, BlockMutationType } from '../../../types/events/block'; -import { ModuleConfig } from '../../types-internal/module-config'; +import type { BlockId } from '../../../types'; +import type { BlockMutationEvent, BlockMutationType } from '../../../types/events/block'; +import type { ModuleConfig } from '../../types-internal/module-config'; import Module from '../__module'; import { modificationsObserverBatchTimeout } from '../constants'; import { BlockChanged, FakeCursorAboutToBeToggled, FakeCursorHaveBeenSet, RedactorDomChanged } from '../events'; diff --git a/src/components/modules/paste.ts b/src/components/modules/paste.ts index bdbec4458..6a8378c41 100644 --- a/src/components/modules/paste.ts +++ b/src/components/modules/paste.ts @@ -1,17 +1,17 @@ import Module from '../__module'; import $ from '../dom'; import * as _ from '../utils'; -import { +import type { BlockAPI, PasteEvent, PasteEventDetail, SanitizerConfig, SanitizerRule } from '../../../types'; -import Block from '../block'; -import { SavedData } from '../../../types/data-formats'; +import type Block from '../block'; +import type { SavedData } from '../../../types/data-formats'; import { clean, sanitizeBlocks } from '../utils/sanitizer'; -import BlockTool from '../tools/block'; +import type BlockToolAdapter from '../tools/block'; /** * Tag substitute object. @@ -21,7 +21,7 @@ interface TagSubstitute { * Name of related Tool * */ - tool: BlockTool; + tool: BlockToolAdapter; /** * If a Tool specifies just a tag name, all the attributes will be sanitized. @@ -47,7 +47,7 @@ interface PatternSubstitute { /** * Name of related Tool */ - tool: BlockTool; + tool: BlockToolAdapter; } /** @@ -290,7 +290,7 @@ export default class Paste extends Module { * * @param tool - BlockTool object */ - private processTool = (tool: BlockTool): void => { + private processTool = (tool: BlockToolAdapter): void => { try { const toolInstance = tool.create({}, {} as BlockAPI, false); @@ -345,7 +345,7 @@ export default class Paste extends Module { * * @param tool - BlockTool object */ - private getTagsConfig(tool: BlockTool): void { + private getTagsConfig(tool: BlockToolAdapter): void { if (tool.pasteConfig === false) { return; } @@ -390,7 +390,7 @@ export default class Paste extends Module { * * @param tool - BlockTool object */ - private getFilesConfig(tool: BlockTool): void { + private getFilesConfig(tool: BlockToolAdapter): void { if (tool.pasteConfig === false) { return; } @@ -435,7 +435,7 @@ export default class Paste extends Module { * * @param tool - BlockTool object */ - private getPatternsConfig(tool: BlockTool): void { + private getPatternsConfig(tool: BlockToolAdapter): void { if ( tool.pasteConfig === false || !tool.pasteConfig.patterns || diff --git a/src/components/modules/renderer.ts b/src/components/modules/renderer.ts index 940ff179b..150af7a2c 100644 --- a/src/components/modules/renderer.ts +++ b/src/components/modules/renderer.ts @@ -1,9 +1,9 @@ import Module from '../__module'; import * as _ from '../utils'; import type { BlockId, BlockToolData, OutputBlockData } from '../../../types'; -import type BlockTool from '../tools/block'; +import type BlockToolAdapter from '../tools/block'; import type { StubData } from '../../tools/stub'; -import Block from '../block'; +import type Block from '../block'; /** * Module that responsible for rendering Blocks on editor initialization @@ -92,7 +92,7 @@ export default class Renderer extends Module { let title = tool; if (Tools.unavailable.has(tool)) { - const toolboxSettings = (Tools.unavailable.get(tool) as BlockTool).toolbox; + const toolboxSettings = (Tools.unavailable.get(tool) as BlockToolAdapter).toolbox; if (toolboxSettings !== undefined && toolboxSettings[0].title !== undefined) { title = toolboxSettings[0].title; diff --git a/src/components/modules/saver.ts b/src/components/modules/saver.ts index af8136d3f..c02192835 100644 --- a/src/components/modules/saver.ts +++ b/src/components/modules/saver.ts @@ -6,9 +6,9 @@ * @version 2.0.0 */ import Module from '../__module'; -import { OutputData } from '../../../types'; -import { SavedData, ValidatedData } from '../../../types/data-formats'; -import Block from '../block'; +import type { OutputData } from '../../../types'; +import type { SavedData, ValidatedData } from '../../../types/data-formats'; +import type Block from '../block'; import * as _ from '../utils'; import { sanitizeBlocks } from '../utils/sanitizer'; diff --git a/src/components/modules/toolbar/blockSettings.ts b/src/components/modules/toolbar/blockSettings.ts index ef47da288..88f861b40 100644 --- a/src/components/modules/toolbar/blockSettings.ts +++ b/src/components/modules/toolbar/blockSettings.ts @@ -1,14 +1,15 @@ import Module from '../../__module'; import $ from '../../dom'; import SelectionUtils from '../../selection'; -import Block from '../../block'; +import type Block from '../../block'; import I18n from '../../i18n'; import { I18nInternalNS } from '../../i18n/namespace-internal'; -import Flipper from '../../flipper'; -import { MenuConfigItem } from '../../../../types/tools'; +import type Flipper from '../../flipper'; +import type { MenuConfigItem } from '../../../../types/tools'; import { resolveAliases } from '../../utils/resolve-aliases'; -import { type Popover, PopoverDesktop, PopoverMobile, PopoverItemParams, PopoverItemType } from '../../utils/popover'; -import { PopoverEvent } from '../../utils/popover/popover.types'; +import type { PopoverItemParams } from '../../utils/popover'; +import { type Popover, PopoverDesktop, PopoverMobile, PopoverItemType } from '../../utils/popover'; +import { PopoverEvent } from '@/types/utils/popover/popover-event'; import { isMobileScreen } from '../../utils'; import { EditorMobileLayoutToggled } from '../../events'; import { IconReplace } from '@codexteam/icons'; diff --git a/src/components/modules/toolbar/index.ts b/src/components/modules/toolbar/index.ts index b03a69745..3098c6aba 100644 --- a/src/components/modules/toolbar/index.ts +++ b/src/components/modules/toolbar/index.ts @@ -4,8 +4,8 @@ import * as _ from '../../utils'; import I18n from '../../i18n'; import { I18nInternalNS } from '../../i18n/namespace-internal'; import * as tooltip from '../../utils/tooltip'; -import { ModuleConfig } from '../../../types-internal/module-config'; -import Block from '../../block'; +import type { ModuleConfig } from '../../../types-internal/module-config'; +import type Block from '../../block'; import Toolbox, { ToolboxEvent } from '../../ui/toolbox'; import { IconMenu, IconPlus } from '@codexteam/icons'; import { BlockHovered } from '../../events/BlockHovered'; diff --git a/src/components/modules/toolbar/inline.ts b/src/components/modules/toolbar/inline.ts index 350c75084..4a138c204 100644 --- a/src/components/modules/toolbar/inline.ts +++ b/src/components/modules/toolbar/inline.ts @@ -3,13 +3,14 @@ import Module from '../../__module'; import $ from '../../dom'; import SelectionUtils from '../../selection'; import * as _ from '../../utils'; -import { InlineTool as IInlineTool } from '../../../../types'; +import type { InlineTool as IInlineTool } from '../../../../types'; import I18n from '../../i18n'; import { I18nInternalNS } from '../../i18n/namespace-internal'; import Shortcuts from '../../utils/shortcuts'; -import { ModuleConfig } from '../../../types-internal/module-config'; +import type { ModuleConfig } from '../../../types-internal/module-config'; import { CommonInternalSettings } from '../../tools/base'; -import { Popover, PopoverItemHtmlParams, PopoverItemParams, PopoverItemType, WithChildren } from '../../utils/popover'; +import type { Popover, PopoverItemHtmlParams, PopoverItemParams, WithChildren } from '../../utils/popover'; +import { PopoverItemType } from '../../utils/popover'; import { PopoverInline } from '../../utils/popover/popover-inline'; /** diff --git a/src/components/modules/tools.ts b/src/components/modules/tools.ts index 01fd7ef14..618242697 100644 --- a/src/components/modules/tools.ts +++ b/src/components/modules/tools.ts @@ -1,16 +1,16 @@ import Paragraph from '@editorjs/paragraph'; import Module from '../__module'; import * as _ from '../utils'; -import { SanitizerConfig, ToolConfig, ToolConstructable, ToolSettings } from '../../../types'; +import type { SanitizerConfig, ToolConfig, ToolConstructable, ToolSettings } from '../../../types'; import BoldInlineTool from '../inline-tools/inline-tool-bold'; import ItalicInlineTool from '../inline-tools/inline-tool-italic'; import LinkInlineTool from '../inline-tools/inline-tool-link'; import ConvertInlineTool from '../inline-tools/inline-tool-convert'; import Stub from '../../tools/stub'; import ToolsFactory from '../tools/factory'; -import InlineTool from '../tools/inline'; -import BlockTool from '../tools/block'; -import BlockTune from '../tools/tune'; +import type InlineToolAdapter from '../tools/inline'; +import type BlockToolAdapter from '../tools/block'; +import type BlockTuneAdapter from '../tools/tune'; import MoveDownTune from '../block-tunes/block-tune-move-down'; import DeleteTune from '../block-tunes/block-tune-delete'; import MoveUpTune from '../block-tunes/block-tune-move-up'; @@ -51,14 +51,14 @@ export default class Tools extends Module { /** * Return Tools for the Inline Toolbar */ - public get inlineTools(): ToolsCollection { + public get inlineTools(): ToolsCollection { return this.available.inlineTools; } /** * Return editor block tools */ - public get blockTools(): ToolsCollection { + public get blockTools(): ToolsCollection { return this.available.blockTools; } @@ -67,14 +67,14 @@ export default class Tools extends Module { * * @returns {object} - object of Inline Tool's classes */ - public get blockTunes(): ToolsCollection { + public get blockTunes(): ToolsCollection { return this.available.blockTunes; } /** * Returns default Tool object */ - public get defaultTool(): BlockTool { + public get defaultTool(): BlockToolAdapter { return this.blockTools.get(this.config.defaultBlock); } @@ -303,7 +303,7 @@ export default class Tools extends Module { * * @param tool - Block Tool */ - private assignInlineToolsToBlockTool(tool: BlockTool): void { + private assignInlineToolsToBlockTool(tool: BlockToolAdapter): void { /** * If common inlineToolbar property is false no Inline Tools should be assigned */ @@ -317,7 +317,7 @@ export default class Tools extends Module { * - if common settings is 'true' or not specified, get default order */ if (tool.enabledInlineTools === true) { - tool.inlineTools = new ToolsCollection( + tool.inlineTools = new ToolsCollection( Array.isArray(this.config.inlineToolbar) ? this.config.inlineToolbar.map(name => [name, this.inlineTools.get(name)]) /** @@ -333,7 +333,7 @@ export default class Tools extends Module { * If user pass the list of inline tools for the particular tool, return it. */ if (Array.isArray(tool.enabledInlineTools)) { - tool.inlineTools = new ToolsCollection( + tool.inlineTools = new ToolsCollection( /** Prepend ConvertTo Inline Tool */ ['convertTo', ...tool.enabledInlineTools].map(name => [name, this.inlineTools.get(name)]) ); @@ -345,27 +345,27 @@ export default class Tools extends Module { * * @param tool — Block Tool */ - private assignBlockTunesToBlockTool(tool: BlockTool): void { + private assignBlockTunesToBlockTool(tool: BlockToolAdapter): void { if (tool.enabledBlockTunes === false) { return; } if (Array.isArray(tool.enabledBlockTunes)) { - const userTunes = new ToolsCollection( + const userTunes = new ToolsCollection( tool.enabledBlockTunes.map(name => [name, this.blockTunes.get(name)]) ); - tool.tunes = new ToolsCollection([...userTunes, ...this.blockTunes.internalTools]); + tool.tunes = new ToolsCollection([...userTunes, ...this.blockTunes.internalTools]); return; } if (Array.isArray(this.config.tunes)) { - const userTunes = new ToolsCollection( + const userTunes = new ToolsCollection( this.config.tunes.map(name => [name, this.blockTunes.get(name)]) ); - tool.tunes = new ToolsCollection([...userTunes, ...this.blockTunes.internalTools]); + tool.tunes = new ToolsCollection([...userTunes, ...this.blockTunes.internalTools]); return; } diff --git a/src/components/tools/base.ts b/src/components/tools/base.ts index 22bc83ef6..2e211707b 100644 --- a/src/components/tools/base.ts +++ b/src/components/tools/base.ts @@ -1,28 +1,11 @@ -import { Tool, ToolConstructable, ToolSettings } from '../../../types/tools'; -import type { SanitizerConfig, API as ApiMethods } from '../../../types'; +import type { Tool, ToolConstructable, ToolSettings } from '@/types/tools'; +import type { SanitizerConfig, API as ApiMethods } from '@/types'; import * as _ from '../utils'; -import type InlineTool from './inline'; -import type BlockTool from './block'; -import type BlockTune from './tune'; - -/** - * What kind of plugins developers can create - */ -export enum ToolType { - /** - * Block tool - */ - Block, - /** - * Inline tool - */ - Inline, - - /** - * Block tune - */ - Tune, -} +import { ToolType } from '@/types/tools/adapters/tool-type'; +import type { BaseToolAdapter as BaseToolAdapterInterface } from '@/types/tools/adapters/base-tool-adapter'; +import type { InlineToolAdapter as InlineToolAdapterInterface } from '@/types/tools/adapters/inline-tool-adapter'; +import type { BlockToolAdapter as BlockToolAdapterInterface } from '@/types/tools/adapters/block-tool-adapter'; +import type { BlockTuneAdapter as BlockTuneAdapterInterface } from '@/types/tools/adapters/block-tune-adapter'; /** * Enum of Tool options provided by user @@ -130,11 +113,11 @@ interface ConstructorOptions { /** * Base abstract class for Tools */ -export default abstract class BaseTool { +export default abstract class BaseToolAdapter implements BaseToolAdapterInterface { /** * Tool type: Block, Inline or Tune */ - public type: ToolType; + public type: Type; /** * Tool name specified in EditorJS config @@ -247,21 +230,21 @@ export default abstract class BaseTool { /** * Returns true if Tools is inline */ - public isInline(): this is InlineTool { + public isInline(): this is InlineToolAdapterInterface { return this.type === ToolType.Inline; } /** * Returns true if Tools is block */ - public isBlock(): this is BlockTool { + public isBlock(): this is BlockToolAdapterInterface { return this.type === ToolType.Block; } /** * Returns true if Tools is tune */ - public isTune(): this is BlockTune { + public isTune(): this is BlockTuneAdapterInterface { return this.type === ToolType.Tune; } @@ -271,5 +254,5 @@ export default abstract class BaseTool { * @param args */ // eslint-disable-next-line @typescript-eslint/no-explicit-any - public abstract create(...args: any[]): Type; + public abstract create(...args: any[]): ToolClass; } diff --git a/src/components/tools/block.ts b/src/components/tools/block.ts index a9ee44396..d56cc4321 100644 --- a/src/components/tools/block.ts +++ b/src/components/tools/block.ts @@ -1,5 +1,5 @@ -import BaseTool, { InternalBlockToolSettings, ToolType, UserSettings } from './base'; -import { +import BaseToolAdapter, { InternalBlockToolSettings, UserSettings } from './base'; +import type { BlockAPI, BlockTool as IBlockTool, BlockToolConstructable, @@ -7,30 +7,32 @@ import { ConversionConfig, PasteConfig, SanitizerConfig, ToolboxConfig, ToolboxConfigEntry -} from '../../../types'; +} from '@/types'; import * as _ from '../utils'; -import InlineTool from './inline'; -import BlockTune from './tune'; +import type InlineToolAdapter from './inline'; +import type BlockTuneAdapter from './tune'; import ToolsCollection from './collection'; +import type { BlockToolAdapter as BlockToolAdapterInterface } from '@/types/tools/adapters/block-tool-adapter'; +import { ToolType } from '@/types/tools/adapters/tool-type'; /** * Class to work with Block tools constructables */ -export default class BlockTool extends BaseTool { +export default class BlockToolAdapter extends BaseToolAdapter implements BlockToolAdapterInterface { /** * Tool type — Block */ - public type = ToolType.Block; + public type: ToolType.Block = ToolType.Block; /** * InlineTool collection for current Block Tool */ - public inlineTools: ToolsCollection = new ToolsCollection(); + public inlineTools: ToolsCollection = new ToolsCollection(); /** * BlockTune collection for current Block Tool */ - public tunes: ToolsCollection = new ToolsCollection(); + public tunes: ToolsCollection = new ToolsCollection(); /** * Tool's constructable blueprint diff --git a/src/components/tools/collection.ts b/src/components/tools/collection.ts index 38ebf8c4a..c82d49626 100644 --- a/src/components/tools/collection.ts +++ b/src/components/tools/collection.ts @@ -1,44 +1,46 @@ -import BlockTool from './block'; -import InlineTool from './inline'; -import BlockTune from './tune'; +import type BlockToolAdapter from './block'; +import type InlineToolAdapter from './inline'; +import type BlockTuneAdapter from './tune'; +import type { ToolsCollection as ToolsCollectionInterface } from '@/types/tools/adapters/tools-collection'; -export type ToolClass = BlockTool | InlineTool | BlockTune; + +export type ToolClass = BlockToolAdapter | InlineToolAdapter | BlockTuneAdapter; /** * Class to store Editor Tools */ -export default class ToolsCollection extends Map { +export default class ToolsCollection extends Map implements ToolsCollectionInterface { /** * Returns Block Tools collection */ - public get blockTools(): ToolsCollection { + public get blockTools(): ToolsCollection { const tools = Array .from(this.entries()) - .filter(([, tool]) => tool.isBlock()) as [string, BlockTool][]; + .filter(([, tool]) => tool.isBlock()) as [string, BlockToolAdapter][]; - return new ToolsCollection(tools); + return new ToolsCollection(tools); } /** * Returns Inline Tools collection */ - public get inlineTools(): ToolsCollection { + public get inlineTools(): ToolsCollection { const tools = Array .from(this.entries()) - .filter(([, tool]) => tool.isInline()) as [string, InlineTool][]; + .filter(([, tool]) => tool.isInline()) as [string, InlineToolAdapter][]; - return new ToolsCollection(tools); + return new ToolsCollection(tools); } /** * Returns Block Tunes collection */ - public get blockTunes(): ToolsCollection { + public get blockTunes(): ToolsCollection { const tools = Array .from(this.entries()) - .filter(([, tool]) => tool.isTune()) as [string, BlockTune][]; + .filter(([, tool]) => tool.isTune()) as [string, BlockTuneAdapter][]; - return new ToolsCollection(tools); + return new ToolsCollection(tools); } /** diff --git a/src/components/tools/factory.ts b/src/components/tools/factory.ts index fdf032c09..94d501df7 100644 --- a/src/components/tools/factory.ts +++ b/src/components/tools/factory.ts @@ -1,12 +1,12 @@ -import { ToolConstructable, ToolSettings } from '../../../types/tools'; +import type { ToolConstructable, ToolSettings } from '../../../types/tools'; import { InternalInlineToolSettings, InternalTuneSettings } from './base'; -import InlineTool from './inline'; -import BlockTune from './tune'; -import BlockTool from './block'; -import ApiModule from '../modules/api'; -import { EditorConfig } from '../../../types/configs'; +import InlineToolAdapter from './inline'; +import BlockTuneAdapter from './tune'; +import BlockToolAdapter from './block'; +import type ApiModule from '../modules/api'; +import type { EditorConfig } from '../../../types/configs'; -type ToolConstructor = typeof InlineTool | typeof BlockTool | typeof BlockTune; +type ToolConstructor = typeof InlineToolAdapter | typeof BlockToolAdapter | typeof BlockTuneAdapter; /** * Factory to construct classes to work with tools @@ -48,7 +48,7 @@ export default class ToolsFactory { * * @param name - tool name */ - public get(name: string): InlineTool | BlockTool | BlockTune { + public get(name: string): InlineToolAdapter | BlockToolAdapter | BlockTuneAdapter { const { class: constructable, isInternal = false, ...config } = this.config[name]; const Constructor = this.getConstructor(constructable); @@ -73,11 +73,11 @@ export default class ToolsFactory { private getConstructor(constructable: ToolConstructable): ToolConstructor { switch (true) { case constructable[InternalInlineToolSettings.IsInline]: - return InlineTool; + return InlineToolAdapter; case constructable[InternalTuneSettings.IsTune]: - return BlockTune; + return BlockTuneAdapter; default: - return BlockTool; + return BlockToolAdapter; } } } diff --git a/src/components/tools/inline.ts b/src/components/tools/inline.ts index 64fd27288..a1f419293 100644 --- a/src/components/tools/inline.ts +++ b/src/components/tools/inline.ts @@ -1,14 +1,16 @@ -import BaseTool, { InternalInlineToolSettings, ToolType } from './base'; -import { InlineTool as IInlineTool, InlineToolConstructable } from '../../../types'; +import BaseToolAdapter, { InternalInlineToolSettings } from './base'; +import type { InlineTool as IInlineTool, InlineToolConstructable } from '@/types'; +import type { InlineToolAdapter as InlineToolAdapterInterface } from '@/types/tools/adapters/inline-tool-adapter'; +import { ToolType } from '@/types/tools/adapters/tool-type'; /** * InlineTool object to work with Inline Tools constructables */ -export default class InlineTool extends BaseTool { +export default class InlineToolAdapter extends BaseToolAdapter implements InlineToolAdapterInterface { /** * Tool type — Inline */ - public type = ToolType.Inline; + public type: ToolType.Inline = ToolType.Inline; /** * Tool's constructable blueprint diff --git a/src/components/tools/tune.ts b/src/components/tools/tune.ts index 29dac95b0..c183c2dee 100644 --- a/src/components/tools/tune.ts +++ b/src/components/tools/tune.ts @@ -1,17 +1,19 @@ -import BaseTool, { ToolType } from './base'; -import { BlockAPI, BlockTune as IBlockTune, BlockTuneConstructable } from '../../../types'; -import { BlockTuneData } from '../../../types/block-tunes/block-tune-data'; +import BaseToolAdapter from './base'; +import type { BlockAPI, BlockTune as IBlockTune, BlockTuneConstructable } from '@/types'; +import type { BlockTuneData } from '@/types/block-tunes/block-tune-data'; +import type { BlockTuneAdapter as BlockTuneAdapterInterface } from '@/types/tools/adapters/block-tune-adapter'; +import { ToolType } from '@/types/tools/adapters/tool-type'; /** * Stub class for BlockTunes * * @todo Implement */ -export default class BlockTune extends BaseTool { +export default class BlockTuneAdapter extends BaseToolAdapter implements BlockTuneAdapterInterface { /** * Tool type — Tune */ - public type = ToolType.Tune; + public type: ToolType.Tune = ToolType.Tune; /** * Tool's constructable blueprint diff --git a/src/components/ui/toolbox.ts b/src/components/ui/toolbox.ts index 0e1502b6b..0cbcb85cd 100644 --- a/src/components/ui/toolbox.ts +++ b/src/components/ui/toolbox.ts @@ -1,16 +1,17 @@ import * as _ from '../utils'; import { BlockToolAPI } from '../block'; import Shortcuts from '../utils/shortcuts'; -import BlockTool from '../tools/block'; -import ToolsCollection from '../tools/collection'; -import { API, BlockToolData, ToolboxConfigEntry, PopoverItemParams, BlockAPI } from '../../../types'; +import type BlockToolAdapter from '../tools/block'; +import type ToolsCollection from '../tools/collection'; +import type { API, BlockToolData, ToolboxConfigEntry, PopoverItemParams, BlockAPI } from '@/types'; import EventsDispatcher from '../utils/events'; import I18n from '../i18n'; import { I18nInternalNS } from '../i18n/namespace-internal'; -import { PopoverEvent } from '../utils/popover/popover.types'; +import { PopoverEvent } from '@/types/utils/popover/popover-event'; import Listeners from '../utils/listeners'; import Dom from '../dom'; -import { Popover, PopoverDesktop, PopoverMobile } from '../utils/popover'; +import type { Popover } from '../utils/popover'; +import { PopoverDesktop, PopoverMobile } from '../utils/popover'; import { EditorMobileLayoutToggled } from '../events'; /** @@ -98,7 +99,7 @@ export default class Toolbox extends EventsDispatcher { /** * List of Tools available. Some of them will be shown in the Toolbox */ - private tools: ToolsCollection; + private tools: ToolsCollection; /** * Text labels used in the Toolbox. Should be passed from the i18n module @@ -130,7 +131,7 @@ export default class Toolbox extends EventsDispatcher { * @param options.api - Editor API methods * @param options.tools - Tools available to check whether some of them should be displayed at the Toolbox or not */ - constructor({ api, tools, i18nLabels }: {api: API; tools: ToolsCollection; i18nLabels: Record}) { + constructor({ api, tools, i18nLabels }: {api: API; tools: ToolsCollection; i18nLabels: Record}) { super(); this.api = api; @@ -285,8 +286,8 @@ export default class Toolbox extends EventsDispatcher { * Returns list of tools that enables the Toolbox (by specifying the 'toolbox' getter) */ @_.cacheable - private get toolsToBeDisplayed(): BlockTool[] { - const result: BlockTool[] = []; + private get toolsToBeDisplayed(): BlockToolAdapter[] { + const result: BlockToolAdapter[] = []; this.tools.forEach((tool) => { const toolToolboxSettings = tool.toolbox; @@ -307,7 +308,7 @@ export default class Toolbox extends EventsDispatcher { /** * Maps tool data to popover item structure */ - const toPopoverItem = (toolboxItem: ToolboxConfigEntry, tool: BlockTool): PopoverItemParams => { + const toPopoverItem = (toolboxItem: ToolboxConfigEntry, tool: BlockToolAdapter): PopoverItemParams => { return { icon: toolboxItem.icon, title: I18n.t(I18nInternalNS.toolNames, toolboxItem.title || _.capitalize(tool.name)), @@ -337,7 +338,7 @@ export default class Toolbox extends EventsDispatcher { * Iterate all tools and enable theirs shortcuts if specified */ private enableShortcuts(): void { - this.toolsToBeDisplayed.forEach((tool: BlockTool) => { + this.toolsToBeDisplayed.forEach((tool: BlockToolAdapter) => { const shortcut = tool.shortcut; if (shortcut) { @@ -386,7 +387,7 @@ export default class Toolbox extends EventsDispatcher { * Fired when the Read-Only mode is activated */ private removeAllShortcuts(): void { - this.toolsToBeDisplayed.forEach((tool: BlockTool) => { + this.toolsToBeDisplayed.forEach((tool: BlockToolAdapter) => { const shortcut = tool.shortcut; if (shortcut) { diff --git a/src/components/utils/api.ts b/src/components/utils/api.ts index 4031bf6f1..2aa0e255b 100644 --- a/src/components/utils/api.ts +++ b/src/components/utils/api.ts @@ -1,6 +1,6 @@ import type { BlockAPI } from '../../../types/api/block'; -import { EditorModules } from '../../types-internal/editor-modules'; -import Block from '../block'; +import type { EditorModules } from '../../types-internal/editor-modules'; +import type Block from '../block'; /** * Returns Block instance by passed Block index or Block id diff --git a/src/components/utils/blocks.ts b/src/components/utils/blocks.ts index 52e739bfa..471bb8647 100644 --- a/src/components/utils/blocks.ts +++ b/src/components/utils/blocks.ts @@ -1,9 +1,9 @@ -import { BlockAPI } from '../../../types'; +import type { BlockAPI } from '../../../types'; import type { ConversionConfig } from '../../../types/configs/conversion-config'; -import { SavedData } from '../../../types/data-formats'; +import type { SavedData } from '../../../types/data-formats'; import type { BlockToolData } from '../../../types/tools/block-tool-data'; import type Block from '../block'; -import BlockTool from '../tools/block'; +import type BlockToolAdapter from '../tools/block'; import { isFunction, isString, log, equals, isEmpty } from '../utils'; import { isToolConvertable } from './tools'; @@ -47,7 +47,7 @@ export function isSameBlockData(data1: BlockToolData, data2: BlockToolData): boo * @param block - block to get conversion items for * @param allBlockTools - all block tools available in the editor */ -export async function getConvertibleToolsForBlock(block: BlockAPI, allBlockTools: BlockTool[]): Promise { +export async function getConvertibleToolsForBlock(block: BlockAPI, allBlockTools: BlockToolAdapter[]): Promise { const savedData = await block.save() as SavedData; const blockData = savedData.data; diff --git a/src/components/utils/notifier.ts b/src/components/utils/notifier.ts index 9ba64bf07..d71571602 100644 --- a/src/components/utils/notifier.ts +++ b/src/components/utils/notifier.ts @@ -3,7 +3,8 @@ * * @see https://github.com/codex-team/js-notifier */ -import notifier, { ConfirmNotifierOptions, NotifierOptions, PromptNotifierOptions } from 'codex-notifier'; +import type { ConfirmNotifierOptions, NotifierOptions, PromptNotifierOptions } from 'codex-notifier'; +import notifier from 'codex-notifier'; /** * Util for showing notifications diff --git a/src/components/utils/popover/components/hint/hint.ts b/src/components/utils/popover/components/hint/hint.ts index 828eb139b..8774f180d 100644 --- a/src/components/utils/popover/components/hint/hint.ts +++ b/src/components/utils/popover/components/hint/hint.ts @@ -1,6 +1,6 @@ import Dom from '../../../../dom'; import { css } from './hint.const'; -import { HintParams } from './hint.types'; +import type { HintParams } from '@/types/utils/popover/hint'; import './hint.css'; diff --git a/src/components/utils/popover/components/hint/index.ts b/src/components/utils/popover/components/hint/index.ts index 9e4870aba..94442802d 100644 --- a/src/components/utils/popover/components/hint/index.ts +++ b/src/components/utils/popover/components/hint/index.ts @@ -1,2 +1,6 @@ export * from './hint'; -export * from './hint.types'; +export type { + HintParams, + HintPosition, + HintTextAlignment +} from '@/types/utils/popover/hint'; diff --git a/src/components/utils/popover/components/popover-header/popover-header.ts b/src/components/utils/popover/components/popover-header/popover-header.ts index edfe4e412..62d558eaa 100644 --- a/src/components/utils/popover/components/popover-header/popover-header.ts +++ b/src/components/utils/popover/components/popover-header/popover-header.ts @@ -1,4 +1,4 @@ -import { PopoverHeaderParams } from './popover-header.types'; +import type { PopoverHeaderParams } from './popover-header.types'; import Dom from '../../../../dom'; import { css } from './popover-header.const'; import { IconChevronLeft } from '@codexteam/icons'; diff --git a/src/components/utils/popover/components/popover-item/index.ts b/src/components/utils/popover/components/popover-item/index.ts index 12c91d40a..5f2ff4a5f 100644 --- a/src/components/utils/popover/components/popover-item/index.ts +++ b/src/components/utils/popover/components/popover-item/index.ts @@ -3,7 +3,8 @@ import { PopoverItemSeparator } from './popover-item-separator/popover-item-sepa import { PopoverItem } from './popover-item'; export * from './popover-item-default/popover-item-default.const'; -export * from './popover-item.types'; +export type * from '@/types/utils/popover/popover-item.d.ts'; +export { PopoverItemType } from '@/types/utils/popover/popover-item-type'; export { PopoverItemDefault, diff --git a/src/components/utils/popover/components/popover-item/popover-item-default/popover-item-default.ts b/src/components/utils/popover/components/popover-item/popover-item-default/popover-item-default.ts index df50e73e4..7e9889ac8 100644 --- a/src/components/utils/popover/components/popover-item/popover-item-default/popover-item-default.ts +++ b/src/components/utils/popover/components/popover-item/popover-item-default/popover-item-default.ts @@ -1,10 +1,10 @@ import Dom from '../../../../../dom'; import { IconDotCircle, IconChevronRight } from '@codexteam/icons'; -import { +import type { PopoverItemDefaultParams as PopoverItemDefaultParams, PopoverItemRenderParamsMap, PopoverItemType -} from '../popover-item.types'; +} from '@/types/utils/popover/popover-item'; import { PopoverItem } from '../popover-item'; import { css } from './popover-item-default.const'; diff --git a/src/components/utils/popover/components/popover-item/popover-item-html/popover-item-html.ts b/src/components/utils/popover/components/popover-item/popover-item-html/popover-item-html.ts index d06210ebf..c578b14f9 100644 --- a/src/components/utils/popover/components/popover-item/popover-item-html/popover-item-html.ts +++ b/src/components/utils/popover/components/popover-item/popover-item-html/popover-item-html.ts @@ -1,5 +1,5 @@ import { PopoverItem } from '../popover-item'; -import { PopoverItemHtmlParams, PopoverItemRenderParamsMap, PopoverItemType } from '../popover-item.types'; +import type { PopoverItemHtmlParams, PopoverItemRenderParamsMap, PopoverItemType } from '@/types/utils/popover/popover-item'; import { css } from './popover-item-html.const'; import Dom from '../../../../../dom'; diff --git a/src/components/utils/popover/components/popover-item/popover-item.ts b/src/components/utils/popover/components/popover-item/popover-item.ts index 9fc59b563..b211cab99 100644 --- a/src/components/utils/popover/components/popover-item/popover-item.ts +++ b/src/components/utils/popover/components/popover-item/popover-item.ts @@ -1,6 +1,6 @@ import * as tooltip from '../../../../utils/tooltip'; import { type HintPosition, Hint } from '../hint'; -import { PopoverItemParams } from './popover-item.types'; +import type { PopoverItemParams } from '@/types/utils/popover/popover-item'; /** * Popover item abstract class diff --git a/src/components/utils/popover/components/search-input/search-input.ts b/src/components/utils/popover/components/search-input/search-input.ts index b726ce5a5..0714ff1c5 100644 --- a/src/components/utils/popover/components/search-input/search-input.ts +++ b/src/components/utils/popover/components/search-input/search-input.ts @@ -1,7 +1,8 @@ import Dom from '../../../../dom'; import Listeners from '../../../listeners'; import { IconSearch } from '@codexteam/icons'; -import { SearchInputEvent, SearchInputEventMap, SearchableItem } from './search-input.types'; +import type { SearchInputEventMap, SearchableItem } from './search-input.types'; +import { SearchInputEvent } from './search-input.types'; import { css } from './search-input.const'; import EventsDispatcher from '../../../events'; diff --git a/src/components/utils/popover/index.ts b/src/components/utils/popover/index.ts index d7834a95a..f335f5816 100644 --- a/src/components/utils/popover/index.ts +++ b/src/components/utils/popover/index.ts @@ -2,8 +2,8 @@ import { PopoverDesktop } from './popover-desktop'; import { PopoverInline } from './popover-inline'; import { PopoverMobile } from './popover-mobile'; -export * from './popover.types'; -export * from './components/popover-item/popover-item.types'; +export type * from '@/types/utils/popover'; +export { PopoverItemType } from '@/types/utils/popover/popover-item-type'; /** * Union type for all popovers diff --git a/src/components/utils/popover/popover-abstract.ts b/src/components/utils/popover/popover-abstract.ts index 7b8e8f0a3..f04539546 100644 --- a/src/components/utils/popover/popover-abstract.ts +++ b/src/components/utils/popover/popover-abstract.ts @@ -1,11 +1,13 @@ -import { PopoverItem, PopoverItemDefault, PopoverItemRenderParamsMap, PopoverItemSeparator, PopoverItemType } from './components/popover-item'; +import type { PopoverItem, PopoverItemRenderParamsMap } from './components/popover-item'; +import { PopoverItemDefault, PopoverItemSeparator, PopoverItemType } from './components/popover-item'; import Dom from '../../dom'; -import { SearchInput } from './components/search-input'; +import type { SearchInput } from './components/search-input'; import EventsDispatcher from '../events'; import Listeners from '../listeners'; -import { PopoverEventMap, PopoverMessages, PopoverParams, PopoverEvent, PopoverNodes } from './popover.types'; +import type { PopoverEventMap, PopoverMessages, PopoverParams, PopoverNodes } from '@/types/utils/popover/popover'; +import { PopoverEvent } from '@/types/utils/popover/popover-event'; import { css } from './popover.const'; -import { PopoverItemParams } from './components/popover-item'; +import type { PopoverItemParams } from './components/popover-item'; import { PopoverItemHtml } from './components/popover-item/popover-item-html/popover-item-html'; /** diff --git a/src/components/utils/popover/popover-desktop.ts b/src/components/utils/popover/popover-desktop.ts index 7adff41f8..1eb6ae6c0 100644 --- a/src/components/utils/popover/popover-desktop.ts +++ b/src/components/utils/popover/popover-desktop.ts @@ -1,10 +1,13 @@ import Flipper from '../../flipper'; import { PopoverAbstract } from './popover-abstract'; -import { PopoverItem, PopoverItemRenderParamsMap, PopoverItemSeparator, css as popoverItemCls } from './components/popover-item'; -import { PopoverEvent, PopoverParams } from './popover.types'; +import type { PopoverItem, PopoverItemRenderParamsMap } from './components/popover-item'; +import { PopoverItemSeparator, css as popoverItemCls } from './components/popover-item'; +import type { PopoverParams } from '@/types/utils/popover/popover'; +import { PopoverEvent } from '@/types/utils/popover/popover-event'; import { keyCodes } from '../../utils'; import { CSSVariables, css } from './popover.const'; -import { SearchInput, SearchInputEvent, SearchableItem } from './components/search-input'; +import type { SearchableItem } from './components/search-input'; +import { SearchInput, SearchInputEvent } from './components/search-input'; import { cacheable } from '../../utils'; import { PopoverItemDefault } from './components/popover-item'; import { PopoverItemHtml } from './components/popover-item/popover-item-html/popover-item-html'; diff --git a/src/components/utils/popover/popover-inline.ts b/src/components/utils/popover/popover-inline.ts index a82db45cf..ebe91223c 100644 --- a/src/components/utils/popover/popover-inline.ts +++ b/src/components/utils/popover/popover-inline.ts @@ -1,9 +1,10 @@ import { isMobileScreen } from '../../utils'; -import { PopoverItem, PopoverItemDefault, PopoverItemType } from './components/popover-item'; +import type { PopoverItem } from './components/popover-item'; +import { PopoverItemDefault, PopoverItemType } from './components/popover-item'; import { PopoverItemHtml } from './components/popover-item/popover-item-html/popover-item-html'; import { PopoverDesktop } from './popover-desktop'; import { CSSVariables, css } from './popover.const'; -import { PopoverParams } from './popover.types'; +import type { PopoverParams } from '@/types/utils/popover/popover'; /** * Horizontal popover that is displayed inline with the content @@ -128,7 +129,7 @@ export class PopoverInline extends PopoverDesktop { protected override showNestedItems(item: PopoverItemDefault | PopoverItemHtml): void { if (this.nestedPopoverTriggerItem === item) { this.destroyNestedPopoverIfExists(); - + this.nestedPopoverTriggerItem = null; return; diff --git a/src/components/utils/popover/popover-mobile.ts b/src/components/utils/popover/popover-mobile.ts index fb599199a..c787d7e3a 100644 --- a/src/components/utils/popover/popover-mobile.ts +++ b/src/components/utils/popover/popover-mobile.ts @@ -2,8 +2,9 @@ import { PopoverAbstract } from './popover-abstract'; import ScrollLocker from '../scroll-locker'; import { PopoverHeader } from './components/popover-header'; import { PopoverStatesHistory } from './utils/popover-states-history'; -import { PopoverMobileNodes, PopoverParams } from './popover.types'; -import { PopoverItemDefault, PopoverItemParams, PopoverItemType } from './components/popover-item'; +import type { PopoverMobileNodes, PopoverParams } from '@/types/utils/popover/popover'; +import type { PopoverItemDefault, PopoverItemParams } from './components/popover-item'; +import { PopoverItemType } from './components/popover-item'; import { css } from './popover.const'; import Dom from '../../dom'; diff --git a/src/components/utils/popover/utils/popover-states-history.ts b/src/components/utils/popover/utils/popover-states-history.ts index 92975468c..1beb3c26f 100644 --- a/src/components/utils/popover/utils/popover-states-history.ts +++ b/src/components/utils/popover/utils/popover-states-history.ts @@ -1,4 +1,4 @@ -import { PopoverItem } from '../../../../../types'; +import type { PopoverItemParams } from '@/types/utils/popover/popover-item'; /** * Represents single states history item @@ -12,7 +12,7 @@ interface PopoverStatesHistoryItem { /** * Popover items */ - items: PopoverItem[] + items: PopoverItemParams[] } /** @@ -54,7 +54,7 @@ export class PopoverStatesHistory { /** * Items list retrieved from the current state */ - public get currentItems(): PopoverItem[] { + public get currentItems(): PopoverItemParams[] { if (this.history.length === 0) { return []; } diff --git a/src/components/utils/sanitizer.ts b/src/components/utils/sanitizer.ts index 9d55a82a5..69d501490 100644 --- a/src/components/utils/sanitizer.ts +++ b/src/components/utils/sanitizer.ts @@ -30,8 +30,8 @@ import * as _ from '../utils'; */ import HTMLJanitor from 'html-janitor'; -import { BlockToolData, SanitizerConfig } from '../../../types'; -import { SavedData } from '../../../types/data-formats'; +import type { BlockToolData, SanitizerConfig } from '../../../types'; +import type { SavedData } from '../../../types/data-formats'; /** * Sanitize Blocks diff --git a/src/components/utils/tools.ts b/src/components/utils/tools.ts index 2defc4912..4c384ca6f 100644 --- a/src/components/utils/tools.ts +++ b/src/components/utils/tools.ts @@ -1,4 +1,4 @@ -import BlockTool from '../tools/block'; +import type BlockToolAdapter from '../tools/block'; import { isFunction, isString } from '../utils'; /** @@ -7,7 +7,7 @@ import { isFunction, isString } from '../utils'; * @param tool - tool to check * @param direction - export for tool to merge from, import for tool to merge to */ -export function isToolConvertable(tool: BlockTool, direction: 'export' | 'import'): boolean { +export function isToolConvertable(tool: BlockToolAdapter, direction: 'export' | 'import'): boolean { if (!tool.conversionConfig) { return false; } diff --git a/src/tools/stub/index.ts b/src/tools/stub/index.ts index be75295f8..a4c8aebc9 100644 --- a/src/tools/stub/index.ts +++ b/src/tools/stub/index.ts @@ -1,5 +1,5 @@ import $ from '../../components/dom'; -import { API, BlockTool, BlockToolConstructorOptions, BlockToolData } from '../../../types'; +import type { API, BlockTool, BlockToolConstructorOptions, BlockToolData } from '../../../types'; import { IconWarning } from '@codexteam/icons'; export interface StubData extends BlockToolData { diff --git a/test/cypress/fixtures/tools/ContentlessTool.ts b/test/cypress/fixtures/tools/ContentlessTool.ts index 49b137772..be5885665 100644 --- a/test/cypress/fixtures/tools/ContentlessTool.ts +++ b/test/cypress/fixtures/tools/ContentlessTool.ts @@ -1,4 +1,4 @@ -import { BlockTool } from '../../../../types'; +import type { BlockTool } from '../../../../types'; /** * In the simplest Contentless Tool (eg. Delimiter) there is no data to save diff --git a/test/cypress/fixtures/tools/SimpleHeader.ts b/test/cypress/fixtures/tools/SimpleHeader.ts index 17cb89a9f..cd87aa7ec 100644 --- a/test/cypress/fixtures/tools/SimpleHeader.ts +++ b/test/cypress/fixtures/tools/SimpleHeader.ts @@ -1,4 +1,4 @@ -import { +import type { BaseTool, BlockToolConstructorOptions, BlockToolData, diff --git a/test/cypress/fixtures/tools/ToolMock.ts b/test/cypress/fixtures/tools/ToolMock.ts index 0ef835f8a..67b290454 100644 --- a/test/cypress/fixtures/tools/ToolMock.ts +++ b/test/cypress/fixtures/tools/ToolMock.ts @@ -1,4 +1,4 @@ -import { BlockTool, BlockToolConstructorOptions } from '../../../../types'; +import type { BlockTool, BlockToolConstructorOptions } from '../../../../types'; /** * Simple structure for Tool data diff --git a/test/cypress/fixtures/types/PartialBlockMutationEvent.ts b/test/cypress/fixtures/types/PartialBlockMutationEvent.ts index 75e23e990..6955eb227 100644 --- a/test/cypress/fixtures/types/PartialBlockMutationEvent.ts +++ b/test/cypress/fixtures/types/PartialBlockMutationEvent.ts @@ -1,4 +1,4 @@ -import { BlockMutationEvent, BlockMutationType } from '../../../../types'; +import type { BlockMutationEvent, BlockMutationType } from '../../../../types'; /** * Simplified version of the BlockMutationEvent with optional fields that could be used in tests diff --git a/test/cypress/plugins/index.ts b/test/cypress/plugins/index.ts deleted file mode 100644 index 3731ae53e..000000000 --- a/test/cypress/plugins/index.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * This file contains connection of Cypres plugins - */ -import * as codeCoverageTask from '@cypress/code-coverage/task'; - -module.exports = (on, config): unknown => { - /** - * Add Cypress task to get code coverage - */ - codeCoverageTask(on, config); - - // It's IMPORTANT to return the config object - // with any changed environment variables - return config; -}; diff --git a/test/cypress/support/e2e.ts b/test/cypress/support/e2e.ts index d19830777..0ec34b571 100644 --- a/test/cypress/support/e2e.ts +++ b/test/cypress/support/e2e.ts @@ -1,3 +1,4 @@ +import '@cypress/code-coverage/support'; /* global chai */ // because this file is imported from cypress/support/e2e.js @@ -5,7 +6,7 @@ // available to them because the supportFile is bundled and served // prior to any spec files loading -import PartialBlockMutationEvent from '../fixtures/types/PartialBlockMutationEvent'; +import type PartialBlockMutationEvent from '../fixtures/types/PartialBlockMutationEvent'; /** * Chai plugin for checking if passed onChange method is called with an array of passed events diff --git a/test/cypress/support/utils/createEditorWithTextBlocks.ts b/test/cypress/support/utils/createEditorWithTextBlocks.ts index 95deb66bb..08d0fc49b 100644 --- a/test/cypress/support/utils/createEditorWithTextBlocks.ts +++ b/test/cypress/support/utils/createEditorWithTextBlocks.ts @@ -1,4 +1,4 @@ -import { EditorConfig } from '../../../../types/index'; +import type { EditorConfig } from '../../../../types/index'; import Chainable = Cypress.Chainable; import type EditorJS from '../../../../types/index'; diff --git a/test/cypress/tests/api/block.cy.ts b/test/cypress/tests/api/block.cy.ts index 188de0689..d5d731f11 100644 --- a/test/cypress/tests/api/block.cy.ts +++ b/test/cypress/tests/api/block.cy.ts @@ -1,4 +1,4 @@ -import EditorJS from '../../../../types'; +import type EditorJS from '../../../../types'; import { BlockChangedMutationType } from '../../../../types/events/block/BlockChanged'; /** diff --git a/test/cypress/tests/api/blocks.cy.ts b/test/cypress/tests/api/blocks.cy.ts index 66496bf71..17da54bf3 100644 --- a/test/cypress/tests/api/blocks.cy.ts +++ b/test/cypress/tests/api/blocks.cy.ts @@ -1,7 +1,7 @@ import type EditorJS from '../../../../types/index'; import type { ConversionConfig, ToolboxConfig } from '../../../../types'; import ToolMock from '../../fixtures/tools/ToolMock'; -import {nanoid} from "nanoid"; +import { nanoid } from 'nanoid'; /** * There will be described test cases of 'blocks.*' API @@ -108,13 +108,12 @@ describe('api.blocks', () => { * Example Tune Class */ class ExampleTune { - protected data: object; /** * * @param data */ - constructor({ data}) { + constructor({ data }) { this.data = data; } diff --git a/test/cypress/tests/api/caret.cy.ts b/test/cypress/tests/api/caret.cy.ts index a50c7b276..882bc1534 100644 --- a/test/cypress/tests/api/caret.cy.ts +++ b/test/cypress/tests/api/caret.cy.ts @@ -1,4 +1,4 @@ -import EditorJS from '../../../../types'; +import type EditorJS from '../../../../types'; /** * Test cases for Caret API diff --git a/test/cypress/tests/api/toolbar.cy.ts b/test/cypress/tests/api/toolbar.cy.ts index 4a8d92378..41da27b14 100644 --- a/test/cypress/tests/api/toolbar.cy.ts +++ b/test/cypress/tests/api/toolbar.cy.ts @@ -1,7 +1,7 @@ /** * There will be described test cases of 'api.toolbar.*' API */ -import EditorJS from '../../../../types'; +import type EditorJS from '../../../../types'; describe('api.toolbar', () => { /** diff --git a/test/cypress/tests/api/tools.cy.ts b/test/cypress/tests/api/tools.cy.ts index 85bfae7e3..be6a52c3b 100644 --- a/test/cypress/tests/api/tools.cy.ts +++ b/test/cypress/tests/api/tools.cy.ts @@ -1,6 +1,6 @@ -import { ToolboxConfig, BlockToolData, ToolboxConfigEntry, PasteConfig } from '../../../../types'; -import EditorJS from '../../../../types'; -import { HTMLPasteEvent, TunesMenuConfig } from '../../../../types/tools'; +import type { ToolboxConfig, BlockToolData, ToolboxConfigEntry, PasteConfig } from '../../../../types'; +import type EditorJS from '../../../../types'; +import type { HTMLPasteEvent, TunesMenuConfig } from '../../../../types/tools'; /* eslint-disable @typescript-eslint/no-empty-function */ diff --git a/test/cypress/tests/api/tunes.cy.ts b/test/cypress/tests/api/tunes.cy.ts index 9cf760fe9..104e4d4ac 100644 --- a/test/cypress/tests/api/tunes.cy.ts +++ b/test/cypress/tests/api/tunes.cy.ts @@ -1,4 +1,4 @@ -import { TunesMenuConfig } from '../../../../types/tools'; +import type { TunesMenuConfig } from '../../../../types/tools'; /* eslint-disable @typescript-eslint/no-empty-function */ diff --git a/test/cypress/tests/copy-paste.cy.ts b/test/cypress/tests/copy-paste.cy.ts index 4862c5109..9a9b4a27c 100644 --- a/test/cypress/tests/copy-paste.cy.ts +++ b/test/cypress/tests/copy-paste.cy.ts @@ -1,7 +1,7 @@ import Header from '@editorjs/header'; import Image from '@editorjs/simple-image'; import * as _ from '../../../src/components/utils'; -import { BlockTool, BlockToolData, OutputData } from '../../../types'; +import type { BlockTool, BlockToolData, OutputData } from '../../../types'; import $ from '../../../src/components/dom'; import type EditorJS from '../../../types/index'; diff --git a/test/cypress/tests/i18n.cy.ts b/test/cypress/tests/i18n.cy.ts index b17df991e..1bcb6b4c7 100644 --- a/test/cypress/tests/i18n.cy.ts +++ b/test/cypress/tests/i18n.cy.ts @@ -1,5 +1,5 @@ import Header from '@editorjs/header'; -import { ToolboxConfig } from '../../../types'; +import type { ToolboxConfig } from '../../../types'; describe('Editor i18n', () => { context('Toolbox', () => { diff --git a/test/cypress/tests/modules/Tools.cy.ts b/test/cypress/tests/modules/Tools.cy.ts index 407ed032a..b4d7770b4 100644 --- a/test/cypress/tests/modules/Tools.cy.ts +++ b/test/cypress/tests/modules/Tools.cy.ts @@ -1,8 +1,8 @@ /* tslint:disable:max-classes-per-file */ /* eslint-disable @typescript-eslint/no-explicit-any, jsdoc/require-jsdoc */ import Tools from '../../../../src/components/modules/tools'; -import { EditorConfig } from '../../../../types'; -import BlockTool from '../../../../src/components/tools/block'; +import type { EditorConfig } from '../../../../types'; +import BlockToolAdapter from '../../../../src/components/tools/block'; describe('Tools module', () => { const defaultConfig = { @@ -315,7 +315,7 @@ describe('Tools module', () => { * @todo add check if user provided default tool is not Block Tool */ it('should return BlockTool instance', () => { - expect(module.defaultTool).to.be.instanceOf(BlockTool); + expect(module.defaultTool).to.be.instanceOf(BlockToolAdapter); }); it('should return default Tool', () => { diff --git a/test/cypress/tests/readOnly.cy.ts b/test/cypress/tests/readOnly.cy.ts index 8bd922568..f1f8e496c 100644 --- a/test/cypress/tests/readOnly.cy.ts +++ b/test/cypress/tests/readOnly.cy.ts @@ -1,4 +1,5 @@ -import EditorJS, { EditorConfig } from '../../../types'; +import type { EditorConfig } from '../../../types'; +import type EditorJS from '../../../types'; describe('ReadOnly API spec', () => { /** diff --git a/test/cypress/tests/sanitisation.cy.ts b/test/cypress/tests/sanitisation.cy.ts index 313e7085c..f0653b870 100644 --- a/test/cypress/tests/sanitisation.cy.ts +++ b/test/cypress/tests/sanitisation.cy.ts @@ -1,5 +1,5 @@ import type EditorJS from '../../../types/index'; -import { OutputData } from '../../../types/index'; +import type { OutputData } from '../../../types/index'; /* eslint-disable @typescript-eslint/no-explicit-any */ diff --git a/test/cypress/tests/tools/BlockTool.cy.ts b/test/cypress/tests/tools/BlockTool.cy.ts index 29df97712..f1ec19243 100644 --- a/test/cypress/tests/tools/BlockTool.cy.ts +++ b/test/cypress/tests/tools/BlockTool.cy.ts @@ -1,9 +1,9 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ /* tslint:disable:max-classes-per-file */ -import { BlockToolData, ToolSettings } from '../../../../types'; -import { ToolType } from '../../../../src/components/tools/base'; -import BlockTool from '../../../../src/components/tools/block'; -import InlineTool from '../../../../src/components/tools/inline'; +import type { BlockToolData, ToolSettings } from '@/types'; +import { ToolType } from '@/types/tools/adapters/tool-type'; +import BlockToolAdapter from '../../../../src/components/tools/block'; +import InlineToolAdapter from '../../../../src/components/tools/inline'; import ToolsCollection from '../../../../src/components/tools/collection'; describe('BlockTool', () => { @@ -80,20 +80,20 @@ describe('BlockTool', () => { }; it('.type should return ToolType.Block', () => { - const tool = new BlockTool(options as any); + const tool = new BlockToolAdapter(options as any); expect(tool.type).to.be.eq(ToolType.Block); }); it('.name should return correct value', () => { - const tool = new BlockTool(options as any); + const tool = new BlockToolAdapter(options as any); expect(tool.name).to.be.eq(options.name); }); it('.isDefault should return correct value', () => { - const tool1 = new BlockTool(options as any); - const tool2 = new BlockTool({ + const tool1 = new BlockToolAdapter(options as any); + const tool2 = new BlockToolAdapter({ ...options, isDefault: true, } as any); @@ -103,8 +103,8 @@ describe('BlockTool', () => { }); it('.isInternal should return correct value', () => { - const tool1 = new BlockTool(options as any); - const tool2 = new BlockTool({ + const tool1 = new BlockToolAdapter(options as any); + const tool2 = new BlockToolAdapter({ ...options, isInternal: true, } as any); @@ -115,13 +115,13 @@ describe('BlockTool', () => { context('.settings', () => { it('should return correct value', () => { - const tool = new BlockTool(options as any); + const tool = new BlockToolAdapter(options as any); expect(tool.settings).to.be.deep.eq(options.config.config); }); it('should add default placeholder if Tool is default', () => { - const tool = new BlockTool({ + const tool = new BlockToolAdapter({ ...options, isDefault: true, } as any); @@ -132,15 +132,15 @@ describe('BlockTool', () => { context('.sanitizeConfig', () => { it('should return correct value', () => { - const tool = new BlockTool(options as any); + const tool = new BlockToolAdapter(options as any); expect(tool.sanitizeConfig).to.be.deep.eq(options.constructable.sanitize); }); it('should return composed config if there are enabled inline tools', () => { - const tool = new BlockTool(options as any); + const tool = new BlockToolAdapter(options as any); - const inlineTool = new InlineTool({ + const inlineTool = new InlineToolAdapter({ name: 'inlineTool', constructable: class { public static sanitize = { @@ -167,12 +167,12 @@ describe('BlockTool', () => { }); it('should return inline tools config if block one is not set', () => { - const tool = new BlockTool({ + const tool = new BlockToolAdapter({ ...options, constructable: class {}, } as any); - const inlineTool1 = new InlineTool({ + const inlineTool1 = new InlineToolAdapter({ name: 'inlineTool', constructable: class { public static sanitize = { @@ -183,7 +183,7 @@ describe('BlockTool', () => { config: {}, } as any); - const inlineTool2 = new InlineTool({ + const inlineTool2 = new InlineToolAdapter({ name: 'inlineTool', constructable: class { public static sanitize = { @@ -204,7 +204,7 @@ describe('BlockTool', () => { }); it('should return empty object by default', () => { - const tool = new BlockTool({ + const tool = new BlockToolAdapter({ ...options, constructable: class {}, } as any); @@ -214,44 +214,44 @@ describe('BlockTool', () => { }); it('.isBlock() should return true', () => { - const tool = new BlockTool(options as any); + const tool = new BlockToolAdapter(options as any); expect(tool.isBlock()).to.be.true; }); it('.isInline() should return false', () => { - const tool = new BlockTool(options as any); + const tool = new BlockToolAdapter(options as any); expect(tool.isInline()).to.be.false; }); it('.isTune() should return false', () => { - const tool = new BlockTool(options as any); + const tool = new BlockToolAdapter(options as any); expect(tool.isTune()).to.be.false; }); it('.isReadOnlySupported should return correct value', () => { - const tool = new BlockTool(options as any); + const tool = new BlockToolAdapter(options as any); expect(tool.isReadOnlySupported).to.be.eq(options.constructable.isReadOnlySupported); }); it('.isLineBreaksEnabled should return correct value', () => { - const tool = new BlockTool(options as any); + const tool = new BlockToolAdapter(options as any); expect(tool.isLineBreaksEnabled).to.be.eq(options.constructable.enableLineBreaks); }); it('.conversionConfig should return correct value', () => { - const tool = new BlockTool(options as any); + const tool = new BlockToolAdapter(options as any); expect(tool.conversionConfig).to.be.deep.eq(options.constructable.conversionConfig); }); describe('.pasteConfig', () => { it('should return correct value', () => { - const tool = new BlockTool(options as any); + const tool = new BlockToolAdapter(options as any); expect(tool.pasteConfig).to.be.deep.eq(options.constructable.pasteConfig); }); @@ -263,7 +263,7 @@ describe('BlockTool', () => { public static pasteConfig = false; }, }; - const tool = new BlockTool(optionsWithDisabledPaste as any); + const tool = new BlockToolAdapter(optionsWithDisabledPaste as any); expect(tool.pasteConfig).to.be.deep.eq(optionsWithDisabledPaste.constructable.pasteConfig); }); @@ -275,7 +275,7 @@ describe('BlockTool', () => { public static pasteConfig = undefined; }, }; - const tool = new BlockTool(optionsWithoutPasteConfig as any); + const tool = new BlockToolAdapter(optionsWithoutPasteConfig as any); expect(tool.pasteConfig).to.be.deep.eq({}); }); @@ -283,13 +283,13 @@ describe('BlockTool', () => { context('.enabledInlineTools', () => { it('should return correct value', () => { - const tool = new BlockTool(options as any); + const tool = new BlockToolAdapter(options as any); expect(tool.enabledInlineTools).to.be.deep.eq(options.config.inlineToolbar); }); it('should return false by default', () => { - const tool = new BlockTool({ + const tool = new BlockToolAdapter({ ...options, config: { ...options.config, @@ -302,7 +302,7 @@ describe('BlockTool', () => { }); it('.enabledBlockTunes should return correct value', () => { - const tool = new BlockTool(options as any); + const tool = new BlockToolAdapter(options as any); expect(tool.enabledBlockTunes).to.be.deep.eq(options.config.tunes); }); @@ -310,7 +310,7 @@ describe('BlockTool', () => { context('.prepare()', () => { it('should call Tool prepare method', () => { options.constructable.prepare = cy.stub(); - const tool = new BlockTool(options as any); + const tool = new BlockToolAdapter(options as any); tool.prepare(); @@ -321,7 +321,7 @@ describe('BlockTool', () => { }); it('should not fail if Tool prepare method is not exist', () => { - const tool = new BlockTool({ + const tool = new BlockToolAdapter({ ...options, constructable: {}, } as any); @@ -333,7 +333,7 @@ describe('BlockTool', () => { context('.reset()', () => { it('should call Tool reset method', () => { options.constructable.reset = cy.stub(); - const tool = new BlockTool(options as any); + const tool = new BlockToolAdapter(options as any); tool.reset(); @@ -341,7 +341,7 @@ describe('BlockTool', () => { }); it('should not fail if Tool reset method is not exist', () => { - const tool = new BlockTool({ + const tool = new BlockToolAdapter({ ...options, constructable: {}, } as any); @@ -352,13 +352,13 @@ describe('BlockTool', () => { context('.shortcut', () => { it('should return user provided shortcut', () => { - const tool = new BlockTool(options as any); + const tool = new BlockToolAdapter(options as any); expect(tool.shortcut).to.be.eq(options.config.shortcut); }); it('should return Tool provided shortcut if user one is not specified', () => { - const tool = new BlockTool({ + const tool = new BlockToolAdapter({ ...options, config: { ...options.config, @@ -372,13 +372,13 @@ describe('BlockTool', () => { context('.toolbox', () => { it('should return user provided toolbox config wrapped in array', () => { - const tool = new BlockTool(options as any); + const tool = new BlockToolAdapter(options as any); expect(tool.toolbox).to.be.deep.eq([ options.config.toolbox ]); }); it('should return Tool provided toolbox config wrapped in array if user one is not specified', () => { - const tool = new BlockTool({ + const tool = new BlockToolAdapter({ ...options, config: { ...options.config, @@ -390,7 +390,7 @@ describe('BlockTool', () => { }); it('should merge Tool provided toolbox config and user one and wrap result in array in case both are objects', () => { - const tool1 = new BlockTool({ + const tool1 = new BlockToolAdapter({ ...options, config: { ...options.config, @@ -399,7 +399,7 @@ describe('BlockTool', () => { }, }, } as any); - const tool2 = new BlockTool({ + const tool2 = new BlockToolAdapter({ ...options, config: { ...options.config, @@ -426,7 +426,7 @@ describe('BlockTool', () => { icon: options.config.toolbox.icon, title: options.config.toolbox.title, }; - const tool = new BlockTool({ + const tool = new BlockToolAdapter({ ...options, constructable: { ...options.constructable, @@ -450,7 +450,7 @@ describe('BlockTool', () => { title: 'Toolbox entry 2', }, ]; - const tool = new BlockTool({ + const tool = new BlockToolAdapter({ ...options, config: { ...options.config, @@ -478,7 +478,7 @@ describe('BlockTool', () => { }, ]; - const tool = new BlockTool({ + const tool = new BlockToolAdapter({ ...options, constructable: { ...options.constructable, @@ -507,7 +507,7 @@ describe('BlockTool', () => { }); it('should return undefined if user specifies false as a value', () => { - const tool = new BlockTool({ + const tool = new BlockToolAdapter({ ...options, config: { ...options.config, @@ -519,7 +519,7 @@ describe('BlockTool', () => { }); it('should return undefined if Tool specifies false as a value', () => { - const tool = new BlockTool({ + const tool = new BlockToolAdapter({ ...options, constructable: class { public static toolbox = false; @@ -530,7 +530,7 @@ describe('BlockTool', () => { }); it('should return undefined if Tool provides empty config', () => { - const tool = new BlockTool({ + const tool = new BlockToolAdapter({ ...options, constructable: class { public static toolbox = {}; @@ -542,7 +542,7 @@ describe('BlockTool', () => { }); context('.create()', () => { - const tool = new BlockTool(options as any); + const tool = new BlockToolAdapter(options as any); const data = { text: 'text' }; const blockAPI = { // eslint-disable-next-line @typescript-eslint/no-empty-function diff --git a/test/cypress/tests/tools/BlockTune.cy.ts b/test/cypress/tests/tools/BlockTune.cy.ts index 3c8c14fa0..c0001b4a3 100644 --- a/test/cypress/tests/tools/BlockTune.cy.ts +++ b/test/cypress/tests/tools/BlockTune.cy.ts @@ -1,9 +1,9 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ /* tslint:disable:max-classes-per-file */ -import { ToolSettings } from '../../../../types'; -import { ToolType } from '../../../../src/components/tools/base'; -import BlockTune from '../../../../src/components/tools/tune'; -import { BlockTuneData } from '../../../../types/block-tunes/block-tune-data'; +import type { ToolSettings } from '@/types'; +import { ToolType } from '@/types/tools/adapters/tool-type'; +import BlockTuneAdapter from '../../../../src/components/tools/tune'; +import type { BlockTuneData } from '@/types/block-tunes/block-tune-data'; describe('BlockTune', () => { /** @@ -45,20 +45,20 @@ describe('BlockTune', () => { }; it('.type should return ToolType.Tune', () => { - const tool = new BlockTune(options as any); + const tool = new BlockTuneAdapter(options as any); expect(tool.type).to.be.eq(ToolType.Tune); }); it('.name should return correct value', () => { - const tool = new BlockTune(options as any); + const tool = new BlockTuneAdapter(options as any); expect(tool.name).to.be.eq(options.name); }); it('.isInternal should return correct value', () => { - const tool1 = new BlockTune(options as any); - const tool2 = new BlockTune({ + const tool1 = new BlockTuneAdapter(options as any); + const tool2 = new BlockTuneAdapter({ ...options, isInternal: true, } as any); @@ -68,25 +68,25 @@ describe('BlockTune', () => { }); it('.settings should return correct value', () => { - const tool = new BlockTune(options as any); + const tool = new BlockTuneAdapter(options as any); expect(tool.settings).to.be.deep.eq(options.config.config); }); it('.isBlock() should return false', () => { - const tool = new BlockTune(options as any); + const tool = new BlockTuneAdapter(options as any); expect(tool.isBlock()).to.be.false; }); it('.isInline() should return false', () => { - const tool = new BlockTune(options as any); + const tool = new BlockTuneAdapter(options as any); expect(tool.isInline()).to.be.false; }); it('.isTune() should return true', () => { - const tool = new BlockTune(options as any); + const tool = new BlockTuneAdapter(options as any); expect(tool.isTune()).to.be.true; }); @@ -94,7 +94,7 @@ describe('BlockTune', () => { context('.prepare()', () => { it('should call Tool prepare method', () => { options.constructable.prepare = cy.stub(); - const tool = new BlockTune(options as any); + const tool = new BlockTuneAdapter(options as any); tool.prepare(); @@ -105,7 +105,7 @@ describe('BlockTune', () => { }); it('should not fail if Tool prepare method is not exist', () => { - const tool = new BlockTune({ + const tool = new BlockTuneAdapter({ ...options, constructable: {}, } as any); @@ -117,7 +117,7 @@ describe('BlockTune', () => { context('.reset()', () => { it('should call Tool reset method', () => { options.constructable.reset = cy.stub(); - const tool = new BlockTune(options as any); + const tool = new BlockTuneAdapter(options as any); tool.reset(); @@ -125,7 +125,7 @@ describe('BlockTune', () => { }); it('should not fail if Tool reset method is not exist', () => { - const tool = new BlockTune({ + const tool = new BlockTuneAdapter({ ...options, constructable: {}, } as any); @@ -135,7 +135,7 @@ describe('BlockTune', () => { }); context('.create()', () => { - const tool = new BlockTune(options as any); + const tool = new BlockTuneAdapter(options as any); const data = { text: 'text' }; const blockAPI = { // eslint-disable-next-line @typescript-eslint/no-empty-function diff --git a/test/cypress/tests/tools/InlineTool.cy.ts b/test/cypress/tests/tools/InlineTool.cy.ts index 656d0f37e..62fd8868f 100644 --- a/test/cypress/tests/tools/InlineTool.cy.ts +++ b/test/cypress/tests/tools/InlineTool.cy.ts @@ -1,8 +1,8 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ /* tslint:disable:max-classes-per-file */ -import { ToolSettings } from '../../../../types'; -import { ToolType } from '../../../../src/components/tools/base'; -import InlineTool from '../../../../src/components/tools/inline'; +import type { ToolSettings } from '@/types'; +import { ToolType } from '@/types/tools/adapters/tool-type'; +import InlineToolAdapter from '../../../../src/components/tools/inline'; describe('InlineTool', () => { /** @@ -52,26 +52,26 @@ describe('InlineTool', () => { }; it('.type should return ToolType.Inline', () => { - const tool = new InlineTool(options as any); + const tool = new InlineToolAdapter(options as any); expect(tool.type).to.be.eq(ToolType.Inline); }); it('.name should return correct value', () => { - const tool = new InlineTool(options as any); + const tool = new InlineToolAdapter(options as any); expect(tool.name).to.be.eq(options.name); }); it('.title should return correct title', () => { - const tool = new InlineTool(options as any); + const tool = new InlineToolAdapter(options as any); expect(tool.title).to.be.eq(options.constructable.title); }); it('.isInternal should return correct value', () => { - const tool1 = new InlineTool(options as any); - const tool2 = new InlineTool({ + const tool1 = new InlineToolAdapter(options as any); + const tool2 = new InlineToolAdapter({ ...options, isInternal: true, } as any); @@ -81,31 +81,31 @@ describe('InlineTool', () => { }); it('.settings should return correct value', () => { - const tool = new InlineTool(options as any); + const tool = new InlineToolAdapter(options as any); expect(tool.settings).to.be.deep.eq(options.config.config); }); it('.sanitizeConfig should return correct value', () => { - const tool = new InlineTool(options as any); + const tool = new InlineToolAdapter(options as any); expect(tool.sanitizeConfig).to.be.deep.eq(options.constructable.sanitize); }); it('.isBlock() should return false', () => { - const tool = new InlineTool(options as any); + const tool = new InlineToolAdapter(options as any); expect(tool.isBlock()).to.be.false; }); it('.isInline() should return true', () => { - const tool = new InlineTool(options as any); + const tool = new InlineToolAdapter(options as any); expect(tool.isInline()).to.be.true; }); it('.isTune() should return false', () => { - const tool = new InlineTool(options as any); + const tool = new InlineToolAdapter(options as any); expect(tool.isTune()).to.be.false; }); @@ -113,7 +113,7 @@ describe('InlineTool', () => { context('.prepare()', () => { it('should call Tool prepare method', () => { options.constructable.prepare = cy.stub(); - const tool = new InlineTool(options as any); + const tool = new InlineToolAdapter(options as any); tool.prepare(); @@ -124,7 +124,7 @@ describe('InlineTool', () => { }); it('should not fail if Tool prepare method is not exist', () => { - const tool = new InlineTool({ + const tool = new InlineToolAdapter({ ...options, constructable: {}, } as any); @@ -136,7 +136,7 @@ describe('InlineTool', () => { context('.reset()', () => { it('should call Tool reset method', () => { options.constructable.reset = cy.stub(); - const tool = new InlineTool(options as any); + const tool = new InlineToolAdapter(options as any); tool.reset(); @@ -144,7 +144,7 @@ describe('InlineTool', () => { }); it('should not fail if Tool reset method is not exist', () => { - const tool = new InlineTool({ + const tool = new InlineToolAdapter({ ...options, constructable: {}, } as any); @@ -155,13 +155,13 @@ describe('InlineTool', () => { context('.shortcut', () => { it('should return user provided shortcut', () => { - const tool = new InlineTool(options as any); + const tool = new InlineToolAdapter(options as any); expect(tool.shortcut).to.be.eq(options.config.shortcut); }); it('should return Tool provided shortcut if user one is not specified', () => { - const tool = new InlineTool({ + const tool = new InlineToolAdapter({ ...options, config: { ...options.config, @@ -174,7 +174,7 @@ describe('InlineTool', () => { }); context('.create()', () => { - const tool = new InlineTool(options as any); + const tool = new InlineToolAdapter(options as any); it('should return Tool instance', () => { expect(tool.create()).to.be.instanceOf(options.constructable); diff --git a/test/cypress/tests/tools/ToolsCollection.cy.ts b/test/cypress/tests/tools/ToolsCollection.cy.ts index 126093240..b6dcb37e4 100644 --- a/test/cypress/tests/tools/ToolsCollection.cy.ts +++ b/test/cypress/tests/tools/ToolsCollection.cy.ts @@ -1,9 +1,9 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import ToolsCollection from '../../../../src/components/tools/collection'; -import BlockTool from '../../../../src/components/tools/block'; -import InlineTool from '../../../../src/components/tools/inline'; -import BlockTune from '../../../../src/components/tools/tune'; -import BaseTool from '../../../../src/components/tools/base'; +import type BlockToolAdapter from '../../../../src/components/tools/block'; +import type InlineToolAdapter from '../../../../src/components/tools/inline'; +import type BlockTuneAdapter from '../../../../src/components/tools/tune'; +import type BaseToolAdapter from '../../../../src/components/tools/base'; const FakeTool = { isBlock(): boolean { @@ -89,7 +89,7 @@ describe('ToolsCollection', (): void => { .from( collection.blockTools.values() ) - .every((tool: BlockTool) => tool.isBlock()) + .every((tool: BlockToolAdapter) => tool.isBlock()) ).to.be.true; }); }); @@ -105,7 +105,7 @@ describe('ToolsCollection', (): void => { .from( collection.inlineTools.values() ) - .every((tool: InlineTool) => tool.isInline()) + .every((tool: InlineToolAdapter) => tool.isInline()) ).to.be.true; }); }); @@ -121,7 +121,7 @@ describe('ToolsCollection', (): void => { .from( collection.blockTunes.values() ) - .every((tool: BlockTune) => tool.isTune()) + .every((tool: BlockTuneAdapter) => tool.isTune()) ).to.be.true; }); }); @@ -137,7 +137,7 @@ describe('ToolsCollection', (): void => { .from( collection.internalTools.values() ) - .every((tool: BaseTool) => tool.isInternal) + .every((tool: BaseToolAdapter) => tool.isInternal) ).to.be.true; }); }); @@ -153,7 +153,7 @@ describe('ToolsCollection', (): void => { .from( collection.externalTools.values() ) - .every((tool: BaseTool) => !tool.isInternal) + .every((tool: BaseToolAdapter) => !tool.isInternal) ).to.be.true; }); }); @@ -166,7 +166,7 @@ describe('ToolsCollection', (): void => { .from( collection.blockTunes.internalTools.values() ) - .every((tool: BlockTune) => tool.isTune() && tool.isInternal) + .every((tool: BlockTuneAdapter) => tool.isTune() && tool.isInternal) ).to.be.true; }); }); @@ -178,7 +178,7 @@ describe('ToolsCollection', (): void => { .from( collection.externalTools.blockTools.values() ) - .every((tool: BlockTool) => tool.isBlock() && !tool.isInternal) + .every((tool: BlockToolAdapter) => tool.isBlock() && !tool.isInternal) ).to.be.true; }); }); diff --git a/test/cypress/tests/tools/ToolsFactory.cy.ts b/test/cypress/tests/tools/ToolsFactory.cy.ts index a645c7366..6bb5aed98 100644 --- a/test/cypress/tests/tools/ToolsFactory.cy.ts +++ b/test/cypress/tests/tools/ToolsFactory.cy.ts @@ -2,9 +2,9 @@ import LinkInlineTool from '../../../../src/components/inline-tools/inline-tool-link'; import MoveUpTune from '../../../../src/components/block-tunes/block-tune-move-up'; import ToolsFactory from '../../../../src/components/tools/factory'; -import InlineTool from '../../../../src/components/tools/inline'; -import BlockTool from '../../../../src/components/tools/block'; -import BlockTune from '../../../../src/components/tools/tune'; +import InlineToolAdapter from '../../../../src/components/tools/inline'; +import BlockToolAdapter from '../../../../src/components/tools/block'; +import BlockTuneAdapter from '../../../../src/components/tools/tune'; import Paragraph from '@editorjs/paragraph'; describe('ToolsFactory', (): void => { @@ -49,19 +49,19 @@ describe('ToolsFactory', (): void => { it('should return InlineTool object for inline tool', (): void => { const tool = factory.get('link'); - expect(tool instanceof InlineTool).to.be.true; + expect(tool instanceof InlineToolAdapter).to.be.true; }); it('should return BlockTool object for block tool', (): void => { const tool = factory.get('paragraph'); - expect(tool instanceof BlockTool).to.be.true; + expect(tool instanceof BlockToolAdapter).to.be.true; }); it('should return BlockTune object for tune', (): void => { const tool = factory.get('moveUp'); - expect(tool instanceof BlockTune).to.be.true; + expect(tool instanceof BlockTuneAdapter).to.be.true; }); }); }); diff --git a/test/cypress/tests/ui/BlockTunes.cy.ts b/test/cypress/tests/ui/BlockTunes.cy.ts index fddbf7332..b5f39c076 100644 --- a/test/cypress/tests/ui/BlockTunes.cy.ts +++ b/test/cypress/tests/ui/BlockTunes.cy.ts @@ -1,7 +1,7 @@ import { selectionChangeDebounceTimeout } from '../../../../src/components/constants'; import Header from '@editorjs/header'; -import { ToolboxConfig } from '../../../../types'; -import { MenuConfig } from '../../../../types/tools'; +import type { ToolboxConfig } from '../../../../types'; +import type { MenuConfig } from '../../../../types/tools'; describe('BlockTunes', function () { diff --git a/test/cypress/tests/ui/InlineToolbar.cy.ts b/test/cypress/tests/ui/InlineToolbar.cy.ts index 1b243cfb4..eca588172 100644 --- a/test/cypress/tests/ui/InlineToolbar.cy.ts +++ b/test/cypress/tests/ui/InlineToolbar.cy.ts @@ -1,6 +1,6 @@ import Header from '@editorjs/header'; -import { MenuConfig } from '../../../../types/tools'; +import type { MenuConfig } from '../../../../types/tools'; describe('Inline Toolbar', () => { describe('Separators', () => { diff --git a/test/cypress/tests/ui/toolbox.cy.ts b/test/cypress/tests/ui/toolbox.cy.ts index ca4da3a9e..d6d1ade63 100644 --- a/test/cypress/tests/ui/toolbox.cy.ts +++ b/test/cypress/tests/ui/toolbox.cy.ts @@ -1,5 +1,5 @@ import type EditorJS from '../../../../types/index'; -import { ConversionConfig, ToolboxConfig } from '../../../../types/index'; +import type { ConversionConfig, ToolboxConfig } from '../../../../types/index'; import ToolMock from '../../fixtures/tools/ToolMock'; describe('Toolbox', function () { diff --git a/test/cypress/tests/utils/flipper.cy.ts b/test/cypress/tests/utils/flipper.cy.ts index 1a91d81c3..09be68153 100644 --- a/test/cypress/tests/utils/flipper.cy.ts +++ b/test/cypress/tests/utils/flipper.cy.ts @@ -1,4 +1,4 @@ -import { PopoverItemParams } from '../../../../types/index.js'; +import type { PopoverItemParams } from '../../../../types/index.js'; /** * Mock of some Block Tool diff --git a/test/cypress/tests/utils/popover.cy.ts b/test/cypress/tests/utils/popover.cy.ts index 748747ca1..28ae3001f 100644 --- a/test/cypress/tests/utils/popover.cy.ts +++ b/test/cypress/tests/utils/popover.cy.ts @@ -1,6 +1,6 @@ import { PopoverDesktop as Popover, PopoverItemType } from '../../../../src/components/utils/popover'; -import { PopoverItemParams } from '../../../../types'; -import { MenuConfig } from '../../../../types/tools'; +import type { PopoverItemParams } from '@/types/utils/popover'; +import type { MenuConfig } from '../../../../types/tools'; import Header from '@editorjs/header'; /* eslint-disable @typescript-eslint/no-empty-function */ diff --git a/test/cypress/tsconfig.json b/test/cypress/tsconfig.json index 4d48eb0fb..7ccaeff75 100644 --- a/test/cypress/tsconfig.json +++ b/test/cypress/tsconfig.json @@ -1,13 +1,9 @@ { - "compilerOptions": { - "target": "es2017", - "lib": ["dom", "es2017", "es2018", "es2019"], - "moduleResolution": "node", - "resolveJsonModule": true, - "allowSyntheticDefaultImports": true, - "experimentalDecorators": true - }, - "include": [ - "../../**/*.ts" - ] - } + "extends": "../../tsconfig.json", + "compilerOptions": { + "types": ["cypress", "node"], + }, + "include": [ + "**/*.ts" + ] +} diff --git a/tsconfig.build.json b/tsconfig.build.json index 27722617f..3eaa0be45 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -1,4 +1,4 @@ { "extends": "./tsconfig.json", - "exclude": ["test"] + "exclude": [ "test" ], } diff --git a/tsconfig.json b/tsconfig.json index c4e387d99..6025fa123 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,5 +1,6 @@ { "compilerOptions" : { + "strict": true, "sourceMap": true, "target": "es2017", "declaration": false, @@ -11,6 +12,15 @@ // allows to omit export default in .json files "allowSyntheticDefaultImports": true, - "experimentalDecorators": true - } + "experimentalDecorators": true, + "baseUrl": "./", + "paths": { + "@/types": [ "./types/" ], + "@/types/*": [ "./types/*" ], + }, + + // @todo move to cypress/tsconfig.json when cypress will support overriding tsconfig path + // @see https://github.com/cypress-io/cypress/issues/23045 + "esModuleInterop": true + }, } diff --git a/types/api/blocks.d.ts b/types/api/blocks.d.ts index 7b9a0455e..f5957bbac 100644 --- a/types/api/blocks.d.ts +++ b/types/api/blocks.d.ts @@ -1,4 +1,3 @@ -import Block from '../../src/components/block'; import {OutputBlockData, OutputData} from '../data-formats/output-data'; import {BlockToolData, ToolConfig} from '../tools'; import {BlockAPI} from './block'; diff --git a/types/api/tools.d.ts b/types/api/tools.d.ts index c578451f7..3363c4a81 100644 --- a/types/api/tools.d.ts +++ b/types/api/tools.d.ts @@ -1,4 +1,4 @@ -import BlockTool from "../../src/components/tools/block"; +import { BlockToolAdapter } from '@/types/tools/adapters/block-tool-adapter'; /** * Describes methods for accessing installed Editor tools @@ -7,5 +7,5 @@ export interface Tools { /** * Returns all available Block Tools */ - getBlockTools(): BlockTool[]; + getBlockTools(): BlockToolAdapter[]; } diff --git a/types/block-tunes/block-tune.d.ts b/types/block-tunes/block-tune.d.ts index 9ef43d306..2220a4780 100644 --- a/types/block-tunes/block-tune.d.ts +++ b/types/block-tunes/block-tune.d.ts @@ -8,7 +8,8 @@ import { MenuConfig } from '../tools'; export interface BlockTune { /** * Returns BlockTune's UI. - * Either HTMLELement (@deprecated) or MenuConfig (@see https://editorjs.io/menu-config/) + * Should return either MenuConfig (recommended) (@see https://editorjs.io/menu-config/) + * or HTMLElement (UI consitency is not guaranteed) */ render(): HTMLElement | MenuConfig; @@ -18,8 +19,6 @@ export interface BlockTune { * You can wrap Tool's content with any wrapper you want to provide Tune's UI * * @param {HTMLElement} pluginsContent — Tool's content wrapper - * - * @return {HTMLElement} */ wrap?(pluginsContent: HTMLElement): HTMLElement; diff --git a/types/configs/index.d.ts b/types/configs/index.d.ts index 4468fca9a..20723a1db 100644 --- a/types/configs/index.d.ts +++ b/types/configs/index.d.ts @@ -5,4 +5,3 @@ export * from './conversion-config'; export * from './log-levels'; export * from './i18n-config'; export * from './i18n-dictionary'; -export * from '../../src/components/utils/popover'; diff --git a/types/index.d.ts b/types/index.d.ts index 1a7e83bf0..66f0eb38c 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -80,10 +80,7 @@ export { I18nConfig, } from './configs'; -export { - PopoverItemParams, - PopoverItemDefaultParams, -} from '../src/components/utils/popover'; +export * from './utils/popover'; export { OutputData, OutputBlockData} from './data-formats/output-data'; export { BlockId } from './data-formats/block-id'; diff --git a/types/tools/adapters/base-tool-adapter.d.ts b/types/tools/adapters/base-tool-adapter.d.ts new file mode 100644 index 000000000..761d71336 --- /dev/null +++ b/types/tools/adapters/base-tool-adapter.d.ts @@ -0,0 +1,76 @@ +import { Tool, ToolSettings } from '..'; +import type { SanitizerConfig } from '../..'; + +import { ToolType } from './tool-type'; +import { InlineToolAdapter } from './inline-tool-adapter'; +import { BlockToolAdapter } from './block-tool-adapter'; +import { BlockTuneAdapter } from './block-tune-adapter'; + +export interface BaseToolAdapter { + /** + * Tool type: Block, Inline or Tune + */ + type: Type; + + /** + * Tool name specified in EditorJS config + */ + name: string; + + /** + * Flag show is current Tool internal (bundled with EditorJS core) or not + */ + readonly isInternal: boolean; + + /** + * Flag show is current Tool default or not + */ + readonly isDefault: boolean; + + /** + * Returns Tool user configuration + */ + settings: ToolSettings; + + /** + * Calls Tool's reset method + */ + reset(): void | Promise; + + /** + * Calls Tool's prepare method + */ + prepare(): void | Promise; + + /** + * Returns shortcut for Tool (internal or specified by user) + */ + shortcut: string | undefined; + + /** + * Returns Tool's sanitizer configuration + */ + sanitizeConfig: SanitizerConfig; + + /** + * Returns true if Tools is inline + */ + isInline(): this is InlineToolAdapter; + + /** + * Returns true if Tools is block + */ + isBlock(): this is BlockToolAdapter; + + /** + * Returns true if Tools is tune + */ + isTune(): this is BlockTuneAdapter; + + /** + * Constructs new Tool instance from constructable blueprint + * + * @param args + */ + create(...args: any[]): ToolClass; +} diff --git a/types/tools/adapters/block-tool-adapter.d.ts b/types/tools/adapters/block-tool-adapter.d.ts new file mode 100644 index 000000000..3b37a2433 --- /dev/null +++ b/types/tools/adapters/block-tool-adapter.d.ts @@ -0,0 +1,78 @@ +import { ToolsCollection } from './tools-collection'; +import { ToolType } from './tool-type'; +import { InlineToolAdapter } from './inline-tool-adapter'; +import { BlockTuneAdapter } from './block-tune-adapter'; +import { BlockTool } from '../block-tool'; +import { BlockToolData } from '../block-tool-data'; +import { BlockAPI } from '../../api/block'; +import { ToolboxConfigEntry } from '../tool-settings'; +import { ConversionConfig } from '../../configs/conversion-config'; +import { PasteConfig } from '../../configs/paste-config'; +import { SanitizerConfig } from '../../configs/sanitizer-config'; +import { BaseToolAdapter } from './base-tool-adapter'; + +interface BlockToolAdapter extends BaseToolAdapter{ + /** + * InlineTool collection for current Block Tool + */ + inlineTools: ToolsCollection; + + /** + * BlockTune collection for current Block Tool + */ + tunes: ToolsCollection; + + /** + * Creates new Tool instance + * @param data - Tool data + * @param block - BlockAPI for current Block + * @param readOnly - True if Editor is in read-only mode + */ + create(data: BlockToolData, block: BlockAPI, readOnly: boolean): BlockTool; + + /** + * Returns true if read-only mode is supported by Tool + */ + isReadOnlySupported: boolean; + + /** + * Returns true if Tool supports linebreaks + */ + isLineBreaksEnabled: boolean; + + /** + * Returns Tool toolbox configuration (internal or user-specified) + */ + toolbox: ToolboxConfigEntry[] | undefined; + + /** + * Returns Tool conversion configuration + */ + conversionConfig: ConversionConfig | undefined; + + /** + * Returns enabled inline tools for Tool + */ + enabledInlineTools: boolean | string[]; + + /** + * Returns enabled tunes for Tool + */ + enabledBlockTunes: boolean | string[]; + + /** + * Returns Tool paste configuration + */ + pasteConfig: PasteConfig; + + /** + * Returns sanitize configuration for Block Tool including configs from related Inline Tools and Block Tunes + */ + sanitizeConfig: SanitizerConfig; + + /** + * Returns sanitizer configuration composed from sanitize config of Inline Tools enabled for Tool + */ + baseSanitizeConfig: SanitizerConfig; +} + diff --git a/types/tools/adapters/block-tune-adapter.d.ts b/types/tools/adapters/block-tune-adapter.d.ts new file mode 100644 index 000000000..880f8264e --- /dev/null +++ b/types/tools/adapters/block-tune-adapter.d.ts @@ -0,0 +1,14 @@ +import { BlockAPI, BlockTune } from '../..'; +import { BlockTuneData } from '../../block-tunes/block-tune-data'; +import { BaseToolAdapter } from './base-tool-adapter'; +import { ToolType } from './tool-type'; + +interface BlockTuneAdapter extends BaseToolAdapter { + /** + * Constructs new BlockTune instance from constructable + * + * @param data - Tune data + * @param block - Block API object + */ + create(data: BlockTuneData, block: BlockAPI): BlockTune; +} diff --git a/types/tools/adapters/inline-tool-adapter.d.ts b/types/tools/adapters/inline-tool-adapter.d.ts new file mode 100644 index 000000000..6c55f1c76 --- /dev/null +++ b/types/tools/adapters/inline-tool-adapter.d.ts @@ -0,0 +1,15 @@ +import { InlineTool as IInlineTool, InlineTool } from '../..'; +import { BaseToolAdapter } from './base-tool-adapter'; +import { ToolType } from './tool-type'; + +interface InlineToolAdapter extends BaseToolAdapter { + /** + * Returns title for Inline Tool if specified by user + */ + title: string; + + /** + * Constructs new InlineTool instance from constructable + */ + create(): IInlineTool; +} diff --git a/types/tools/adapters/tool-factory.d.ts b/types/tools/adapters/tool-factory.d.ts new file mode 100644 index 000000000..9e7082794 --- /dev/null +++ b/types/tools/adapters/tool-factory.d.ts @@ -0,0 +1,5 @@ +import { BlockToolAdapter } from './block-tool-adapter'; +import { BlockTuneAdapter } from './block-tune-adapter'; +import { InlineToolAdapter } from './inline-tool-adapter'; + +export type ToolFactory = BlockToolAdapter | InlineToolAdapter | BlockTuneAdapter; diff --git a/types/tools/adapters/tool-type.ts b/types/tools/adapters/tool-type.ts new file mode 100644 index 000000000..adef96d34 --- /dev/null +++ b/types/tools/adapters/tool-type.ts @@ -0,0 +1,18 @@ +/** + * What kind of plugins developers can create + */ +export enum ToolType { + /** + * Block tool + */ + Block, + /** + * Inline tool + */ + Inline, + + /** + * Block tune + */ + Tune, +} diff --git a/types/tools/adapters/tools-collection.d.ts b/types/tools/adapters/tools-collection.d.ts new file mode 100644 index 000000000..273c93866 --- /dev/null +++ b/types/tools/adapters/tools-collection.d.ts @@ -0,0 +1,34 @@ +import { BlockToolAdapter } from './block-tool-adapter'; +import { BlockTuneAdapter } from './block-tune-adapter'; +import { InlineToolAdapter } from './inline-tool-adapter'; +import { ToolFactory } from './tool-factory'; + +/** + * Interface for a collection of tools. + */ +export interface ToolsCollection { + /** + * Returns Block Tools collection + */ + blockTools: ToolsCollection; + + /** + * Returns Inline Tools collection + */ + inlineTools: ToolsCollection; + + /** + * Returns Block Tunes collection + */ + blockTunes: ToolsCollection; + + /** + * Returns internal Tools collection + */ + internalTools: ToolsCollection; + + /** + * Returns Tools collection provided by user + */ + externalTools: ToolsCollection; +} diff --git a/src/components/utils/popover/components/hint/hint.types.ts b/types/utils/popover/hint.d.ts similarity index 100% rename from src/components/utils/popover/components/hint/hint.types.ts rename to types/utils/popover/hint.d.ts diff --git a/types/utils/popover/index.d.ts b/types/utils/popover/index.d.ts new file mode 100644 index 000000000..9f3172f51 --- /dev/null +++ b/types/utils/popover/index.d.ts @@ -0,0 +1,5 @@ +export type * from './hint'; +export type * from './popover-item'; +export * from './popover-item-type'; +export type * from './popover'; +export * from './popover-event'; diff --git a/types/utils/popover/popover-event.ts b/types/utils/popover/popover-event.ts new file mode 100644 index 000000000..2e74dbc03 --- /dev/null +++ b/types/utils/popover/popover-event.ts @@ -0,0 +1,15 @@ + +/** + * Event that can be triggered by the Popover + */ +export enum PopoverEvent { + /** + * When popover closes + */ + Closed = 'closed', + + /** + * When it closes because item with 'closeOnActivate' property set was clicked + */ + ClosedOnActivate = 'closed-on-activate', +} diff --git a/types/utils/popover/popover-item-type.ts b/types/utils/popover/popover-item-type.ts new file mode 100644 index 000000000..e26d3722d --- /dev/null +++ b/types/utils/popover/popover-item-type.ts @@ -0,0 +1,13 @@ +/** + * Popover item types + */ +export enum PopoverItemType { + /** Regular item with icon, title and other properties */ + Default = 'default', + + /** Gray line used to separate items from each other */ + Separator = 'separator', + + /** Item with custom html content */ + Html = 'html' +} diff --git a/src/components/utils/popover/components/popover-item/popover-item.types.ts b/types/utils/popover/popover-item.d.ts similarity index 93% rename from src/components/utils/popover/components/popover-item/popover-item.types.ts rename to types/utils/popover/popover-item.d.ts index 9f7a64011..3957fdae0 100644 --- a/src/components/utils/popover/components/popover-item/popover-item.types.ts +++ b/types/utils/popover/popover-item.d.ts @@ -1,18 +1,7 @@ -import { HintTextAlignment, HintParams, HintPosition } from '../hint'; +import { HintParams, HintPosition, HintTextAlignment } from "./hint"; +import { PopoverItemType } from "./popover-item-type"; -/** - * Popover item types - */ -export enum PopoverItemType { - /** Regular item with icon, title and other properties */ - Default = 'default', - - /** Gray line used to separate items from each other */ - Separator = 'separator', - - /** Item with custom html content */ - Html = 'html' -} +export { PopoverItemType } from './popover-item-type'; /** * Represents popover item children configuration diff --git a/src/components/utils/popover/popover.types.ts b/types/utils/popover/popover.d.ts similarity index 85% rename from src/components/utils/popover/popover.types.ts rename to types/utils/popover/popover.d.ts index 0af9a11f1..a42641f94 100644 --- a/src/components/utils/popover/popover.types.ts +++ b/types/utils/popover/popover.d.ts @@ -1,4 +1,5 @@ -import { PopoverItemParams } from '../../../../types'; +import { PopoverItemParams } from './popover-item'; +import { PopoverEvent } from './popover-event'; /** * Params required to render popover @@ -54,20 +55,6 @@ export interface PopoverMessages { search?: string } -/** - * Event that can be triggered by the Popover - */ -export enum PopoverEvent { - /** - * When popover closes - */ - Closed = 'closed', - - /** - * When it closes because item with 'closeOnActivate' property set was clicked - */ - ClosedOnActivate = 'closed-on-activate', -} /** * Events fired by the Popover diff --git a/vite.config.js b/vite.config.js index 63c4ca9d1..98bc3c261 100644 --- a/vite.config.js +++ b/vite.config.js @@ -57,6 +57,12 @@ export default { 'VERSION': JSON.stringify(VERSION), }, + resolve: { + alias: { + '@/types': path.resolve(__dirname, './types'), + }, + }, + server: { port: 3303, open: true, diff --git a/vite.config.test.js b/vite.config.test.js new file mode 100644 index 000000000..a053f5805 --- /dev/null +++ b/vite.config.test.js @@ -0,0 +1,18 @@ +import path from 'path'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + build: { + minify: false, + sourcemap: true, + }, + define: { + 'NODE_ENV': JSON.stringify('test'), + 'VERSION': JSON.stringify('test-version'), + }, + resolve: { + alias: { + '@/types': path.resolve(__dirname, './types'), + }, + }, +}); diff --git a/yarn.lock b/yarn.lock index df7d4b53a..f616c23e2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -207,6 +207,11 @@ "@babel/helper-validator-identifier" "^7.19.1" to-fast-properties "^2.0.0" +"@codexteam/icons@0.3.2": + version "0.3.2" + resolved "https://registry.yarnpkg.com/@codexteam/icons/-/icons-0.3.2.tgz#b7aed0ba7b344e07953101f5476cded570d4f150" + integrity sha512-P1ep2fHoy0tv4wx85eic+uee5plDnZQ1Qa6gDfv7eHPkCXorMtVqJhzMb75o1izogh6G7380PqmFDXV3bW3Pig== + "@codexteam/icons@^0.0.4": version "0.0.4" resolved "https://registry.yarnpkg.com/@codexteam/icons/-/icons-0.0.4.tgz#8b72dcd3f3a1b0d880bdceb2abebd74b46d3ae13" @@ -222,11 +227,6 @@ resolved "https://registry.yarnpkg.com/@codexteam/icons/-/icons-0.0.6.tgz#5553ada48dddf5940851ccc142cfe17835c36ad3" integrity sha512-L7Q5PET8PjKcBT5wp7VR+FCjwCi5PUp7rd/XjsgQ0CI5FJz0DphyHGRILMuDUdCW2MQT9NHbVr4QP31vwAkS/A== -"@codexteam/icons@^0.3.0": - version "0.3.0" - resolved "https://registry.yarnpkg.com/@codexteam/icons/-/icons-0.3.0.tgz#62380b4053d487a257de443864b5c72dafab95e6" - integrity sha512-fJE9dfFdgq8xU+sbsxjH0Kt8Yeatw9xHBJWb77DhRkEXz3OCoIS6hrRC1ewHEryxzIjxD8IyQrRq2f+Gz3BcmA== - "@codexteam/shortcuts@^1.1.1": version "1.2.0" resolved "https://registry.yarnpkg.com/@codexteam/shortcuts/-/shortcuts-1.2.0.tgz#b8dd7396962b0bd845a5c8f8f19bc6119b520e19" @@ -564,12 +564,18 @@ dependencies: "@codexteam/icons" "^0.0.5" -"@editorjs/header@^2.7.0": - version "2.7.0" - resolved "https://registry.yarnpkg.com/@editorjs/header/-/header-2.7.0.tgz#755d104a9210a8e2d9ccf22b175b2a93bdbb2330" - integrity sha512-4fGKGe2ZYblVqR/P/iw5ieG00uXInFgNMftBMqJRYcB2hUPD30kuu7Sn6eJDcLXoKUMOeqi8Z2AlUxYAmvw7zQ== +"@editorjs/editorjs@^2.29.1": + version "2.30.2" + resolved "https://registry.yarnpkg.com/@editorjs/editorjs/-/editorjs-2.30.2.tgz#b045af18a9ebe0c02cb32be41b2a98e23ee08e59" + integrity sha512-JjtUDs2/aHTEjNZzEf/2cugpIli1+aNeU8mloOd5USbVxv2vC02HTMpv7Vc1UyB7dIuc45JaYSJwgnBZp9duhA== + +"@editorjs/header@^2.8.7": + version "2.8.7" + resolved "https://registry.yarnpkg.com/@editorjs/header/-/header-2.8.7.tgz#6aa34e01638d18fbbc6d3bd75f1844869eca9193" + integrity sha512-rfxzYFR/Jhaocj3Xxx8XjEjyzfPbBIVkcPZ9Uy3rEz1n3ewhV0V4zwuxCjVfFhLUVgQQExq43BxJnTNlLOzqDQ== dependencies: "@codexteam/icons" "^0.0.5" + "@editorjs/editorjs" "^2.29.1" "@editorjs/paragraph@^2.11.6": version "2.11.6" From c8632b295f7890495cf96274a84f32992593db0f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 9 Aug 2024 18:48:46 +0300 Subject: [PATCH 55/61] Bump version up to 2.30.4-rc.0 (#2809) * Bump version * Update package.json --------- Co-authored-by: github-actions Co-authored-by: Peter --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index b93c73292..0d15de592 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@editorjs/editorjs", - "version": "2.30.3", + "version": "2.30.4", "description": "Editor.js — open source block-style WYSIWYG editor with JSON output", "main": "dist/editorjs.umd.js", "module": "dist/editorjs.mjs", From 339e772b92aaa8413dc933288f5dbd1fe3feb554 Mon Sep 17 00:00:00 2001 From: Tatiana Fomina Date: Tue, 13 Aug 2024 21:41:08 +0300 Subject: [PATCH 56/61] fix(types): FIx incorrect imports (#2811) * Fix types * Update changelog --- docs/CHANGELOG.md | 4 ++++ package.json | 2 +- types/api/tools.d.ts | 2 +- types/tools/menu-config.d.ts | 2 +- 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 6c6bf200b..bce98d8e9 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +### 2.30.5 + +– `Fix` – Fix exported types + ### 2.30.4 - `Fix` - Tool's exporting types added diff --git a/package.json b/package.json index 0d15de592..15405f6e5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@editorjs/editorjs", - "version": "2.30.4", + "version": "2.30.5", "description": "Editor.js — open source block-style WYSIWYG editor with JSON output", "main": "dist/editorjs.umd.js", "module": "dist/editorjs.mjs", diff --git a/types/api/tools.d.ts b/types/api/tools.d.ts index 3363c4a81..66c77dea9 100644 --- a/types/api/tools.d.ts +++ b/types/api/tools.d.ts @@ -1,4 +1,4 @@ -import { BlockToolAdapter } from '@/types/tools/adapters/block-tool-adapter'; +import { BlockToolAdapter } from '../tools/adapters/block-tool-adapter'; /** * Describes methods for accessing installed Editor tools diff --git a/types/tools/menu-config.d.ts b/types/tools/menu-config.d.ts index 8f616ef25..a33d751dd 100644 --- a/types/tools/menu-config.d.ts +++ b/types/tools/menu-config.d.ts @@ -1,4 +1,4 @@ -import { PopoverItemDefaultBaseParams, PopoverItemHtmlParams, PopoverItemParams, PopoverItemSeparatorParams, WithChildren } from "../configs"; +import { PopoverItemDefaultBaseParams, PopoverItemHtmlParams, PopoverItemSeparatorParams, WithChildren } from '../utils/popover'; /** * Menu configuration format. From 8f365f006328a36faacf60352e20a0fc365c227b Mon Sep 17 00:00:00 2001 From: Nikita Melnikov Date: Tue, 20 Aug 2024 22:31:21 +0100 Subject: [PATCH 57/61] chore: updgrade cypress to solve the issue with firefox 129 (#2817) --- package.json | 2 +- yarn.lock | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/package.json b/package.json index 15405f6e5..1647190f2 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ "codex-notifier": "^1.1.2", "codex-tooltip": "^1.0.5", "core-js": "3.30.0", - "cypress": "^13.7.1", + "cypress": "^13.13.3", "cypress-intellij-reporter": "^0.0.7", "cypress-plugin-tab": "^1.0.5", "cypress-terminal-report": "^5.3.2", diff --git a/yarn.lock b/yarn.lock index f616c23e2..29cccc68c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -504,7 +504,7 @@ js-yaml "4.1.0" nyc "15.1.0" -"@cypress/request@^3.0.0": +"@cypress/request@^3.0.1": version "3.0.1" resolved "https://registry.yarnpkg.com/@cypress/request/-/request-3.0.1.tgz#72d7d5425236a2413bd3d8bb66d02d9dc3168960" integrity sha512-TWivJlJi8ZDx2wGOw1dbLuHJKUYX7bWySw377nlnGOW3hP9/MUKIsEdXT/YngWxVdgNCHRBmFlBipE+5/2ZZlQ== @@ -1718,12 +1718,12 @@ cypress-vite@^1.5.0: chokidar "^3.5.3" debug "^4.3.4" -cypress@^13.7.1: - version "13.7.1" - resolved "https://registry.yarnpkg.com/cypress/-/cypress-13.7.1.tgz#d1208eb04efd46ef52a30480a5da71a03373261a" - integrity sha512-4u/rpFNxOFCoFX/Z5h+uwlkBO4mWzAjveURi3vqdSu56HPvVdyGTxGw4XKGWt399Y1JwIn9E1L9uMXQpc0o55w== +cypress@^13.13.3: + version "13.13.3" + resolved "https://registry.yarnpkg.com/cypress/-/cypress-13.13.3.tgz#21ee054bb4e00b3858f2e33b4f8f4e69128470a9" + integrity sha512-hUxPrdbJXhUOTzuML+y9Av7CKoYznbD83pt8g3klgpioEha0emfx4WNIuVRx0C76r0xV2MIwAW9WYiXfVJYFQw== dependencies: - "@cypress/request" "^3.0.0" + "@cypress/request" "^3.0.1" "@cypress/xvfb" "^1.2.4" "@types/sinonjs__fake-timers" "8.1.1" "@types/sizzle" "^2.3.2" @@ -1762,7 +1762,7 @@ cypress@^13.7.1: request-progress "^3.0.0" semver "^7.5.3" supports-color "^8.1.1" - tmp "~0.2.1" + tmp "~0.2.3" untildify "^4.0.0" yauzl "^2.10.0" @@ -4999,7 +4999,7 @@ through@^2.3.8: resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5" integrity sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg== -tmp@~0.2.1: +tmp@~0.2.3: version "0.2.3" resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.2.3.tgz#eb783cc22bc1e8bebd0671476d46ea4eb32a79ae" integrity sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w== From c82933616c1f4f7afbb45ca839916c1decba13dd Mon Sep 17 00:00:00 2001 From: VolgaIgor <43250768+VolgaIgor@users.noreply.github.com> Date: Sat, 14 Sep 2024 00:39:19 +0300 Subject: [PATCH 58/61] Fixed display of conversion menu for blocks without export rule (#2799) * Fixed display of convert menu for blocks without export rule According to the workflow script from the documentation: https://editorjs.io/tools-api/#conversionconfig * Update CHANGELOG.md * some improvements and tests --------- Co-authored-by: Peter Savchenko --- docs/CHANGELOG.md | 4 ++ src/components/utils/blocks.ts | 22 ++++++++-- .../tools/ToolWithoutConversionExport.ts | 23 +++++++++++ test/cypress/tests/ui/BlockTunes.cy.ts | 40 +++++++++++++++++-- test/cypress/tests/utils/flipper.cy.ts | 4 +- types/tools/block-tool.d.ts | 6 +-- types/tools/tool.d.ts | 23 +++++++---- 7 files changed, 102 insertions(+), 20 deletions(-) create mode 100644 test/cypress/fixtures/tools/ToolWithoutConversionExport.ts diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index bce98d8e9..eacada6dd 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +### 2.30.6 + +– `Fix` – Fix the display of ‘Convert To’ near blocks that do not have the ‘conversionConfig.export’ rule specified + ### 2.30.5 – `Fix` – Fix exported types diff --git a/src/components/utils/blocks.ts b/src/components/utils/blocks.ts index 471bb8647..fb564223d 100644 --- a/src/components/utils/blocks.ts +++ b/src/components/utils/blocks.ts @@ -51,6 +51,15 @@ export async function getConvertibleToolsForBlock(block: BlockAPI, allBlockTools const savedData = await block.save() as SavedData; const blockData = savedData.data; + /** + * Checking that the block's tool has an «export» rule + */ + const blockTool = allBlockTools.find((tool) => tool.name === block.name); + + if (blockTool !== undefined && !isToolConvertable(blockTool, 'export')) { + return []; + } + return allBlockTools.reduce((result, tool) => { /** * Skip tools without «import» rule specified @@ -59,12 +68,19 @@ export async function getConvertibleToolsForBlock(block: BlockAPI, allBlockTools return result; } + /** + * Skip tools that does not specify toolbox + */ + if (tool.toolbox === undefined) { + return result; + } + /** Filter out invalid toolbox entries */ const actualToolboxItems = tool.toolbox.filter((toolboxItem) => { /** * Skip items that don't pass 'toolbox' property or do not have an icon */ - if (isEmpty(toolboxItem) || !toolboxItem.icon) { + if (isEmpty(toolboxItem) || toolboxItem.icon === undefined) { return false; } @@ -86,10 +102,10 @@ export async function getConvertibleToolsForBlock(block: BlockAPI, allBlockTools result.push({ ...tool, toolbox: actualToolboxItems, - }); + } as BlockToolAdapter); return result; - }, []); + }, [] as BlockToolAdapter[]); } diff --git a/test/cypress/fixtures/tools/ToolWithoutConversionExport.ts b/test/cypress/fixtures/tools/ToolWithoutConversionExport.ts new file mode 100644 index 000000000..77f43f988 --- /dev/null +++ b/test/cypress/fixtures/tools/ToolWithoutConversionExport.ts @@ -0,0 +1,23 @@ +import type { ConversionConfig } from '@/types/configs/conversion-config'; +import ToolMock from './ToolMock'; + +/** + * This tool has a conversionConfig, but it doesn't have export property. + * + * That means that tool can be created from string, but can't be converted to string. + */ +export class ToolWithoutConversionExport extends ToolMock { + /** + * Rules specified how our Tool can be converted to/from other Tool. + */ + public static get conversionConfig(): ConversionConfig { + return { + import: 'text', // this tool can be created from string + + /** + * Here is no "export" property, so this tool can't be converted to string + */ + // export: (data) => data.text, + }; + } +} diff --git a/test/cypress/tests/ui/BlockTunes.cy.ts b/test/cypress/tests/ui/BlockTunes.cy.ts index b5f39c076..43d7e0e5f 100644 --- a/test/cypress/tests/ui/BlockTunes.cy.ts +++ b/test/cypress/tests/ui/BlockTunes.cy.ts @@ -1,8 +1,8 @@ import { selectionChangeDebounceTimeout } from '../../../../src/components/constants'; import Header from '@editorjs/header'; -import type { ToolboxConfig } from '../../../../types'; +import type { ConversionConfig, ToolboxConfig } from '../../../../types'; import type { MenuConfig } from '../../../../types/tools'; - +import { ToolWithoutConversionExport } from '../../fixtures/tools/ToolWithoutConversionExport'; describe('BlockTunes', function () { describe('Search', () => { @@ -185,6 +185,39 @@ describe('BlockTunes', function () { .should('not.exist'); }); + it('should not display the ConvertTo control if block has no conversionConfig.export specified', () => { + cy.createEditor({ + tools: { + testTool: ToolWithoutConversionExport, + }, + data: { + blocks: [ + { + type: 'testTool', + data: { + text: 'Some text', + }, + }, + ], + }, + }).as('editorInstance'); + + cy.get('@editorInstance') + .get('[data-cy=editorjs]') + .find('.ce-block') + .click(); + + cy.get('@editorInstance') + .get('[data-cy=editorjs]') + .find('.ce-toolbar__settings-btn') + .click(); + + cy.get('@editorInstance') + .get('[data-cy=editorjs]') + .find('.ce-popover-item[data-item-name=convert-to]') + .should('not.exist'); + }); + it('should not display tool with the same data in "Convert to" menu', () => { /** * Tool with several toolbox entries configured @@ -193,9 +226,10 @@ describe('BlockTunes', function () { /** * Tool is convertable */ - public static get conversionConfig(): { import: string } { + public static get conversionConfig(): ConversionConfig { return { import: 'text', + export: 'text', }; } diff --git a/test/cypress/tests/utils/flipper.cy.ts b/test/cypress/tests/utils/flipper.cy.ts index 09be68153..114a38e1e 100644 --- a/test/cypress/tests/utils/flipper.cy.ts +++ b/test/cypress/tests/utils/flipper.cy.ts @@ -87,9 +87,9 @@ describe('Flipper', () => { .trigger('keydown', { keyCode: ARROW_DOWN_KEY_CODE }); /** - * Check whether we focus the Move Up Tune or not + * Check whether we focus the Delete Tune or not */ - cy.get('[data-item-name="move-up"]') + cy.get('[data-item-name="delete"]') .should('have.class', 'ce-popover-item--focused'); cy.get('[data-cy=editorjs]') diff --git a/types/tools/block-tool.d.ts b/types/tools/block-tool.d.ts index ae02161b5..ddf478968 100644 --- a/types/tools/block-tool.d.ts +++ b/types/tools/block-tool.d.ts @@ -1,6 +1,6 @@ import { ConversionConfig, PasteConfig, SanitizerConfig } from '../configs'; import { BlockToolData } from './block-tool-data'; -import { BaseTool, BaseToolConstructable } from './tool'; +import { BaseTool, BaseToolConstructable, BaseToolConstructorOptions } from './tool'; import { ToolConfig } from './tool-config'; import { API, BlockAPI, ToolboxConfig } from '../index'; import { PasteEvent } from './paste-events'; @@ -83,10 +83,8 @@ export interface BlockTool extends BaseTool { /** * Describe constructor parameters */ -export interface BlockToolConstructorOptions { - api: API; +export interface BlockToolConstructorOptions extends BaseToolConstructorOptions { data: BlockToolData; - config: ToolConfig; block: BlockAPI; readOnly: boolean; } diff --git a/types/tools/tool.d.ts b/types/tools/tool.d.ts index 184000eba..17aa0f2d9 100644 --- a/types/tools/tool.d.ts +++ b/types/tools/tool.d.ts @@ -9,15 +9,27 @@ import {MenuConfig} from './menu-config'; export interface BaseTool { /** * Tool`s render method - * - * For Inline Tools may return either HTMLElement (deprecated) or {@link MenuConfig} + * + * For Inline Tools may return either HTMLElement (deprecated) or {@link MenuConfig} * @see https://editorjs.io/menu-config - * + * * For Block Tools returns tool`s wrapper html element */ render(): RenderReturnType | Promise; } +export interface BaseToolConstructorOptions { + /** + * Editor.js API + */ + api: API; + + /** + * Tool configuration + */ + config?: ToolConfig; +} + export interface BaseToolConstructable { /** * Define Tool type as Inline @@ -35,11 +47,6 @@ export interface BaseToolConstructable { */ title?: string; - /** - * Describe constructor parameters - */ - new (config: {api: API, config?: ToolConfig}): BaseTool; - /** * Tool`s prepare method. Can be async * @param data From 2f9696a000a79cd0cf7df695be039969d5e6a6b2 Mon Sep 17 00:00:00 2001 From: Angus MacIsaac Date: Fri, 13 Sep 2024 19:00:52 -0300 Subject: [PATCH 59/61] Swallow getLayoutMap() error (#2790) * Fix getLayoutMap() bug * Update CHANGELOG.md --------- Co-authored-by: Angus Co-authored-by: Peter --- docs/CHANGELOG.md | 1 + src/components/utils/keyboard.ts | 13 ++++++++++--- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index eacada6dd..5c50865e3 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -3,6 +3,7 @@ ### 2.30.6 – `Fix` – Fix the display of ‘Convert To’ near blocks that do not have the ‘conversionConfig.export’ rule specified +– `Fix` – The Plus button does not appear when the editor is loaded in an iframe in Chrome ### 2.30.5 diff --git a/src/components/utils/keyboard.ts b/src/components/utils/keyboard.ts index 62586d576..65d8e614e 100644 --- a/src/components/utils/keyboard.ts +++ b/src/components/utils/keyboard.ts @@ -47,8 +47,15 @@ export async function getKeyboardKeyForCode(code: string, fallback: string): Pro return fallback; } - const map = await keyboard.getLayoutMap(); - const key = map.get(code); + try { + const map = await keyboard.getLayoutMap(); - return key || fallback; + const key = map.get(code); + + return key || fallback; + } catch (e) { + console.error(e); + + return fallback; + } } From b6ba44d61039ddca08275673a81695fe637b66b3 Mon Sep 17 00:00:00 2001 From: Ilya Gorenburg Date: Fri, 13 Sep 2024 18:12:46 -0400 Subject: [PATCH 60/61] fix: prevent inline toolbar from closing in nested instance of editor (#2780) * fix: prevent inline toolbar from closing in nested instance of editor * docs: updated changelog.md with fix description * fix: fix import to use `type` --------- Co-authored-by: Peter --- docs/CHANGELOG.md | 1 + src/components/modules/ui.ts | 6 ++- .../support/utils/nestedEditorInstance.ts | 31 +++++++++++ .../cypress/tests/modules/InlineToolbar.cy.ts | 53 +++++++++++++++++++ 4 files changed, 89 insertions(+), 2 deletions(-) create mode 100644 test/cypress/support/utils/nestedEditorInstance.ts diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 5c50865e3..eecd87f9b 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -4,6 +4,7 @@ – `Fix` – Fix the display of ‘Convert To’ near blocks that do not have the ‘conversionConfig.export’ rule specified – `Fix` – The Plus button does not appear when the editor is loaded in an iframe in Chrome +- `Fix` - Prevent inline toolbar from closing in nested instance of editor ### 2.30.5 diff --git a/src/components/modules/ui.ts b/src/components/modules/ui.ts index f9c3cbef6..6bbcdb8ce 100644 --- a/src/components/modules/ui.ts +++ b/src/components/modules/ui.ts @@ -847,9 +847,11 @@ export default class UI extends Module { /** * Event can be fired on clicks at non-block-content elements, - * for example, at the Inline Toolbar or some Block Tune element + * for example, at the Inline Toolbar or some Block Tune element. + * We also make sure that the closest block belongs to the current editor and not a parent */ - const clickedOutsideBlockContent = focusedElement.closest(`.${Block.CSS.content}`) === null; + const closestBlock = focusedElement.closest(`.${Block.CSS.content}`); + const clickedOutsideBlockContent = closestBlock === null || (closestBlock.closest(`.${Selection.CSS.editorWrapper}`) !== this.nodes.wrapper); if (clickedOutsideBlockContent) { /** diff --git a/test/cypress/support/utils/nestedEditorInstance.ts b/test/cypress/support/utils/nestedEditorInstance.ts new file mode 100644 index 000000000..f335cbcee --- /dev/null +++ b/test/cypress/support/utils/nestedEditorInstance.ts @@ -0,0 +1,31 @@ +import type { BlockTool, BlockToolConstructorOptions } from '../../../../types'; +import { createEditorWithTextBlocks } from './createEditorWithTextBlocks'; + +export const NESTED_EDITOR_ID = 'nested-editor'; + +/** + * Creates nested Editor instance with paragraph block + */ +export default class NestedEditor implements BlockTool { + private data: { text: string }; + + constructor(value: BlockToolConstructorOptions) { + this.data = value.data; + } + + public render(): HTMLDivElement { + const editorEl = Object.assign(document.createElement('div'), { + id: NESTED_EDITOR_ID, + }); + + editorEl.setAttribute('data-cy', NESTED_EDITOR_ID); + + createEditorWithTextBlocks([ this.data.text ], { holder: NESTED_EDITOR_ID }); + + return editorEl; + } + + public save(): string { + return this.data.text; + } +} diff --git a/test/cypress/tests/modules/InlineToolbar.cy.ts b/test/cypress/tests/modules/InlineToolbar.cy.ts index bc8990bda..ebdb71d67 100644 --- a/test/cypress/tests/modules/InlineToolbar.cy.ts +++ b/test/cypress/tests/modules/InlineToolbar.cy.ts @@ -1,4 +1,5 @@ import Header from '@editorjs/header'; +import NestedEditor, { NESTED_EDITOR_ID } from '../../support/utils/nestedEditorInstance'; describe('Inline Toolbar', () => { it('should appear aligned with left coord of selection rect', () => { @@ -164,4 +165,56 @@ describe('Inline Toolbar', () => { }); }); }); + + describe('Nested Editor instance inline toolbar', () => { + it('should not close inline toolbar of the nested Editor instance when clicking within that toolbar', () => { + cy.createEditor({ + tools: { + nestedEditor: { + class: NestedEditor, + }, + }, + data: { + blocks: [ + { + type: 'paragraph', + data: { + text: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, + }, + { + type: 'nestedEditor', + data: { + text: 'Nunc pellentesque, tortor nec luctus venenatis', + }, + }, + ], + }, + }); + + cy.get(`[data-cy=${NESTED_EDITOR_ID}]`) + .find('.ce-paragraph') + .selectText('tortor nec luctus'); + + cy.get(`[data-cy=${NESTED_EDITOR_ID}]`) + .find('[data-item-name=link]') + .click(); + + // `wait()` function below is required. without it the test will always pass + // because cypress types the text in the field without delay, while we need some delay (just like user) + // to test the actual case that nested editor inline toolbar is still visible and not closed + + cy.get(`[data-cy=${NESTED_EDITOR_ID}]`) + .find('.ce-inline-tool-input') + .click() + .wait(100) + .type('https://editorjs.io'); + + cy.get(`[data-cy=${NESTED_EDITOR_ID}]`) + .find('.ce-popover__container') + .then(($toolbar) => { + expect($toolbar).to.be.visible; + }); + }); + }); }); From 3aa164d2d082890ca2eaff799b1d951a08fc4ba7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 14 Sep 2024 01:18:43 +0300 Subject: [PATCH 61/61] Bump version up to 2.30.6-rc.0 (#2818) * Bump version * 2.30.6 --------- Co-authored-by: github-actions Co-authored-by: Peter --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 1647190f2..6d0917c88 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@editorjs/editorjs", - "version": "2.30.5", + "version": "2.30.6", "description": "Editor.js — open source block-style WYSIWYG editor with JSON output", "main": "dist/editorjs.umd.js", "module": "dist/editorjs.mjs",