Compare commits

...

28 commits

Author SHA1 Message Date
Peter Savchenko 29d68ecb47
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
2024-05-23 20:06:33 +03:00
Tatiana Fomina d18eeb5dc8
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
2024-05-16 15:26:25 +03:00
Tatiana Fomina 50f43bb35d
Change cypress preprocessor (#2712) 2024-05-04 21:23:36 +03:00
Tatiana Fomina f78972ee09
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
2024-05-04 15:35:36 +00:00
github-actions[bot] bd1de56ef3
Bump version (#2705)
Co-authored-by: github-actions <action@github.com>
2024-05-01 21:00:22 +03:00
Peter Savchenko 8276daa5ca
fix changelog (#2704) 2024-05-01 20:59:33 +03:00
github-actions[bot] 238c909016
Bump version (#2701)
Co-authored-by: github-actions <action@github.com>
2024-04-29 22:28:45 +03:00
Peter Savchenko 23858e0025
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
2024-04-29 22:24:31 +03:00
github-actions[bot] 5eafda5ec4
Bump version (#2698)
Co-authored-by: github-actions <action@github.com>
2024-04-27 21:22:12 +03:00
Peter Savchenko efa0a34f8e
fix caret loosing after caret (#2697) 2024-04-27 21:19:12 +03:00
Peter Savchenko c48fca1be3
fix ios shift (#2696) 2024-04-27 21:09:16 +03:00
Peter Savchenko 1028577521
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
2024-04-27 21:04:26 +03:00
github-actions[bot] 844272656e
Bump version (#2694)
Co-authored-by: github-actions <action@github.com>
2024-04-27 16:59:52 +03:00
Tatiana Fomina 7821e35302
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
2024-04-27 16:57:52 +03:00
github-actions[bot] 4118dc3aea
Bump version (#2693)
Co-authored-by: github-actions <action@github.com>
2024-04-23 22:52:31 +03:00
Tatiana Fomina e1c70b4fb8
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
2024-04-22 22:38:20 +03:00
github-actions[bot] 54c4c234a5
Bump version (#2659)
Co-authored-by: github-actions <action@github.com>
2024-04-13 23:07:20 +03:00
Tatiana Fomina 5125f015dc
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
2024-04-13 17:34:26 +00:00
Peter Savchenko ecdd73347c
fix(dx): dev example page fixed (#2682)
* fix dev example

* embed goes to master
2024-04-11 17:00:48 +03:00
Peter Savchenko 1320b047a2
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 <guillaume@onepilot.co>
Co-authored-by: Guillaume Leon <97881811+GuillaumeOnepilot@users.noreply.github.com>
2024-04-01 12:29:47 +03:00
Alex Yang b355f1673c
fix: strict css type (#2573)
Co-authored-by: Peter Savchenko <specc.dev@gmail.com>
Co-authored-by: Ilya Maroz <37909603+ilyamore88@users.noreply.github.com>
2024-03-13 17:57:52 +03:00
dependabot[bot] bb2047c60f
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] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Peter Savchenko <specc.dev@gmail.com>
2024-03-13 17:53:00 +03:00
dependabot[bot] cc0d6de04b
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] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Peter Savchenko <specc.dev@gmail.com>
2024-03-13 17:47:18 +03:00
dependabot[bot] 9b3e9615b0
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] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Peter Savchenko <specc.dev@gmail.com>
2024-03-13 17:36:26 +03:00
Peter Savchenko ee6433201d
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
2024-03-13 17:30:16 +03:00
github-actions[bot] e9b4c30407
Bump version up to 2.30.0-rc.0 (#2640)
* Bump version

* Update package.json

---------

Co-authored-by: github-actions <action@github.com>
Co-authored-by: Peter Savchenko <specc.dev@gmail.com>
2024-02-28 20:17:42 +03:00
VikhorKonstantin 8138ce95b2
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 <specc.dev@gmail.com>
2024-02-28 20:08:08 +03:00
Yaroslav Gusev 7ff5faa46f
Change workflows trigger (#2595) 2024-02-28 15:33:50 +03:00
100 changed files with 4762 additions and 1364 deletions

View file

@ -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]

View file

@ -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]

View file

@ -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 }}

1
.gitignore vendored
View file

@ -17,3 +17,4 @@ dist/
coverage/
.nyc_output/
.vscode/launch.json

View file

@ -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.

View file

@ -1,5 +1,25 @@
# Changelog
### 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
- `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
### 2.29.1
- `Fix` — Toolbox wont be shown when Slash pressed with along with Shift or Alt

View file

@ -89,22 +89,22 @@
Read more in Tool's README file. For example:
https://github.com/editor-js/header#installation
-->
<script src="./tools/header/dist/bundle.js" onload="document.getElementById('hint-tools').hidden = true"></script><!-- Header -->
<script src="./tools/simple-image/dist/bundle.js"></script><!-- Image -->
<script src="./tools/delimiter/dist/bundle.js"></script><!-- Delimiter -->
<!-- <script src="./tools/list/dist/bundle.js"></script> List-->
<script src="./tools/nested-list/dist/nested-list.js"></script><!-- Nested List -->
<script src="./tools/checklist/dist/bundle.js"></script><!-- Checklist -->
<script src="./tools/quote/dist/bundle.js"></script><!-- Quote -->
<script src="./tools/code/dist/bundle.js"></script><!-- Code -->
<script src="./tools/embed/dist/bundle.js"></script><!-- Embed -->
<script src="./tools/table/dist/table.js"></script><!-- Table -->
<script src="./tools/link/dist/bundle.js"></script><!-- Link -->
<script src="./tools/raw/dist/bundle.js"></script><!-- Raw -->
<script src="./tools/warning/dist/bundle.js"></script><!-- Warning -->
<script src="./tools/header/dist/header.umd.js" onload="document.getElementById('hint-tools').hidden = true"></script><!-- Header -->
<script src="./tools/simple-image/dist/simple-image.umd.js"></script><!-- Image -->
<script src="./tools/delimiter/dist/delimiter.umd.js"></script><!-- Delimiter -->
<!-- <script src="./tools/list/dist/list.umd.js"></script> List-->
<script src="./tools/nested-list/dist/nested-list.umd.js"></script><!-- Nested List -->
<script src="./tools/checklist/dist/checklist.umd.js"></script><!-- Checklist -->
<script src="./tools/quote/dist/quote.umd.js"></script><!-- Quote -->
<script src="./tools/code/dist/code.umd.js"></script><!-- Code -->
<script src="./tools/embed/dist/embed.umd.js"></script><!-- Embed -->
<script src="./tools/table/dist/table.umd.js"></script><!-- Table -->
<script src="./tools/link/dist/link.umd.js"></script><!-- Link -->
<script src="./tools/raw/dist/raw.umd.js"></script><!-- Raw -->
<script src="./tools/warning/dist/warning.umd.js"></script><!-- Warning -->
<script src="./tools/marker/dist/bundle.js"></script><!-- Marker -->
<script src="./tools/inline-code/dist/bundle.js"></script><!-- Inline Code -->
<script src="./tools/marker/dist/marker.umd.js"></script><!-- Marker -->
<script src="./tools/inline-code/dist/inline-code.umd.js"></script><!-- Inline Code -->
<!-- Load Editor.js's Core -->
<script src="../dist/editorjs.umd.js" onload="document.getElementById('hint-core').hidden = true;"></script>

@ -1 +1 @@
Subproject commit b1367277e070bbbf80b7b14b1963845ba9a71d8c
Subproject commit 1c116d5e09e19951948d6166047aa2f30877aaf9

@ -1 +1 @@
Subproject commit 193f5f6f00288679a97bfe620a4d811e5acd9b16
Subproject commit f281996f82c7ac676172757e45687cae27443427

@ -1 +1 @@
Subproject commit 86e8c5501dcbb8eaaeec756e1145db49b8339160
Subproject commit 4ca1c1c972261f47dd34f6b8754763a4a79a4866

@ -1 +1 @@
Subproject commit 23de06be69bb9e636a2278b0d54f8c2d85d7ae13
Subproject commit dfdbf2423d2777f7026a7df768c6582e1a409db7

@ -1 +1 @@
Subproject commit 80278ee75146ff461e9dcaeff1a337167ef97162
Subproject commit 5118ce87a752515fb6b31325f234f4ccd62f42c9

@ -1 +1 @@
Subproject commit 927ec04edae75fb2e9a83add24be38d439dc3a19
Subproject commit 25d46cd8d3930851b14ddc26ee80fb5b485e1496

@ -1 +1 @@
Subproject commit 7cc94718e4c20d6f9db2c236a60b119c39d389e0
Subproject commit dcd4c17740c9ba636140751596aff1e9f6ef6b01

@ -1 +1 @@
Subproject commit 861de29b1d553bb9377dcbaf451af605b28b57bd
Subproject commit aaa69d5408bad34027d6252a3892d40f9fa121be

@ -1 +1 @@
Subproject commit f0e9f0110983cd973a1345f2885b18db4fd54636
Subproject commit a6dc6a692b88c9eff3d87223b239e7517b160c67

@ -1 +1 @@
Subproject commit 13e0b1cf72cfa706dc236e617683a5e349a021f5
Subproject commit 8d6897fca43e387bcdf4a681380be975fe8f2a07

@ -1 +1 @@
Subproject commit c5c47395516cae0e456881a67a84fd69fec06c47
Subproject commit 95b37462dc93c19b83f0481f509034a40d436cf2

@ -1 +1 @@
Subproject commit 02e0db32a101ec5cfa61210de45be7de647c40c6
Subproject commit 9377ca713f552576b8b11f77cf371b67261ec00b

@ -1 +1 @@
Subproject commit b4164eac4d81259a15368d7681884e3554554662
Subproject commit cae470fded570ef9a82a45734526ccf45959e204

@ -1 +1 @@
Subproject commit 2d411a650afa04f0468f7648ee0b5a765362161c
Subproject commit 963883520c7bbe5040366335c9a37bbdc7cf60fd

@ -1 +1 @@
Subproject commit 605a73d2b7bec6438c7c0d5ab09eae86b5e9212e
Subproject commit 2948cd7595e632f7555e2dc09e6bac050a2b87ea

@ -1 +1 @@
Subproject commit 7e706b1cb67655db75d3a154038e4f11e2d00128
Subproject commit e63e91aa833d774be9bf4a76013b1025a009989d

View file

@ -1,6 +1,6 @@
{
"name": "@editorjs/editorjs",
"version": "2.29.1",
"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",
@ -45,17 +45,18 @@
"@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",
"cypress-vite": "^1.5.0",
"eslint": "^8.37.0",
"eslint-config-codex": "^1.7.1",
"eslint-plugin-chai-friendly": "^0.7.2",

View file

@ -6,7 +6,7 @@ import {
SanitizerConfig,
ToolConfig,
ToolboxConfigEntry,
PopoverItem
PopoverItemParams
} from '../../../types';
import { SavedData } from '../../../types/data-formats';
@ -25,7 +25,8 @@ import { 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';
import { PopoverItemType } from '../utils/popover';
/**
* Interface describes Block class constructor argument
@ -229,7 +230,6 @@ export default class Block extends EventsDispatcher<BlockEvents> {
tunesData,
}: BlockConstructorOptions, eventBus?: EventsDispatcher<EditorEventMap>) {
super();
this.name = tool.name;
this.id = id;
this.settings = tool.settings;
@ -550,7 +550,7 @@ export default class Block extends EventsDispatcher<BlockEvents> {
*
* @returns {object}
*/
public async save(): Promise<void | SavedData> {
public async save(): Promise<undefined | SavedData> {
const extractedBlock = await this.toolInstance.save(this.pluginsContent as HTMLElement);
const tunesData: { [name: string]: BlockTuneData } = this.unavailableTunesData;
@ -611,33 +611,54 @@ export default class Block extends EventsDispatcher<BlockEvents> {
}
/**
* Returns data to render in tunes menu.
* Splits block tunes settings into 2 groups: popover items and custom html.
* Returns data to render in Block Tunes menu.
* Splits block tunes into 2 groups: block specific tunes and common tunes
*/
public getTunes(): [PopoverItem[], HTMLElement] {
const customHtmlTunesContainer = document.createElement('div');
const tunesItems: TunesMenuConfigItem[] = [];
public getTunes(): {
toolTunes: PopoverItemParams[];
commonTunes: PopoverItemParams[];
} {
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() : [];
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 */
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);
/** Separate custom html from Popover items params for common tunes */
commonTunes.forEach(tuneConfig => {
if ($.isElement(tuneConfig)) {
commonTunesPopoverParams.push({
type: PopoverItemType.Html,
element: tuneConfig,
});
} else if (Array.isArray(tuneConfig)) {
commonTunesPopoverParams.push(...tuneConfig);
} else {
tunesItems.push(rendered);
commonTunesPopoverParams.push(tuneConfig);
}
});
return [tunesItems, customHtmlTunesContainer];
return {
toolTunes: toolTunesPopoverParams,
commonTunes: commonTunesPopoverParams,
};
}
/**
@ -711,11 +732,8 @@ export default class Block extends EventsDispatcher<BlockEvents> {
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);
});
}
@ -738,6 +756,10 @@ export default class Block extends EventsDispatcher<BlockEvents> {
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.

View file

@ -0,0 +1,5 @@
/**
* Debounce timeout for selection change event
* {@link modules/ui.ts}
*/
export const selectionChangeDebounceTimeout = 180;

View file

@ -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);
}

View file

@ -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;
}

View file

@ -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
}

View file

@ -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);
}

View file

@ -18,7 +18,8 @@
},
"popover": {
"Filter": "",
"Nothing found": ""
"Nothing found": "",
"Convert to": ""
}
},
"toolNames": {

View file

@ -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<BlockAPIInterface> => {
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,

View file

@ -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;
};

View file

@ -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;
@ -249,6 +251,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();
}
@ -279,8 +288,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;
}
@ -384,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
@ -488,17 +501,14 @@ 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)
.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();
});
}

View file

@ -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];
}
@ -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,
@ -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<void> {
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);
}
@ -793,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<void> {
public async convert(blockToConvert: Block, targetToolName: string, blockDataOverrides?: BlockToolData): Promise<Block> {
/**
* At first, we get current Block data
*/
@ -838,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);
}
/**

View file

@ -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;
}

View file

@ -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;
}
/**

View file

@ -7,7 +7,13 @@ 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, PopoverItemParams, PopoverItemDefaultParams, 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';
/**
* HTML Elements that used for BlockSettings
@ -27,8 +33,6 @@ interface BlockSettingsNodes {
export default class BlockSettings extends Module<BlockSettingsNodes> {
/**
* Module Events
*
* @returns {{opened: string, closed: string}}
*/
public get events(): { opened: string; closed: string } {
return {
@ -56,8 +60,12 @@ export default class BlockSettings extends Module<BlockSettingsNodes> {
*
* @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 +75,9 @@ export default class BlockSettings extends Module<BlockSettingsNodes> {
/**
* 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 +90,8 @@ export default class BlockSettings extends Module<BlockSettingsNodes> {
if (import.meta.env.MODE === 'test') {
this.nodes.wrapper.setAttribute('data-cy', 'block-tunes');
}
this.eventsDispatcher.on(EditorMobileLayoutToggled, this.close);
}
/**
@ -89,6 +99,8 @@ export default class BlockSettings extends Module<BlockSettingsNodes> {
*/
public destroy(): void {
this.removeAllNodes();
this.listeners.destroy();
this.eventsDispatcher.off(EditorMobileLayoutToggled, this.close);
}
/**
@ -96,7 +108,7 @@ export default class BlockSettings extends Module<BlockSettingsNodes> {
*
* @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<void> {
this.opened = true;
/**
@ -111,18 +123,17 @@ export default class BlockSettings extends Module<BlockSettingsNodes> {
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 } = targetBlock.getTunes();
/** 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,
customContentFlippableItems: this.getControls(customHtmlTunesContainer),
items: await this.getTunesItems(targetBlock, commonTunes, toolTunes),
scopeElement: this.Editor.API.methods.ui.nodes.redactor,
messages: {
nothingFound: I18n.ui(I18nInternalNS.ui.popover, 'Nothing found'),
@ -132,7 +143,7 @@ export default class BlockSettings extends Module<BlockSettingsNodes> {
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 +151,14 @@ export default class BlockSettings extends Module<BlockSettingsNodes> {
/**
* 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,6 +194,115 @@ export default class BlockSettings extends Module<BlockSettingsNodes> {
this.popover.getElement().remove();
this.popover = null;
}
};
/**
* 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<PopoverItemParams[]> {
const items = [] as TunesMenuConfigItem[];
if (toolTunes !== undefined && toolTunes.length > 0) {
items.push(...toolTunes);
items.push({
type: PopoverItemType.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: PopoverItemType.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<PopoverItemDefaultParams[]> {
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;
}
/**
@ -192,27 +312,15 @@ export default class BlockSettings extends Module<BlockSettingsNodes> {
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<HTMLElement>(
`.${StylesAPI.classes.settingsButton}, ${$.allInputsSelector}`
);
return Array.from(controls);
}
/**
* Resolves aliases in tunes menu items
*
* @param item - item with resolved aliases
*/
private resolveTuneAliases(item: TunesMenuConfigItem): TunesMenuConfigItem {
private resolveTuneAliases(item: TunesMenuConfigItem): PopoverItemParams {
if (item.type === PopoverItemType.Separator || item.type === PopoverItemType.Html) {
return item;
}
const result = resolveAliases(item, { label: 'title' });
if (item.confirmation) {

View file

@ -183,16 +183,14 @@ export default class ConversionToolbar extends Module<ConversionToolbarNodes> {
public async replaceWithBlock(replacingToolName: string, blockDataOverrides?: BlockToolData): Promise<void> {
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);
}
/**

View file

@ -220,6 +220,7 @@ export default class Toolbar extends Module<ToolbarNodes> {
};
}
/**
* Toggles read-only mode
*
@ -479,9 +480,10 @@ export default class Toolbar extends Module<ToolbarNodes> {
}
});
return this.toolboxInstance.make();
return this.toolboxInstance.getElement();
}
/**
* Handler for Plus Button
*/

View file

@ -427,6 +427,10 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
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) => {
/**

View file

@ -15,6 +15,8 @@ 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
*/
@ -120,7 +122,7 @@ export default class UI extends Module<UINodes> {
/**
* Detect mobile version
*/
this.checkIsMobile();
this.setIsMobile();
/**
* Make main UI elements
@ -233,10 +235,21 @@ export default class UI extends Module<UINodes> {
}
/**
* 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;
}
/**
@ -350,7 +363,6 @@ export default class UI extends Module<UINodes> {
/**
* Handle selection change to manipulate Inline Toolbar appearance
*/
const selectionChangeDebounceTimeout = 180;
const selectionChangeDebounced = _.debounce(() => {
this.selectionChanged();
}, selectionChangeDebounceTimeout);
@ -426,7 +438,7 @@ export default class UI extends Module<UINodes> {
/**
* Detect mobile version
*/
this.checkIsMobile();
this.setIsMobile();
}
/**
@ -556,6 +568,11 @@ export default class UI extends Module<UINodes> {
*/
private enterPressed(event: KeyboardEvent): void {
const { BlockManager, BlockSelection } = this.Editor;
if (this.someToolbarOpened) {
return;
}
const hasPointerToBlock = BlockManager.currentBlockIndex >= 0;
/**
@ -591,6 +608,10 @@ export default class UI extends Module<UINodes> {
*/
const newBlock = this.Editor.BlockManager.insert();
/**
* Prevent default enter behaviour to prevent adding a new line (<div><br></div>) to the inserted block
*/
event.preventDefault();
this.Editor.Caret.setToBlock(newBlock);
/**

View file

@ -3,11 +3,15 @@ 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 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<ToolboxEventMap> {
*/
public opened = false;
/**
* Listeners util instance
*/
protected listeners: Listeners = new Listeners();
/**
* Editor API
*/
@ -82,8 +91,9 @@ export default class Toolbox extends EventsDispatcher<ToolboxEventMap> {
/**
* 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,17 +109,15 @@ export default class Toolbox extends EventsDispatcher<ToolboxEventMap> {
* Current module HTML Elements
*/
private nodes: {
toolbox: HTMLElement | null;
} = {
toolbox: null,
};
toolbox: HTMLElement;
} ;
/**
* CSS styles
*
* @returns {Object<string, string>}
*/
private static get CSS(): { [name: string]: string } {
private static get CSS(): {
toolbox: string;
} {
return {
toolbox: 'ce-toolbox',
};
@ -128,36 +136,26 @@ export default class Toolbox extends EventsDispatcher<ToolboxEventMap> {
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<ToolboxEventMap> {
* 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<ToolboxEventMap> {
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<ToolboxEventMap> {
}
}
/**
* 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
*/
@ -256,11 +303,11 @@ export default class Toolbox extends EventsDispatcher<ToolboxEventMap> {
* 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)),
@ -273,7 +320,7 @@ export default class Toolbox extends EventsDispatcher<ToolboxEventMap> {
};
return this.toolsToBeDisplayed
.reduce<PopoverItem[]>((result, tool) => {
.reduce<PopoverItemParams[]>((result, tool) => {
if (Array.isArray(tool.toolbox)) {
tool.toolbox.forEach(item => {
result.push(toPopoverItem(item, tool));
@ -309,7 +356,7 @@ export default class Toolbox extends EventsDispatcher<ToolboxEventMap> {
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();
@ -321,11 +368,9 @@ export default class Toolbox extends EventsDispatcher<ToolboxEventMap> {
*/
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) {}

View file

@ -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);
}

View file

@ -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 | null, modifier?: string) => {
const className = [blockName, elementName]
.filter(x => !!x)
.join(ELEMENT_DELIMITER);
return [className, modifier]
.filter(x => !!x)
.join(MODIFIER_DELIMITER);
};
}

View file

@ -1,7 +1,36 @@
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.
*
* @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);
}
/**
* 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.
@ -9,12 +38,32 @@ import { isFunction, isString, log } from '../utils';
* 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');
}
/**

View file

@ -3,7 +3,7 @@ import { isEmpty } from '../utils';
/**
* Event Dispatcher event listener
*/
type Listener<Data> = (data?: Data) => void;
type Listener<Data> = (data: Data) => void;
/**
* Mapped type with subscriptions list

View file

@ -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;
}
const elementRemovedItself = Array.from(removedNodes).some(node => node === element);
if (elementRemovedItself) {
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));
return addedNodesBelongsToBlock || removedNodesBelongsToBlock;
return false;
}

View file

@ -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'),
};

View file

@ -0,0 +1,10 @@
.ce-hint {
&--align-left {
text-align: left;
}
&__description {
opacity: 0.6;
margin-top: 3px;
}
}

View file

@ -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;
}
}

View file

@ -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';

View file

@ -0,0 +1,2 @@
export * from './hint';
export * from './hint.types';

View file

@ -0,0 +1,2 @@
export * from './popover-header';
export * from './popover-header.types';

View file

@ -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'),
};

View file

@ -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();
}
}

View file

@ -0,0 +1,14 @@
/**
* Popover header params
*/
export interface PopoverHeaderParams {
/**
* Text to be displayed inside header
*/
text: string;
/**
* Back button click handler
*/
onBackButtonClick: () => void;
}

View file

@ -0,0 +1,12 @@
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
};

View file

@ -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')(),
};

View file

@ -1,16 +1,28 @@
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 {
PopoverItemDefaultParams as PopoverItemDefaultParams,
PopoverItemParams as PopoverItemParams,
PopoverItemRenderParamsMap,
PopoverItemType
} 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 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 PopoverItem {
export class PopoverItemDefault extends 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 +57,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);
}
/**
@ -59,63 +75,29 @@ export class PopoverItem {
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;
/**
* 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',
};
}
private confirmationState: PopoverItemDefaultParams | null = null;
/**
* 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: PopoverItemParams) {
this.params = params;
this.nodes.root = this.make(params);
constructor(private readonly params: PopoverItemDefaultParams, renderParams?: PopoverItemRenderParamsMap[PopoverItemType.Default]) {
super();
this.nodes.root = this.make(params, renderParams);
}
/**
* Returns popover item root element
*/
public getElement(): HTMLElement {
public getElement(): HTMLElement | null {
return this.nodes.root;
}
@ -123,7 +105,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 +120,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);
}
/**
@ -146,8 +128,8 @@ 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);
public override toggleHidden(isHidden: boolean): void {
this.nodes.root?.classList.toggle(css.hidden, isHidden);
}
/**
@ -166,40 +148,61 @@ 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
* @param renderParams - popover item render params
*/
private make(params: PopoverItemParams): HTMLElement {
const el = Dom.make('div', PopoverItem.CSS.container);
private make(params: PopoverItemDefaultParams, renderParams?: PopoverItemRenderParamsMap[PopoverItemType.Default]): HTMLElement {
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);
}
if (params.hint !== undefined && renderParams?.hint?.enabled !== false) {
this.addHint(el, {
...params.hint,
position: renderParams?.hint?.position || 'right',
});
}
return el;
@ -210,16 +213,20 @@ export class PopoverItem {
*
* @param newState - new popover item params that should be applied
*/
private enableConfirmationMode(newState: PopoverItemParams): void {
private enableConfirmationMode(newState: PopoverItemDefaultParams): void {
if (this.nodes.root === null) {
return;
}
const params = {
...this.params,
...newState,
confirmation: newState.confirmation,
} as PopoverItemParams;
} as PopoverItemDefaultParams;
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 +237,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 +255,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 +268,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);
};
/**
@ -280,10 +290,10 @@ export class PopoverItem {
*
* @param item - item to activate or bring to confirmation mode
*/
private activateOrEnableConfirmationMode(item: PopoverItemParams): void {
private activateOrEnableConfirmationMode(item: PopoverItemDefaultParams): void {
if (item.confirmation === undefined) {
try {
item.onActivate(item);
item.onActivate?.(item);
this.disableConfirmationMode();
} catch {
this.animateError();
@ -297,20 +307,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);
};
}

View file

@ -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'),
};

View file

@ -0,0 +1,66 @@
import { PopoverItem } from '../popover-item';
import { PopoverItemHtmlParams, PopoverItemRenderParamsMap, PopoverItemType } 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
* @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, renderParams?: PopoverItemRenderParamsMap[PopoverItemType.Html]) {
super();
this.nodes = {
root: Dom.make('div', css.root),
};
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',
});
}
}
/**
* 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<HTMLElement>(
`button, ${Dom.allInputsSelector}`
);
return Array.from(controls);
}
}

View file

@ -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'),
};

View file

@ -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);
}
}

View file

@ -0,0 +1,34 @@
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
*/
public abstract getElement(): HTMLElement | null;
/**
* Toggles item hidden state
*
* @param isHidden - true if item should be hidden
*/
public abstract toggleHidden(isHidden: boolean): void;
}

View file

@ -0,0 +1,190 @@
import { HintParams, HintPosition } from '../hint';
/**
* 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.
* Special item type that is used to separate items in the popover.
*/
export interface PopoverItemSeparatorParams {
/**
* Item type
*/
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;
/**
* Hint data to be displayed on item hover
*/
hint?: HintParams;
}
/**
* Common parameters for all kinds of default popover items: with or without confirmation
*/
interface PopoverItemDefaultBaseParams {
/**
* Item type
*/
type?: PopoverItemType.Default;
/**
* Displayed text
*/
title?: string;
/**
* Item icon to be appeared near a title
*/
icon?: string;
/**
* Additional displayed text
*/
secondaryLabel?: string;
/**
* True if item should be highlighted as active
*/
isActive?: boolean;
/**
* True if item should be disabled
*/
isDisabled?: boolean;
/**
* True if popover should close once item is activated
*/
closeOnActivate?: boolean;
/**
* Item name
* Used in data attributes needed for cypress tests
*/
name?: string;
/**
* 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;
/**
* 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
*
* @param item - activated item
* @param event - event that initiated item activation
*/
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
*/
export type PopoverItemDefaultParams =
PopoverItemWithConfirmationParams |
PopoverItemWithoutConfirmationParams |
PopoverItemWithChildrenParams;
/**
* Represents single popover item
*/
export type PopoverItemParams =
PopoverItemDefaultParams |
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;
}
};
};

View file

@ -0,0 +1,2 @@
export * from './search-input';
export * from './search-input.types';

View file

@ -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'),
};

View file

@ -1,18 +1,14 @@
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 { 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 default class SearchInput {
export class SearchInput extends EventsDispatcher<SearchInputEventMap> {
/**
* Input wrapper element
*/
@ -36,44 +32,50 @@ export default class SearchInput {
/**
* Current search query
*/
private searchQuery: string;
/**
* 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',
};
}
private searchQuery: string | undefined;
/**
* @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;
placeholder?: string;
}) {
super();
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.emit(SearchInputEvent.Search, {
query: this.searchQuery,
items: this.foundItems,
});
});
}
/**
@ -96,7 +98,11 @@ export default class SearchInput {
public clear(): void {
this.input.value = '';
this.searchQuery = '';
this.onSearch('', this.foundItems);
this.emit(SearchInputEvent.Search, {
query: '',
items: this.foundItems,
});
}
/**
@ -106,38 +112,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 +126,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;
}
}

View file

@ -0,0 +1,30 @@
/**
* Item that could be searched
*/
export interface SearchableItem {
/**
* Items title
*/
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[]};
}

View file

@ -1,527 +1,12 @@
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';
export * from './components/popover-item/popover-item.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<PopoverEventMap> {
/**
* 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) {
requestAnimationFrame(() => {
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 };

View file

@ -0,0 +1,310 @@
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';
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
*/
export abstract class PopoverAbstract<Nodes extends PopoverNodes = PopoverNodes> extends EventsDispatcher<PopoverEventMap> {
/**
* List of popover items
*/
protected items: Array<PopoverItem>;
/**
* Listeners util instance
*/
protected listeners: Listeners = new Listeners();
/**
* Refs to created HTML elements
*/
protected nodes: Nodes;
/**
* List of default popover items that are searchable and may have confirmation state
*/
protected get itemsDefault(): PopoverItemDefault[] {
return this.items.filter(item => item instanceof PopoverItemDefault) as PopoverItemDefault[];
}
/**
* Instance of the Search Input
*/
protected 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
* @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,
protected readonly itemsRenderParams: PopoverItemRenderParamsMap = {}
) {
super();
this.items = this.buildItems(params.items);
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.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.itemsDefault.forEach(item => item.reset());
if (this.search !== undefined) {
this.search.clear();
}
this.emit(PopoverEvent.Close);
}
/**
* Clears memory
*/
public destroy(): void {
this.listeners.removeAll();
}
/**
* Factory method for creating popover items
*
* @param items - list of items params
*/
protected buildItems(items: PopoverItemParams[]): Array<PopoverItem> {
return items.map(item => {
switch (item.type) {
case PopoverItemType.Separator:
return new PopoverItemSeparator();
case PopoverItemType.Html:
return new PopoverItemHtml(item, this.itemsRenderParams[PopoverItemType.Html]);
default:
return new PopoverItemDefault(item, this.itemsRenderParams[PopoverItemType.Default]);
}
});
}
/**
* Retrieves popover item that is the target of the specified event
*
* @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;
}
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);
}
/**
* 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.itemsDefault.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 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: PopoverItemDefault): void {
if (clickedItem.toggle === true) {
clickedItem.toggleActive();
}
if (typeof clickedItem.toggle === 'string') {
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) {
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: PopoverItemDefault): void;
}

View file

@ -0,0 +1,358 @@
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 { 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.
* 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 {
/**
* Flipper - module for keyboard iteration between elements
*/
public flipper: Flipper;
/**
* 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.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);
this.search?.on(SearchInputEvent.Search, this.handleSearch);
}
/**
* 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.destroyNestedPopoverIfExists();
this.flipper.deactivate();
this.previouslyHoveredItem = null;
}
/**
* Clears memory
*/
public destroy(): void {
this.hide();
super.destroy();
}
/**
* Handles displaying nested items for the item.
*
* @param item item to show nested popover for
*/
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 = data.query === '' ? this.flippableElements : data.items.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[]);
}
};
/**
* 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.
*/
private get flippableElements(): 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.itemsDefault.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: PopoverItemDefault): 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);
}
}

View file

@ -0,0 +1,161 @@
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 { 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<PopoverMobileNodes> {
/**
* 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();
/**
* Flag that indicates if popover is hidden
*/
private isHidden = true;
/**
* Construct the instance
*
* @param params - popover params
*/
constructor(params: PopoverParams) {
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);
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();
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;
}
/**
* 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: PopoverItemDefault): 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 items - new popover items
* @param title - new popover header text
*/
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 = this.buildItems(items);
this.items.forEach(item => {
const itemEl = item.getElement();
if (itemEl === null) {
return;
}
this.nodes.items?.appendChild(itemEl);
});
}
}

View file

@ -0,0 +1,25 @@
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'),
items: className('items'),
overlay: className('overlay'),
overlayHidden: className('overlay', 'hidden'),
popoverNested: className(null, 'nested'),
popoverHeader: className('header'),
};

View file

@ -0,0 +1,96 @@
import { 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;
/**
* 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;
}
/**
* 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;
}

View file

@ -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();
}
}
}

View file

@ -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

View file

@ -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));
opacity: 0;
max-height: 0;
pointer-events: none;
padding: 0;
border: none;
&__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);
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,28 +109,28 @@
}
}
&--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;
}
}
&__search, &__custom-content:not(:empty) {
&__search {
margin-bottom: 5px;
}
@ -123,16 +151,30 @@
}
}
&__custom-content:not(:empty) {
padding: 4px;
@media (--not-mobile) {
padding: 0;
&--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;
}
}
&__custom-content--hidden {
display: none;
&--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%);
}
}
}
}
@ -140,15 +182,34 @@
/**
* Popover item styles
*/
.ce-popover-item {
--border-radius: 6px;
--icon-size: 20px;
--icon-size-mobile: 28px;
.ce-popover-item-separator {
padding: 4px 3px;
&--hidden {
display: none;
}
&__line {
height: 1px;
background: var(--color-border);
width: 100%;
}
}
.ce-popover-item-html {
&--hidden {
display: none;
}
}
.ce-popover-item {
--border-radius: 6px;
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 +222,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 +239,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 +261,8 @@
white-space: nowrap;
text-overflow: ellipsis;
margin-right: auto;
@media (--mobile) {
font-size: 16px;
}
@ -205,7 +271,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 +438,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;
}
}
}

View file

@ -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 || '';
}
}
}

View file

@ -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);
});

View file

@ -85,6 +85,14 @@ declare global {
* @returns number[] - array of line wrap positions
*/
getLineWrapPositions(): Chainable<number[]>;
/**
* 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<Subject>;
}
interface ApplicationWindow {

View file

@ -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"`);
});
});
});
});

View file

@ -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<EditorJS>('@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<EditorJS>('@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<EditorJS>('@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;
});
});
});
});

View file

@ -1,5 +1,7 @@
import type EditorJS from '../../../../../types/index';
import Chainable = Cypress.Chainable;
import { SimpleHeader } from '../../../fixtures/tools/SimpleHeader';
import type { ConversionConfig } from '../../../../../types/index';
/**
@ -293,11 +295,142 @@ 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 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<EditorJS>('@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
});
/**
* Mock of tool without merge method
* Caret is set to the place of merging
*/
class ExampleOfUnmergeableTool {
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<EditorJS>('@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 (target Bock is lack of merge() and conversionConfig). Also, should close the Toolbox.', function () {
/**
* Mock of tool without merge() method
*/
class UnmergeableToolWithoutConversionConfig {
/**
* Render method mock
*/
@ -320,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: [

View file

@ -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,7 +19,15 @@ describe('Slash keydown', function () {
.click()
.type('/');
cy.get('[data-cy="toolbox"] .ce-popover')
/**
* 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');
});
@ -46,7 +54,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 +80,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 +114,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');
});
});

View file

@ -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;
});
});
});
});
});

View file

@ -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,
},
}));
});
});

View file

@ -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 <span id="taint-html">XSS<span> block',
},
},
],
},
}).as('editorInstance');
cy.get('[data-cy=editorjs]')
.find('.ce-paragraph')
.last()
.click()
.type('{home}')
.type('{backspace}');
cy.get<EditorJS>('@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
});
});
});

View file

@ -0,0 +1,441 @@
import { selectionChangeDebounceTimeout } from '../../../../src/components/constants';
import Header from '@editorjs/header';
import { ToolboxConfig } from '../../../../types';
import { TunesMenuConfig } from '../../../../types/tools';
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');
});
});
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');
});
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;
});
});
});
});
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');
});
});
});

View file

@ -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;
});
});
});
});

View file

@ -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]')

View file

@ -1,5 +1,6 @@
import Popover from '../../../../src/components/utils/popover';
import { PopoverItem } from '../../../../types';
import { PopoverDesktop as Popover, PopoverItemType } from '../../../../src/components/utils/popover';
import { PopoverItemParams } from '../../../../types';
import { TunesMenuConfig } from '../../../../types/tools';
/* eslint-disable @typescript-eslint/no-empty-function */
@ -14,13 +15,13 @@ 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 = {
icon: confirmActionIcon,
title: confirmActionTitle,
onActivate: cy.stub(),
};
const items: PopoverItem[] = [
const items: PopoverItemParams[] = [
{
icon: actionIcon,
title: actionTitle,
@ -68,7 +69,7 @@ describe('Popover', () => {
});
it('should render the items with true isActive property value as active', () => {
const items: PopoverItem[] = [
const items = [
{
icon: 'Icon',
title: 'Title',
@ -92,7 +93,7 @@ describe('Popover', () => {
});
it('should not execute item\'s onActivate callback if the item is disabled', () => {
const items: PopoverItem[] = [
const items: PopoverItemParams[] = [
{
icon: 'Icon',
title: 'Title',
@ -114,6 +115,9 @@ describe('Popover', () => {
.should('have.class', 'ce-popover-item--disabled')
.click()
.then(() => {
if (items[0].type !== PopoverItemType.Default) {
return;
}
// Check onActivate callback has never been called
expect(items[0].onActivate).to.have.not.been.called;
});
@ -121,7 +125,7 @@ describe('Popover', () => {
});
it('should close once item with closeOnActivate property set to true is activated', () => {
const items: PopoverItem[] = [
const items = [
{
icon: 'Icon',
title: 'Title',
@ -148,7 +152,7 @@ describe('Popover', () => {
});
it('should highlight as active the item with toggle property set to true once activated', () => {
const items: PopoverItem[] = [
const items = [
{
icon: 'Icon',
title: 'Title',
@ -172,7 +176,7 @@ 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 = [
{
icon: 'Icon 1',
title: 'Title 1',
@ -217,7 +221,7 @@ describe('Popover', () => {
});
it('should toggle item if it is the only item in toggle group', () => {
const items: PopoverItem[] = [
const items = [
{
icon: 'Icon',
title: 'Title',
@ -240,21 +244,637 @@ 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();
/* Check custom content exists in the popover */
cy.get('[data-cy-name=customContent]');
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');
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)', () => {
/** 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');
});
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 {
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 [
{
onActivate: (): void => {},
icon: 'Icon',
title: 'Tune',
toggle: 'key',
name: 'test-item',
},
{
type: PopoverItemType.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 [
{
onActivate: (): void => {},
icon: 'Icon',
title: 'Tune 1',
name: 'test-item-1',
},
{
type: PopoverItemType.Separator,
},
{
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 */
// 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');
});
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 [
{
onActivate: (): void => {},
icon: 'Icon',
title: 'Tune 1',
name: 'test-item-1',
},
{
type: PopoverItemType.Separator,
},
{
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');
});
});

View file

@ -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<BlockAPI>;
}

10
types/api/caret.d.ts vendored
View file

@ -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

View file

@ -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';

View file

@ -1,81 +0,0 @@
/**
* Common parameters for both types of popover items: with or without confirmation
*/
interface PopoverItemBase {
/**
* Displayed text
*/
title?: string;
/**
* Item icon to be appeared near a title
*/
icon?: string;
/**
* Additional displayed text
*/
secondaryLabel?: string;
/**
* True if item should be highlighted as active
*/
isActive?: boolean;
/**
* True if item should be disabled
*/
isDisabled?: boolean;
/**
* True if popover should close once item is activated
*/
closeOnActivate?: boolean;
/**
* Item name
* Used in data attributes needed for cypress tests
*/
name?: string;
/**
* 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;
}
/**
* Represents popover item with confirmation state configuration
*/
export interface PopoverItemWithConfirmation extends PopoverItemBase {
/**
* 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;
onActivate?: never;
}
/**
* Represents default popover item without confirmation state configuration
*/
export interface PopoverItemWithoutConfirmation extends PopoverItemBase {
confirmation?: never;
/**
* Popover item activation handler
*
* @param item - activated item
* @param event - event that initiated item activation
*/
onActivate: (item: PopoverItem, event?: PointerEvent) => void;
}
/**
* Represents single popover item
*/
export type PopoverItem = PopoverItemWithConfirmation | PopoverItemWithoutConfirmation

11
types/index.d.ts vendored
View file

@ -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'

View file

@ -1,6 +1,6 @@
import { ToolConfig } from './tool-config';
import { ToolConstructable, BlockToolData } from './index';
import { PopoverItem } from '../configs';
import { PopoverItemDefaultParams, PopoverItemSeparatorParams, PopoverItemHtmlParams } 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,24 @@ 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;
/**
* 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

663
yarn.lock

File diff suppressed because it is too large Load diff