Merge branch 'next' into hotfix/issue-2249-svg-style-override-issue

This commit is contained in:
Siddharth 2024-05-03 11:22:19 +05:30 committed by GitHub
commit 83544e147c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
148 changed files with 8846 additions and 2568 deletions

View file

@ -31,6 +31,8 @@
"ClientRect": true,
"ArrayLike": true,
"InputEvent": true,
"unknown": true
"unknown": true,
"requestAnimationFrame": true,
"navigator": true
}
}

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]
@ -11,6 +18,8 @@ jobs:
check-for-no-version-changing:
if: github.event.pull_request.merged == true
runs-on: ubuntu-latest
permissions:
actions: write
steps:
# Checkout to target branch
- uses: actions/checkout@v2
@ -32,16 +41,22 @@ jobs:
uses: codex-team/action-nodejs-package-info@v1
# Stop workflow and do not bump version if it was changed already
- name: Stop workflow and do not bump version if it was changed already
uses: actions/github-script@v3
- name: Stop workflow if version was changed already
if: steps.packageOld.outputs.version != steps.packageNew.outputs.version
with:
script: |
core.setFailed('Version was changed! ${{ steps.packageOld.outputs.version }} -> ${{ steps.packageNew.outputs.version }}')
run: |
curl -L \
-X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \
-H "X-GitHub-Api-Version: 2022-11-28" \
https://api.github.com/repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/cancel
bump-version:
needs: check-for-no-version-changing
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
# Checkout to target branch
- uses: actions/checkout@v2

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]
@ -11,6 +18,8 @@ jobs:
check-version-changing:
if: github.event.pull_request.merged == true
runs-on: ubuntu-latest
permissions:
actions: write
steps:
- uses: actions/setup-node@v3
with:
@ -36,16 +45,21 @@ jobs:
# Stop workflow if version was not changed
- name: Stop workflow if version was not changed
uses: actions/github-script@v3
if: steps.packageOld.outputs.version == steps.packageNew.outputs.version
with:
script: |
core.setFailed('No version changes. ${{ steps.packageOld.outputs.version }}')
run: |
curl -L \
-X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \
-H "X-GitHub-Api-Version: 2022-11-28" \
https://api.github.com/repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/cancel
# Create a new draft release
release-draft:
needs: check-version-changing
runs-on: ubuntu-latest
permissions:
contents: write
steps:
# Checkout to target branch
- uses: actions/checkout@v2
@ -118,4 +132,4 @@ jobs:
webhook: ${{ secrets.CODEX_BOT_WEBHOOK_FRONTEND }}
message: '🦥 [Draft release v${{ steps.package.outputs.version }}](${{ steps.create_release.outputs.html_url }}) for package [${{ steps.package.outputs.name }}](${{ steps.package.outputs.npmjs-link }}) has been created. Add changelog and publish it!'
parse_mode: 'markdown'
disable_web_page_preview: true
disable_web_page_preview: true

View file

@ -12,10 +12,10 @@ 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 }}
build: yarn build
build: yarn build:test

View file

@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-latest
steps:
# Checkout to target branch
- uses: actions/checkout@v2
- uses: actions/checkout@v4
with:
# Pull submodules
submodules: 'recursive'
@ -46,9 +46,11 @@ jobs:
notify:
needs: publish
runs-on: ubuntu-latest
env:
GITHUB_LINK: ${{ github.server_url }}/${{ github.repository }}/releases/tag/${{ github.ref_name }}
steps:
# Checkout to target branch
- uses: actions/checkout@v2
- uses: actions/checkout@v4
- name: Get package info
id: package
@ -58,6 +60,6 @@ jobs:
uses: codex-team/action-codexbot-notify@v1
with:
webhook: ${{ secrets.CODEX_BOT_NOTIFY_EDITORJS_PUBLIC_CHAT }}
message: '📦 [${{ steps.package.outputs.name }}](${{ steps.package.outputs.npmjs-link }}) ${{ steps.package.outputs.version }} was published'
message: '📦 [${{ steps.package.outputs.name }} ${{ steps.package.outputs.version }}](${{ env.GITHUB_LINK }}) was published'
parse_mode: 'markdown'
disable_web_page_preview: true
disable_web_page_preview: true

1
.gitignore vendored
View file

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

View file

@ -8,6 +8,7 @@
"colspan",
"contenteditable",
"contentless",
"Convertable",
"cssnano",
"cssnext",
"Debouncer",
@ -34,6 +35,7 @@
"textareas",
"twitterwidget",
"typeof",
"Unmergeable",
"viewports"
]
}

View file

@ -6,14 +6,28 @@ export default defineConfig({
},
fixturesFolder: 'test/cypress/fixtures',
screenshotsFolder: 'test/cypress/screenshots',
video: false,
videosFolder: 'test/cypress/videos',
e2e: {
// We've imported your old cypress plugins here.
// You may want to clean this up later by importing these.
setupNodeEvents(on, config) {
return require('./test/cypress/plugins/index.ts')(on, 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.
* https://github.com/archfz/cypress-terminal-report
*/
require('cypress-terminal-report/src/installLogsPrinter')(on);
require('./test/cypress/plugins/index.ts')(on, config);
},
specPattern: 'test/cypress/tests/**/*.cy.{js,jsx,ts,tsx}',
supportFile: 'test/cypress/support/index.ts',
},
'retries': {
// Configure retry attempts for `cypress run`
'runMode': 2,
// Configure retry attempts for `cypress open`
'openMode': 0,
},
});

View file

@ -1,5 +1,88 @@
# 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.
- `Improvement` - The API `blocks.convert()` now returns the new block API
- `Improvement` - The API `caret.setToBlock()` now can accept either BlockAPI or block index or block id
### 2.29.1
- `Fix` — Toolbox wont be shown when Slash pressed with along with Shift or Alt
- `Fix` — Toolbox will be opened when Slash pressed in non-US keyboard layout where there is no physical '/' key.
### 2.29.0
- `New` — Editor Config now has the `style.nonce` attribute that could be used to allowlist editor style tag for Content Security Policy "style-src"
- `New` — Toolbox now will be opened by '/' in empty Block instead of Tab
- `New` — Block Tunes now will be opened by 'CMD+/' instead of Tab in non-empty block
- `New` — Tab now will navigate through Blocks. In last block Tab will navigate to the next input on page.
- `Fix` — Passing an empty array via initial data or `blocks.render()` won't break the editor
- `Fix` — Layout did not shrink when a large document cleared in Chrome
- `Fix` — Multiple Tooltip elements creation fixed
- `Fix` — When the focusing Block is out of the viewport, the page will be scrolled.
- `Fix` - Compiler error "This import is never used as a value and must use 'import type'..." fixed
- `Fix``blocks.render()` won't lead the `onChange` call in Safari
- `Fix` — Editor wrapper element growing on the Inline Toolbar close
- `Fix` — Fix errors thrown by clicks on a document when the editor is being initialized
- `Fix` — Caret losing on Mobile Devices when adding a block via Toolbox or via Backspace at the beginning of a Block
- `Improvement` — Now you can set focus via arrows/Tab to "contentless" (decorative) blocks like Delimiter which have no inputs.
- `Improvement` — Inline Toolbar sometimes opened in an incorrect position. Now it will be aligned by the left side of the selected text. And won't overflow the right side of the text column.
- `Improvement` - Now the `data-mutation-free` supports deep nesting, so you can mark some element with it to prevent the onChange call caused by child element mutating
- `Improvement` - Now the `data-mutation-free` also allows to skip "characterData" mutations (eg. text content change)
- `Refactoring``ce-block--focused` class toggling removed as unused.
### 2.28.2
- `Fix` — Get rid of redundant logs from the build
### 2.28.1
- `Fix` — Some Block were be skipped on saving after pasting them as HTML
### 2.28.0
- `New` - Block ids now displayed in DOM via a data-id attribute. Could be useful for plugins that want to access a Block's element by id.
- `New` - The `blocks.convert(blockId, newType)` API method was added. It allows to convert existing Block to a Block of another type.
- `New` - The `blocks.insertMany()` API method added. It allows to insert several Blocks to the specified index.
- `Improvement` - The Delete keydown at the end of the Block will now work opposite a Backspace at the start. Next Block will be removed (if empty) or merged with the current one.
- `Improvement` - The Delete keydown will work like a Backspace when several Blocks are selected.
- `Improvement` - If we have two empty Blocks, and press Backspace at the start of the second one, the previous will be removed instead of the current.
- `Improvement` - Tools shortcuts could be used to convert one Block to another.
- `Improvement` - Tools shortcuts displayed in the Conversion Toolbar
- `Improvement` - Initialization Loader has been removed.
- `Improvement` - Selection style won't override your custom style for `::selection` outside the editor.
- `Improvement` - Performance optimizations: initialization speed increased, `blocks.render()` API method optimized. Big documents will be displayed faster.
- `Improvement` - "Editor saving" log removed
- `Improvement` - "I'm ready" log removed
- `Improvement` - The stub-block style is simplified.
- `Improvement` - If some Block's tool throws an error during construction, we will show Stub block instead of skipping it during render
- `Improvement` - Call of `blocks.clear()` now will trigger onChange with "block-removed" event for all removed blocks.
- `Improvement` - The `blocks.clear()` now can be awaited.
- `Improvement` - `BlockMutationType` and `BlockMutationEvent` types exported
- `Improvement` - `blocks.update(id, data)` now can accept partial data object — it will update only passed properties, others will remain the same.
- `Improvement` - `blocks.update(id, data)` now will trigger onChange with only `block-change` event.
- `Improvement` - `blocks.update(id, data)` will return a promise with BlockAPI object of the changed block.
### 2.27.2
- `Fix` - `onChange` won't be called when element with data-mutation-free changes some attribute
### 2.27.1
- `Fix` - `onChange` will be called on removing the whole text in a block
### 2.27.0
- `New`*Toolbar API* — Added a new method for toggling the toolbox.

View file

@ -69,7 +69,7 @@ Check [Editor.js's community](https://github.com/editor-js/) to see Tools exampl
## Create Editor instance
Create an instance of Editor.js and pass [Configuration Object](../src/types-internal/editor-config.ts).
At least the `holderId` option is required.
At least the `holder` option is required.
```html
<div id="editorjs"></div>
@ -92,7 +92,7 @@ var editor = new EditorJS({
/**
* Create a holder for the Editor and pass its ID
*/
holderId : 'editorjs',
holder : 'editorjs',
/**
* Available Tools list.

View file

@ -70,7 +70,7 @@ to the `tools` property of Editor Config.
```javascript
var editor = new EditorJS({
holderId : 'editorjs',
holder : 'editorjs',
tools: {
text: {
class: Text,

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 3cc506758440ac3f1bc83008a6ef75813b6386c3
Subproject commit 2948cd7595e632f7555e2dc09e6bac050a2b87ea

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

View file

@ -98,6 +98,8 @@
<script type="module">
import EditorJS from './src/codex.ts';
window.EditorJS = EditorJS;
/**
* To initialize the Editor, create a new instance with configuration object
* @see docs/installation.md for mode details
@ -404,6 +406,8 @@
localStorage.setItem('theme', document.body.classList.contains("dark-mode") ? 'dark' : 'default');
})
window.editor = editor;
</script>
</body>
</html>

View file

@ -1,6 +1,6 @@
{
"name": "@editorjs/editorjs",
"version": "2.27.0",
"version": "2.30.0-rc.9",
"description": "Editor.js — Native JS, based on API and Open Source",
"main": "dist/editorjs.umd.js",
"module": "dist/editorjs.mjs",
@ -14,7 +14,8 @@
],
"scripts": {
"dev": "vite",
"build": "vite build",
"build": "vite build --mode production",
"build:test": "vite build --mode test",
"lint": "eslint src/ --ext .ts && yarn lint:tests",
"lint:errors": "eslint src/ --ext .ts --quiet",
"lint:fix": "eslint src/ --ext .ts --fix",
@ -26,8 +27,8 @@
"_tools:build": "git submodule foreach yarn build",
"_tools:make": "yarn _tools:yarn && yarn _tools:build",
"tools:update": "yarn _tools:checkout && yarn _tools:pull && yarn _tools:make",
"test:e2e": "yarn build && cypress run",
"test:e2e:open": "yarn build && cypress open",
"test:e2e": "yarn build:test && cypress run",
"test:e2e:open": "yarn build:test && cypress open",
"devserver:start": "yarn build && node ./devserver.js"
},
"author": "CodeX",
@ -44,15 +45,17 @@
"@editorjs/code": "^2.7.0",
"@editorjs/delimiter": "^1.2.0",
"@editorjs/header": "^2.7.0",
"@editorjs/paragraph": "^2.9.0",
"@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",
"eslint": "^8.37.0",
"eslint-config-codex": "^1.7.1",
"eslint-plugin-chai-friendly": "^0.7.2",

View file

@ -10,6 +10,7 @@ import '@babel/register';
import './components/polyfills';
import Core from './components/core';
import * as _ from './components/utils';
import { destroy as destroyTooltip } from './components/utils/tooltip';
declare const VERSION: string;
@ -67,6 +68,9 @@ export default class EditorJS {
*/
this.isReady = editor.isReady.then(() => {
this.exportAPI(editor);
/**
* @todo pass API as an argument. It will allow to use Editor's API when editor is ready
*/
onReady();
});
}
@ -87,6 +91,8 @@ export default class EditorJS {
moduleInstance.listeners.removeAll();
});
destroyTooltip();
editor = null;
for (const field in this) {

View file

@ -84,6 +84,13 @@ function BlockAPI(
return block.stretched;
},
/**
* True if Block has inputs to be focused
*/
get focusable(): boolean {
return block.focusable;
},
/**
* Call Tool method with errors handler under-the-hood
*

View file

@ -6,7 +6,7 @@ import {
SanitizerConfig,
ToolConfig,
ToolboxConfigEntry,
PopoverItem
PopoverItemParams
} from '../../../types';
import { SavedData } from '../../../types/data-formats';
@ -21,10 +21,11 @@ import BlockTune from '../tools/tune';
import { BlockTuneData } from '../../../types/block-tunes/block-tune-data';
import ToolsCollection from '../tools/collection';
import EventsDispatcher from '../utils/events';
import { TunesMenuConfigItem } from '../../../types/tools';
import { TunesMenuConfig, TunesMenuConfigItem } from '../../../types/tools';
import { isMutationBelongsToElement } from '../utils/mutations';
import { EditorEventMap, FakeCursorAboutToBeToggled, FakeCursorHaveBeenSet, RedactorDomChanged } from '../events';
import { RedactorDomChangedPayload } from '../events/RedactorDomChanged';
import { convertBlockDataToString, isSameBlockData } from '../utils/blocks';
/**
* Interface describes Block class constructor argument
@ -110,7 +111,6 @@ export default class Block extends EventsDispatcher<BlockEvents> {
wrapper: 'ce-block',
wrapperStretched: 'ce-block--stretched',
content: 'ce-block__content',
focused: 'ce-block--focused',
selected: 'ce-block--selected',
dropTarget: 'ce-block--drop-target',
};
@ -229,7 +229,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;
@ -251,15 +250,20 @@ export default class Block extends EventsDispatcher<BlockEvents> {
this.holder = this.compose();
/**
* Start watching block mutations
* Bind block events in RIC for optimizing of constructing process time
*/
this.watchBlockMutations();
window.requestIdleCallback(() => {
/**
* Start watching block mutations
*/
this.watchBlockMutations();
/**
* Mutation observer doesn't track changes in "<input>" and "<textarea>"
* so we need to track focus events to update current input and clear cache.
*/
this.addInputEvents();
/**
* Mutation observer doesn't track changes in "<input>" and "<textarea>"
* so we need to track focus events to update current input and clear cache.
*/
this.addInputEvents();
});
}
/**
@ -386,13 +390,20 @@ export default class Block extends EventsDispatcher<BlockEvents> {
return _.isFunction(this.toolInstance.merge);
}
/**
* If Block contains inputs, it is focusable
*/
public get focusable(): boolean {
return this.inputs.length !== 0;
}
/**
* Check block for emptiness
*
* @returns {boolean}
*/
public get isEmpty(): boolean {
const emptyText = $.isEmpty(this.pluginsContent);
const emptyText = $.isEmpty(this.pluginsContent, '/');
const emptyMedia = !this.hasMedia;
return emptyText && emptyMedia;
@ -423,22 +434,6 @@ export default class Block extends EventsDispatcher<BlockEvents> {
return !!this.holder.querySelector(mediaTags.join(','));
}
/**
* Set focused state
*
* @param {boolean} state - 'true' to select, 'false' to remove selection
*/
public set focused(state: boolean) {
this.holder.classList.toggle(Block.CSS.focused, state);
}
/**
* Get Block's focused state
*/
public get focused(): boolean {
return this.holder.classList.contains(Block.CSS.focused);
}
/**
* Set selected state
* We don't need to mark Block as Selected when it is empty
@ -554,7 +549,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;
@ -616,34 +611,60 @@ 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.
* Splits block tunes into 3 groups: block specific tunes, common tunes
* and custom html that is produced by combining tunes html from both previous groups
*/
public getTunes(): [PopoverItem[], HTMLElement] {
public getTunes(): {
toolTunes: PopoverItemParams[];
commonTunes: PopoverItemParams[];
customHtmlTunes: HTMLElement
} {
const customHtmlTunesContainer = document.createElement('div');
const tunesItems: TunesMenuConfigItem[] = [];
const commonTunesPopoverParams: TunesMenuConfigItem[] = [];
/** Tool's tunes: may be defined as return value of optional renderSettings method */
const tunesDefinedInTool = typeof this.toolInstance.renderSettings === 'function' ? this.toolInstance.renderSettings() : [];
/** Separate custom html from Popover items params for tool's tunes */
const {
items: toolTunesPopoverParams,
htmlElement: toolTunesHtmlElement,
} = this.getTunesDataSegregated(tunesDefinedInTool);
if (toolTunesHtmlElement !== undefined) {
customHtmlTunesContainer.appendChild(toolTunesHtmlElement);
}
/** Common tunes: combination of default tunes (move up, move down, delete) and third-party tunes connected via tunes api */
const commonTunes = [
...this.tunesInstances.values(),
...this.defaultTunesInstances.values(),
].map(tuneInstance => tuneInstance.render());
[tunesDefinedInTool, commonTunes].flat().forEach(rendered => {
if ($.isElement(rendered)) {
customHtmlTunesContainer.appendChild(rendered);
} else if (Array.isArray(rendered)) {
tunesItems.push(...rendered);
} else {
tunesItems.push(rendered);
/** Separate custom html from Popover items params for common tunes */
commonTunes.forEach(tuneConfig => {
const {
items,
htmlElement,
} = this.getTunesDataSegregated(tuneConfig);
if (htmlElement !== undefined) {
customHtmlTunesContainer.appendChild(htmlElement);
}
if (items !== undefined) {
commonTunesPopoverParams.push(...items);
}
});
return [tunesItems, customHtmlTunesContainer];
return {
toolTunes: toolTunesPopoverParams,
commonTunes: commonTunesPopoverParams,
customHtmlTunes: customHtmlTunesContainer,
};
}
/**
* Update current input index with selection anchor node
*/
@ -715,14 +736,39 @@ 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);
});
}
/**
* Exports Block data as string using conversion config
*/
public async exportDataAsString(): Promise<string> {
const blockData = await this.data;
return convertBlockDataToString(blockData, this.tool.conversionConfig);
}
/**
* Determines if tool's tunes settings are custom html or popover params and separates one from another by putting to different object fields
*
* @param tunes - tool's tunes config
*/
private getTunesDataSegregated(tunes: HTMLElement | TunesMenuConfig): { htmlElement?: HTMLElement; items: PopoverItemParams[] } {
const result = { } as { htmlElement?: HTMLElement; items: PopoverItemParams[] };
if ($.isElement(tunes)) {
result.htmlElement = tunes as HTMLElement;
} else if (Array.isArray(tunes)) {
result.items = tunes as PopoverItemParams[];
} else {
result.items = [ tunes ];
}
return result;
}
/**
* Make default Block wrappers and put Tool`s content there
*
@ -733,6 +779,16 @@ 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.
*/
wrapper.dataset.id = this.id;
/**
* Saving a reference to plugin's content element for guaranteed accessing it later
*/
@ -878,18 +934,22 @@ export default class Block extends EventsDispatcher<BlockEvents> {
* we should fire 'didMutated' event in that case
*/
const everyRecordIsMutationFree = mutationsOrInputEvent.length > 0 && mutationsOrInputEvent.every((record) => {
const { addedNodes, removedNodes } = record;
const { addedNodes, removedNodes, target } = record;
const changedNodes = [
...Array.from(addedNodes),
...Array.from(removedNodes),
target,
];
return changedNodes.some((node) => {
if (!$.isElement(node)) {
return false;
/**
* "characterData" mutation record has Text node as a target, so we need to get parent element to check it for mutation-free attribute
*/
node = node.parentElement;
}
return (node as HTMLElement).dataset.mutationFree === 'true';
return node && (node as HTMLElement).closest('[data-mutation-free="true"]') !== null;
});
});

View file

@ -220,6 +220,62 @@ export default class Blocks {
}
}
/**
* Replaces block under passed index with passed block
*
* @param index - index of existed block
* @param block - new block
*/
public replace(index: number, block: Block): void {
if (this.blocks[index] === undefined) {
throw Error('Incorrect index');
}
const prevBlock = this.blocks[index];
prevBlock.holder.replaceWith(block.holder);
this.blocks[index] = block;
}
/**
* Inserts several blocks at once
*
* @param blocks - blocks to insert
* @param index - index to insert blocks at
*/
public insertMany(blocks: Block[], index: number ): void {
const fragment = new DocumentFragment();
for (const block of blocks) {
fragment.appendChild(block.holder);
}
if (this.length > 0) {
if (index > 0) {
const previousBlockIndex = Math.min(index - 1, this.length - 1);
const previousBlock = this.blocks[previousBlockIndex];
previousBlock.holder.after(fragment);
} else if (index === 0) {
this.workingArea.prepend(fragment);
}
/**
* Insert blocks to the array at the specified index
*/
this.blocks.splice(index, 0, ...blocks);
} else {
this.blocks.push(...blocks);
this.workingArea.appendChild(fragment);
}
/**
* Call Rendered event for each block
*/
blocks.forEach((block) => block.call(BlockToolAPI.RENDERED));
}
/**
* Remove block
*
@ -267,7 +323,7 @@ export default class Blocks {
* @param {number} index Block index
* @returns {Block}
*/
public get(index: number): Block {
public get(index: number): Block | undefined {
return this.blocks[index];
}

View file

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

View file

@ -39,7 +39,8 @@ export default class Core {
/**
* Ready promise. Resolved if Editor.js is ready to work, rejected otherwise
*/
let onReady, onFail;
let onReady: (value?: void | PromiseLike<void>) => void;
let onFail: (reason?: unknown) => void;
this.isReady = new Promise((resolve, reject) => {
onReady = resolve;
@ -50,33 +51,21 @@ export default class Core {
.then(async () => {
this.configuration = config;
await this.validate();
await this.init();
this.validate();
this.init();
await this.start();
await this.render();
_.logLabeled('I\'m ready! (ノ◕ヮ◕)ノ*:・゚✧', 'log', '', 'color: #E24A75');
const { BlockManager, Caret, UI, ModificationsObserver } = this.moduleInstances;
setTimeout(async () => {
await this.render();
UI.checkEmptiness();
ModificationsObserver.enable();
if ((this.configuration as EditorConfig).autofocus) {
const { BlockManager, Caret } = this.moduleInstances;
if ((this.configuration as EditorConfig).autofocus) {
Caret.setToBlock(BlockManager.blocks[0], Caret.positions.START);
}
Caret.setToBlock(BlockManager.blocks[0], Caret.positions.START);
BlockManager.highlightCurrentNode();
}
/**
* Remove loader, show content
*/
this.moduleInstances.UI.removeLoader();
/**
* Resolve this.isReady promise
*/
onReady();
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
}, 500);
onReady();
})
.catch((error) => {
_.log(`Editor.js is not ready because of ${error}`, 'error');
@ -210,10 +199,8 @@ export default class Core {
/**
* Checks for required fields in Editor's config
*
* @returns {Promise<void>}
*/
public async validate(): Promise<void> {
public validate(): void {
const { holderId, holder } = this.config;
if (holderId && holder) {

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, 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);
}
@ -348,9 +350,10 @@ export default class Dom {
* @description Method checks simple Node without any childs for emptiness
* If you have Node with 2 or more children id depth, you better use {@link Dom#isEmpty} method
* @param {Node} node - node to check
* @param {string} [ignoreChars] - char or substring to treat as empty
* @returns {boolean} true if it is empty
*/
public static isNodeEmpty(node: Node): boolean {
public static isNodeEmpty(node: Node, ignoreChars?: string): boolean {
let nodeText;
if (this.isSingleTag(node as HTMLElement) && !this.isLineBreakTag(node as HTMLElement)) {
@ -363,6 +366,10 @@ export default class Dom {
nodeText = node.textContent.replace('\u200B', '');
}
if (ignoreChars) {
nodeText = nodeText.replace(new RegExp(ignoreChars, 'g'), '');
}
return nodeText.trim().length === 0;
}
@ -386,9 +393,10 @@ export default class Dom {
*
* @description Pushes to stack all DOM leafs and checks for emptiness
* @param {Node} node - node to check
* @param {string} [ignoreChars] - char or substring to treat as empty
* @returns {boolean}
*/
public static isEmpty(node: Node): boolean {
public static isEmpty(node: Node, ignoreChars?: string): boolean {
/**
* Normalize node to merge several text nodes to one to reduce tree walker iterations
*/
@ -403,7 +411,7 @@ export default class Dom {
continue;
}
if (this.isLeaf(node) && !this.isNodeEmpty(node)) {
if (this.isLeaf(node) && !this.isNodeEmpty(node, ignoreChars)) {
return false;
}

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,9 +1,10 @@
import { BlockAPI as BlockAPIInterface, Blocks } from '../../../../types/api';
import { BlockToolData, OutputData, ToolConfig } from '../../../../types';
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';
import Module from '../../__module';
import Block from '../../block';
import { capitalize } from './../../utils';
/**
* @class BlocksAPI
@ -17,7 +18,7 @@ export default class BlocksAPI extends Module {
*/
public get methods(): Blocks {
return {
clear: (): void => this.clear(),
clear: (): Promise<void> => this.clear(),
render: (data: OutputData): Promise<void> => this.render(data),
renderFromHTML: (data: string): Promise<void> => this.renderFromHTML(data),
delete: (index?: number): void => this.delete(index),
@ -31,8 +32,10 @@ export default class BlocksAPI extends Module {
stretchBlock: (index: number, status = true): void => this.stretchBlock(index, status),
insertNewBlock: (): void => this.insertNewBlock(),
insert: this.insert,
insertMany: this.insertMany,
update: this.update,
composeBlockData: this.composeBlockData,
convert: this.convert,
};
}
@ -137,9 +140,11 @@ export default class BlocksAPI extends Module {
*
* @param {number} blockIndex - index of Block to delete
*/
public delete(blockIndex?: number): void {
public delete(blockIndex: number = this.Editor.BlockManager.currentBlockIndex): void {
try {
this.Editor.BlockManager.removeBlock(blockIndex);
const block = this.Editor.BlockManager.getBlockByIndex(blockIndex);
this.Editor.BlockManager.removeBlock(block);
} catch (e) {
_.logLabeled(e, 'warn');
@ -167,8 +172,8 @@ export default class BlocksAPI extends Module {
/**
* Clear Editor's area
*/
public clear(): void {
this.Editor.BlockManager.clear(true);
public async clear(): Promise<void> {
await this.Editor.BlockManager.clear(true);
this.Editor.InlineToolbar.close();
}
@ -177,10 +182,21 @@ export default class BlocksAPI extends Module {
*
* @param {OutputData} data Saved Editor data
*/
public render(data: OutputData): Promise<void> {
this.Editor.BlockManager.clear();
public async render(data: OutputData): Promise<void> {
if (data === undefined || data.blocks === undefined) {
throw new Error('Incorrect data passed to the render() method');
}
return this.Editor.Renderer.render(data.blocks);
/**
* Semantic meaning of the "render" method: "Display the new document over the existing one that stays unchanged"
* So we need to disable modifications observer temporarily
*/
this.Editor.ModificationsObserver.disable();
await this.Editor.BlockManager.clear();
await this.Editor.Renderer.render(data.blocks);
this.Editor.ModificationsObserver.enable();
}
/**
@ -288,25 +304,105 @@ export default class BlocksAPI extends Module {
* @param id - id of the block to update
* @param data - the new data
*/
public update = (id: string, data: BlockToolData): void => {
public update = async (id: string, data: Partial<BlockToolData>): Promise<BlockAPIInterface> => {
const { BlockManager } = this.Editor;
const block = BlockManager.getBlockById(id);
if (!block) {
_.log('blocks.update(): Block with passed id was not found', 'warn');
return;
if (block === undefined) {
throw new Error(`Block with id "${id}" not found`);
}
const blockIndex = BlockManager.getBlockIndex(block);
const updatedBlock = await BlockManager.update(block, data);
BlockManager.insert({
id: block.id,
tool: block.name,
data,
index: blockIndex,
replace: true,
tunes: block.tunes,
});
// we cast to any because our BlockAPI has no "new" signature
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return new (BlockAPI as any)(updatedBlock);
};
/**
* Converts block to another type. Both blocks should provide the conversionConfig.
*
* @param id - id of the existing block to convert. Should provide 'conversionConfig.export' method
* @param newType - new block type. Should provide 'conversionConfig.import' method
* @param dataOverrides - optional data overrides for the new block
* @throws Error if conversion is not possible
*/
private convert = async (id: string, newType: string, dataOverrides?: BlockToolData): Promise<BlockAPIInterface> => {
const { BlockManager, Tools } = this.Editor;
const blockToConvert = BlockManager.getBlockById(id);
if (!blockToConvert) {
throw new Error(`Block with id "${id}" not found`);
}
const originalBlockTool = Tools.blockTools.get(blockToConvert.name);
const targetBlockTool = Tools.blockTools.get(newType);
if (!targetBlockTool) {
throw new Error(`Block Tool with type "${newType}" not found`);
}
const originalBlockConvertable = originalBlockTool?.conversionConfig?.export !== undefined;
const targetBlockConvertable = targetBlockTool.conversionConfig?.import !== undefined;
if (originalBlockConvertable && targetBlockConvertable) {
const newBlock = await BlockManager.convert(blockToConvert, newType, dataOverrides);
return new BlockAPI(newBlock);
} else {
const unsupportedBlockTypes = [
!originalBlockConvertable ? capitalize(blockToConvert.name) : false,
!targetBlockConvertable ? capitalize(newType) : false,
].filter(Boolean).join(' and ');
throw new Error(`Conversion from "${blockToConvert.name}" to "${newType}" is not possible. ${unsupportedBlockTypes} tool(s) should provide a "conversionConfig"`);
}
};
/**
* Inserts several Blocks to a specified index
*
* @param blocks - blocks data to insert
* @param index - index to insert the blocks at
*/
private insertMany = (
blocks: OutputBlockData[],
index: number = this.Editor.BlockManager.blocks.length - 1
): BlockAPIInterface[] => {
this.validateIndex(index);
const blocksToInsert = blocks.map(({ id, type, data }) => {
return this.Editor.BlockManager.composeBlock({
id,
tool: type || (this.config.defaultBlock as string),
data,
});
});
this.Editor.BlockManager.insertMany(blocksToInsert, index);
// we cast to any because our BlockAPI has no "new" signature
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return blocksToInsert.map((block) => new (BlockAPI as any)(block));
};
/**
* Validated block index and throws an error if it's invalid
*
* @param index - index to validate
*/
private validateIndex(index: unknown): void {
if (typeof index !== 'number') {
throw new Error('Index should be a number');
}
if (index < 0) {
throw new Error(`Index should be greater than or equal to 0`);
}
if (index === null) {
throw new Error(`Index should be greater than or equal to 0`);
}
}
}

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

@ -2,16 +2,12 @@ import { Tooltip as ITooltip } from '../../../../types/api';
import type { TooltipOptions, TooltipContent } from 'codex-tooltip/types';
import Module from '../../__module';
import { ModuleConfig } from '../../../types-internal/module-config';
import Tooltip from '../../utils/tooltip';
import * as tooltip from '../../utils/tooltip';
/**
* @class TooltipAPI
* @classdesc Tooltip API
*/
export default class TooltipAPI extends Module {
/**
* Tooltip utility Instance
*/
private tooltip: Tooltip;
/**
* @class
* @param moduleConfiguration - Module Configuration
@ -23,15 +19,6 @@ export default class TooltipAPI extends Module {
config,
eventsDispatcher,
});
this.tooltip = new Tooltip();
}
/**
* Destroy Module
*/
public destroy(): void {
this.tooltip.destroy();
}
/**
@ -59,14 +46,14 @@ export default class TooltipAPI extends Module {
* @param {TooltipOptions} options - tooltip options
*/
public show(element: HTMLElement, content: TooltipContent, options?: TooltipOptions): void {
this.tooltip.show(element, content, options);
tooltip.show(element, content, options);
}
/**
* Method hides tooltip on HTML page
*/
public hide(): void {
this.tooltip.hide();
tooltip.hide();
}
/**
@ -77,6 +64,6 @@ export default class TooltipAPI extends Module {
* @param {TooltipOptions} options - tooltip options
*/
public onHover(element: HTMLElement, content: TooltipContent, options?: TooltipOptions): void {
this.tooltip.onHover(element, content, options);
tooltip.onHover(element, content, options);
}
}

View file

@ -5,6 +5,8 @@ import Module from '../__module';
import * as _ from '../utils';
import SelectionUtils from '../selection';
import Flipper from '../flipper';
import type Block from '../block';
import { areBlocksMergeable } from '../utils/blocks';
/**
*
@ -29,6 +31,10 @@ export default class BlockEvents extends Module {
this.backspace(event);
break;
case _.keyCodes.DELETE:
this.delete(event);
break;
case _.keyCodes.ENTER:
this.enter(event);
break;
@ -47,6 +53,24 @@ export default class BlockEvents extends Module {
this.tabPressed(event);
break;
}
/**
* We check for "key" here since on different keyboard layouts "/" can be typed as "Shift + 7" etc
*
* @todo probably using "beforeInput" event would be better here
*/
if (event.key === '/' && !event.ctrlKey && !event.metaKey) {
this.slashPressed();
}
/**
* If user pressed "Ctrl + /" or "Cmd + /" open Block Settings
* We check for "code" here since on different keyboard layouts there can be different keys in place of Slash.
*/
if (event.code === 'Slash' && (event.ctrlKey || event.metaKey)) {
event.preventDefault();
this.commandSlashPressed();
}
}
/**
@ -80,7 +104,6 @@ export default class BlockEvents extends Module {
const isShortcut = event.ctrlKey || event.metaKey || event.altKey || event.shiftKey;
if (!isShortcut) {
this.Editor.BlockManager.clearFocused();
this.Editor.BlockSelection.clearSelection(event);
}
}
@ -107,40 +130,6 @@ export default class BlockEvents extends Module {
this.Editor.UI.checkEmptiness();
}
/**
* Open Toolbox to leaf Tools
*
* @param {KeyboardEvent} event - tab keydown event
*/
public tabPressed(event): void {
/**
* Clear blocks selection by tab
*/
this.Editor.BlockSelection.clearSelection(event);
const { BlockManager, InlineToolbar, ConversionToolbar } = this.Editor;
const currentBlock = BlockManager.currentBlock;
if (!currentBlock) {
return;
}
const isEmptyBlock = currentBlock.isEmpty;
const canOpenToolbox = currentBlock.tool.isDefault && isEmptyBlock;
const conversionToolbarOpened = !isEmptyBlock && ConversionToolbar.opened;
const inlineToolbarOpened = !isEmptyBlock && !SelectionUtils.isCollapsed && InlineToolbar.opened;
const canOpenBlockTunes = !conversionToolbarOpened && !inlineToolbarOpened;
/**
* For empty Blocks we show Plus button via Toolbox only for default Blocks
*/
if (canOpenToolbox) {
this.activateToolbox();
} else if (canOpenBlockTunes) {
this.activateBlockSettings();
}
}
/**
* Add drop target styles
*
@ -207,6 +196,69 @@ export default class BlockEvents extends Module {
});
}
/**
* Tab pressed inside a Block.
*
* @param {KeyboardEvent} event - keydown
*/
private tabPressed(event: KeyboardEvent): void {
const { InlineToolbar, ConversionToolbar, Caret } = this.Editor;
const isFlipperActivated = ConversionToolbar.opened || InlineToolbar.opened;
if (isFlipperActivated) {
return;
}
const isNavigated = event.shiftKey ? Caret.navigatePrevious(true) : Caret.navigateNext(true);
/**
* If we have next Block/input to focus, then focus it. Otherwise, leave native Tab behaviour
*/
if (isNavigated) {
event.preventDefault();
}
}
/**
* '/' + 'command' keydown inside a Block
*/
private commandSlashPressed(): void {
if (this.Editor.BlockSelection.selectedBlocks.length > 1) {
return;
}
this.activateBlockSettings();
}
/**
* '/' keydown inside a Block
*/
private slashPressed(): void {
const currentBlock = this.Editor.BlockManager.currentBlock;
const canOpenToolbox = currentBlock.isEmpty;
/**
* @todo Handle case when slash pressed when several blocks are selected
*/
/**
* Toolbox will be opened only if Block is empty
*/
if (!canOpenToolbox) {
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();
}
/**
* ENTER pressed on block
*
@ -234,8 +286,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;
}
@ -277,108 +333,179 @@ export default class BlockEvents extends Module {
* @param {KeyboardEvent} event - keydown
*/
private backspace(event: KeyboardEvent): void {
const { BlockManager, BlockSelection, Caret } = this.Editor;
const currentBlock = BlockManager.currentBlock;
const tool = currentBlock.tool;
const { BlockManager, Caret } = this.Editor;
const { currentBlock, previousBlock } = BlockManager;
/**
* Check if Block should be removed by current Backspace keydown
* If some fragment is selected, leave native behaviour
*/
if (currentBlock.selected || (currentBlock.isEmpty && currentBlock.currentInput === currentBlock.firstInput)) {
event.preventDefault();
if (!SelectionUtils.isCollapsed) {
return;
}
const index = BlockManager.currentBlockIndex;
/**
* If caret is not at the start, leave native behaviour
*/
if (!Caret.isAtStart) {
return;
}
/**
* All the cases below have custom behaviour, so we don't need a native one
*/
event.preventDefault();
this.Editor.Toolbar.close();
if (BlockManager.previousBlock && BlockManager.previousBlock.inputs.length === 0) {
/** If previous block doesn't contain inputs, remove it */
BlockManager.removeBlock(index - 1);
} else {
/** If block is empty, just remove it */
BlockManager.removeBlock();
}
const isFirstInputFocused = currentBlock.currentInput === currentBlock.firstInput;
Caret.setToBlock(
BlockManager.currentBlock,
index ? Caret.positions.END : Caret.positions.START
);
/** Close Toolbar */
this.Editor.Toolbar.close();
/** Clear selection */
BlockSelection.clearSelection(event);
/**
* For example, caret at the start of the Quote second input (caption) just navigate previous input
*/
if (!isFirstInputFocused) {
Caret.navigatePrevious();
return;
}
/**
* Don't handle Backspaces when Tool sets enableLineBreaks to true.
* Uses for Tools like <code> where line breaks should be handled by default behaviour.
*
* But if caret is at start of the block, we allow to remove it by backspaces
* Backspace at the start of the first Block should do nothing
*/
if (tool.isLineBreaksEnabled && !Caret.isAtStart) {
if (previousBlock === null) {
return;
}
const isFirstBlock = BlockManager.currentBlockIndex === 0;
const canMergeBlocks = Caret.isAtStart &&
SelectionUtils.isCollapsed &&
currentBlock.currentInput === currentBlock.firstInput &&
!isFirstBlock;
/**
* If prev Block is empty, it should be removed just like a character
*/
if (previousBlock.isEmpty) {
BlockManager.removeBlock(previousBlock);
if (canMergeBlocks) {
/**
* preventing browser default behaviour
*/
event.preventDefault();
return;
}
/**
* Merge Blocks
*/
this.mergeBlocks();
/**
* If current Block is empty, just remove it and set cursor to the previous Block (like we're removing line break char)
*/
if (currentBlock.isEmpty) {
BlockManager.removeBlock(currentBlock);
const newCurrentBlock = BlockManager.currentBlock;
Caret.setToBlock(newCurrentBlock, Caret.positions.END);
return;
}
const bothBlocksMergeable = areBlocksMergeable(currentBlock, previousBlock);
/**
* If Blocks could be merged, do it
* Otherwise, just navigate previous block
*/
if (bothBlocksMergeable) {
this.mergeBlocks(previousBlock, currentBlock);
} else {
Caret.setToBlock(previousBlock, Caret.positions.END);
}
}
/**
* Merge current and previous Blocks if they have the same type
* Handles delete keydown on Block
* Removes char after the caret.
* If caret is at the end of the block, merge next block with current
*
* @param {KeyboardEvent} event - keydown
*/
private mergeBlocks(): void {
const { BlockManager, Caret, Toolbar } = this.Editor;
const targetBlock = BlockManager.previousBlock;
const blockToMerge = BlockManager.currentBlock;
private delete(event: KeyboardEvent): void {
const { BlockManager, Caret } = this.Editor;
const { currentBlock, nextBlock } = BlockManager;
/**
* Blocks that can be merged:
* 1) with the same Name
* 2) Tool has 'merge' method
*
* other case will handle as usual ARROW LEFT behaviour
* If some fragment is selected, leave native behaviour
*/
if (blockToMerge.name !== targetBlock.name || !targetBlock.mergeable) {
/** If target Block doesn't contain inputs or empty, remove it */
if (targetBlock.inputs.length === 0 || targetBlock.isEmpty) {
BlockManager.removeBlock(BlockManager.currentBlockIndex - 1);
if (!SelectionUtils.isCollapsed) {
return;
}
Caret.setToBlock(BlockManager.currentBlock);
Toolbar.close();
/**
* If caret is not at the end, leave native behaviour
*/
if (!Caret.isAtEnd) {
return;
}
return;
}
/**
* All the cases below have custom behaviour, so we don't need a native one
*/
event.preventDefault();
this.Editor.Toolbar.close();
if (Caret.navigatePrevious()) {
Toolbar.close();
}
const isLastInputFocused = currentBlock.currentInput === currentBlock.lastInput;
/**
* For example, caret at the end of the Quote first input (quote text) just navigate next input (caption)
*/
if (!isLastInputFocused) {
Caret.navigateNext();
return;
}
/**
* Delete at the end of the last Block should do nothing
*/
if (nextBlock === null) {
return;
}
/**
* If next Block is empty, it should be removed just like a character
*/
if (nextBlock.isEmpty) {
BlockManager.removeBlock(nextBlock);
return;
}
/**
* If current Block is empty, just remove it and set cursor to the next Block (like we're removing line break char)
*/
if (currentBlock.isEmpty) {
BlockManager.removeBlock(currentBlock);
Caret.setToBlock(nextBlock, Caret.positions.START);
return;
}
const bothBlocksMergeable = areBlocksMergeable(currentBlock, nextBlock);
/**
* If Blocks could be merged, do it
* Otherwise, just navigate to the next block
*/
if (bothBlocksMergeable) {
this.mergeBlocks(currentBlock, nextBlock);
} else {
Caret.setToBlock(nextBlock, Caret.positions.START);
}
}
/**
* Merge passed Blocks
*
* @param targetBlock - to which Block we want to merge
* @param blockToMerge - what Block we want to merge
*/
private mergeBlocks(targetBlock: Block, blockToMerge: Block): void {
const { BlockManager, Caret, Toolbar } = this.Editor;
Caret.createShadow(targetBlock.pluginsContent);
BlockManager.mergeBlocks(targetBlock, blockToMerge)
BlockManager
.mergeBlocks(targetBlock, blockToMerge)
.then(() => {
/** Restore caret position after merge */
Caret.restoreCaret(targetBlock.pluginsContent as HTMLElement);
targetBlock.pluginsContent.normalize();
Toolbar.close();
});
}
@ -401,9 +528,8 @@ export default class BlockEvents extends Module {
}
/**
* Close Toolbar and highlighting when user moves cursor
* Close Toolbar when user moves cursor
*/
this.Editor.BlockManager.clearFocused();
this.Editor.Toolbar.close();
const shouldEnableCBS = this.Editor.Caret.isAtEnd || this.Editor.BlockSelection.anyBlockSelected;
@ -422,19 +548,21 @@ export default class BlockEvents extends Module {
* Default behaviour moves cursor by 1 character, we need to prevent it
*/
event.preventDefault();
} else {
/**
* After caret is set, update Block input index
*/
_.delay(() => {
/** Check currentBlock for case when user moves selection out of Editor */
if (this.Editor.BlockManager.currentBlock) {
this.Editor.BlockManager.currentBlock.updateCurrentInput();
}
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
}, 20)();
return;
}
/**
* After caret is set, update Block input index
*/
_.delay(() => {
/** Check currentBlock for case when user moves selection out of Editor */
if (this.Editor.BlockManager.currentBlock) {
this.Editor.BlockManager.currentBlock.updateCurrentInput();
}
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
}, 20)();
/**
* Clear blocks selection by arrows
*/
@ -460,9 +588,8 @@ export default class BlockEvents extends Module {
}
/**
* Close Toolbar and highlighting when user moves cursor
* Close Toolbar when user moves cursor
*/
this.Editor.BlockManager.clearFocused();
this.Editor.Toolbar.close();
const shouldEnableCBS = this.Editor.Caret.isAtStart || this.Editor.BlockSelection.anyBlockSelected;
@ -481,19 +608,21 @@ export default class BlockEvents extends Module {
* Default behaviour moves cursor by 1 character, we need to prevent it
*/
event.preventDefault();
} else {
/**
* After caret is set, update Block input index
*/
_.delay(() => {
/** Check currentBlock for case when user ends selection out of Editor and then press arrow-key */
if (this.Editor.BlockManager.currentBlock) {
this.Editor.BlockManager.currentBlock.updateCurrentInput();
}
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
}, 20)();
return;
}
/**
* After caret is set, update Block input index
*/
_.delay(() => {
/** Check currentBlock for case when user ends selection out of Editor and then press arrow-key */
if (this.Editor.BlockManager.currentBlock) {
this.Editor.BlockManager.currentBlock.updateCurrentInput();
}
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
}, 20)();
/**
* Clear blocks selection by arrows
*/
@ -543,7 +672,6 @@ export default class BlockEvents extends Module {
*/
private activateBlockSettings(): void {
if (!this.Editor.Toolbar.opened) {
this.Editor.BlockManager.currentBlock.focused = true;
this.Editor.Toolbar.moveAndOpen();
}

View file

@ -18,6 +18,9 @@ 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, sanitizeBlocks } from '../utils/sanitizer';
import { convertStringToBlockData, isBlockConvertable } from '../utils/blocks';
import PromiseQueue from '../utils/promise-queue';
/**
* @typedef {BlockManager} BlockManager
@ -66,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];
}
@ -84,7 +87,7 @@ export default class BlockManager extends Module {
*
* @returns {Block|null}
*/
public get nextBlock(): Block {
public get nextBlock(): Block | null {
const isLastBlock = this.currentBlockIndex === (this._blocks.length - 1);
if (isLastBlock) {
@ -121,7 +124,7 @@ export default class BlockManager extends Module {
*
* @returns {Block|null}
*/
public get previousBlock(): Block {
public get previousBlock(): Block | null {
const isFirstBlock = this.currentBlockIndex === 0;
if (isFirstBlock) {
@ -242,7 +245,9 @@ export default class BlockManager extends Module {
}, this.eventsDispatcher);
if (!readOnly) {
this.bindBlockEvents(block);
window.requestIdleCallback(() => {
this.bindBlockEvents(block);
}, { timeout: 2000 });
}
return block;
@ -319,21 +324,59 @@ export default class BlockManager extends Module {
}
/**
* Replace current working block
* Inserts several blocks at once
*
* @param {object} options - replace options
* @param {string} options.tool plugin name
* @param {BlockToolData} options.data plugin data
* @returns {Block}
* @param blocks - blocks to insert
* @param index - index where to insert
*/
public replace({
tool = this.config.defaultBlock,
data = {},
}): Block {
public insertMany(blocks: Block[], index = 0): void {
this._blocks.insertMany(blocks, index);
}
/**
* Update Block data.
*
* Currently we don't have an 'update' method in the Tools API, so we just create a new block with the same id and type
* Should not trigger 'block-removed' or 'block-added' events
*
* @param block - block to update
* @param data - new data
*/
public async update(block: Block, data: Partial<BlockToolData>): Promise<Block> {
const existingData = await block.data;
const newBlock = this.composeBlock({
id: block.id,
tool: block.name,
data: Object.assign({}, existingData, data),
tunes: block.tunes,
});
const blockIndex = this.getBlockIndex(block);
this._blocks.replace(blockIndex, newBlock);
this.blockDidMutated(BlockChangedMutationType, newBlock, {
index: blockIndex,
});
return newBlock;
}
/**
* Replace passed Block with the new one with specified Tool and data
*
* @param block - block to replace
* @param newTool - new Tool name
* @param data - new Tool data
*/
public replace(block: Block, newTool: string, data: BlockToolData): Block {
const blockIndex = this.getBlockIndex(block);
return this.insert({
tool,
tool: newTool,
data,
index: this.currentBlockIndex,
index: blockIndex,
replace: true,
});
}
@ -356,7 +399,16 @@ export default class BlockManager extends Module {
});
try {
block.call(BlockToolAPI.ON_PASTE, pasteEvent);
/**
* We need to call onPaste after Block will be ready
* because onPaste could change tool's root element, and we need to do that after block.watchBlockMutations() bound
* to detect tool root element change
*
* @todo make this.insert() awaitable and remove requestIdleCallback
*/
window.requestIdleCallback(() => {
block.call(BlockToolAPI.ON_PASTE, pasteEvent);
});
} catch (e) {
_.log(`${toolName}: onPaste callback call is failed`, 'error', e);
}
@ -419,61 +471,90 @@ export default class BlockManager extends Module {
* @returns {Promise} - the sequence that can be continued
*/
public async mergeBlocks(targetBlock: Block, blockToMerge: Block): Promise<void> {
const blockToMergeIndex = this._blocks.indexOf(blockToMerge);
let blockToMergeData: BlockToolData | undefined;
if (blockToMerge.isEmpty) {
/**
* 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;
}
const blockToMergeData = await blockToMerge.data;
if (!_.isEmpty(blockToMergeData)) {
await targetBlock.mergeWith(blockToMergeData);
}
this.removeBlock(blockToMergeIndex);
await targetBlock.mergeWith(blockToMergeData);
this.removeBlock(blockToMerge);
this.currentBlockIndex = this._blocks.indexOf(targetBlock);
}
/**
* Remove block with passed index or remove last
* Remove passed Block
*
* @param {number|null} index - index of Block to remove
* @throws {Error} if Block to remove is not found
* @param block - Block to remove
* @param addLastBlock - if true, adds new default block at the end. @todo remove this logic and use event-bus instead
*/
public removeBlock(index = this.currentBlockIndex): void {
/**
* If index is not passed and there is no block selected, show a warning
*/
if (!this.validateIndex(index)) {
throw new Error('Can\'t find a Block to remove');
}
public removeBlock(block: Block, addLastBlock = true): Promise<void> {
return new Promise((resolve) => {
const index = this._blocks.indexOf(block);
const blockToRemove = this._blocks[index];
/**
* If index is not passed and there is no block selected, show a warning
*/
if (!this.validateIndex(index)) {
throw new Error('Can\'t find a Block to remove');
}
blockToRemove.destroy();
this._blocks.remove(index);
block.destroy();
this._blocks.remove(index);
/**
* Force call of didMutated event on Block removal
*/
this.blockDidMutated(BlockRemovedMutationType, blockToRemove, {
index,
/**
* Force call of didMutated event on Block removal
*/
this.blockDidMutated(BlockRemovedMutationType, block, {
index,
});
if (this.currentBlockIndex >= index) {
this.currentBlockIndex--;
}
/**
* If first Block was removed, insert new Initial Block and set focus on it`s first input
*/
if (!this.blocks.length) {
this.currentBlockIndex = -1;
if (addLastBlock) {
this.insert();
}
} else if (index === 0) {
this.currentBlockIndex = 0;
}
resolve();
});
if (this.currentBlockIndex >= index) {
this.currentBlockIndex--;
}
/**
* If first Block was removed, insert new Initial Block and set focus on it`s first input
*/
if (!this.blocks.length) {
this.currentBlockIndex = -1;
this.insert();
} else if (index === 0) {
this.currentBlockIndex = 0;
}
}
/**
@ -493,7 +574,7 @@ export default class BlockManager extends Module {
continue;
}
this.removeBlock(index);
this.removeBlock(this.blocks[index]);
firstSelectedBlockIndex = index;
}
@ -543,13 +624,28 @@ export default class BlockManager extends Module {
return this.insert({ data });
}
/**
* Returns Block by passed index
*
* If we pass -1 as index, the last block will be returned
* There shouldn't be a case when there is no blocks at all at least one always should exist
*/
public getBlockByIndex(index: -1): Block;
/**
* Returns Block by passed index.
*
* Could return undefined if there is no block with such index
*/
public getBlockByIndex(index: number): Block | undefined;
/**
* Returns Block by passed index
*
* @param {number} index - index to get. -1 to get last
* @returns {Block}
*/
public getBlockByIndex(index): Block {
public getBlockByIndex(index: number): Block | undefined {
if (index === -1) {
index = this._blocks.length - 1;
}
@ -595,32 +691,6 @@ export default class BlockManager extends Module {
}
}
/**
* Remove selection from all Blocks then highlight only Current Block
*/
public highlightCurrentNode(): void {
/**
* Remove previous selected Block's state
*/
this.clearFocused();
/**
* Mark current Block as selected
*
* @type {boolean}
*/
this.currentBlock.focused = true;
}
/**
* Remove selection from all Blocks
*/
public clearFocused(): void {
this.blocks.forEach((block) => {
block.focused = false;
});
}
/**
* 1) Find first-level Block from passed child Node
* 2) Mark it as current
@ -676,7 +746,11 @@ export default class BlockManager extends Module {
* @param {Node} childNode - node to get Block by
* @returns {Block}
*/
public getBlockByChildNode(childNode: Node): Block {
public getBlockByChildNode(childNode: Node): Block | undefined {
if (!childNode || childNode instanceof Node === false) {
return undefined;
}
/**
* If node is Text TextNode
*/
@ -739,13 +813,68 @@ export default class BlockManager extends Module {
});
}
/**
* Converts passed Block to the new Tool
* Uses Conversion Config
*
* @param blockToConvert - Block that should be converted
* @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<Block> {
/**
* At first, we get current Block data
*/
const savedBlock = await blockToConvert.save();
if (!savedBlock) {
throw new Error('Could not convert Block. Failed to extract original Block data.');
}
/**
* Getting a class of the replacing Tool
*/
const replacingTool = this.Editor.Tools.blockTools.get(targetToolName);
if (!replacingTool) {
throw new Error(`Could not convert Block. Tool «${targetToolName}» not found.`);
}
/**
* Using Conversion Config "export" we get a stringified version of the Block data
*/
const exportedData = await blockToConvert.exportDataAsString();
/**
* Clean exported data with replacing sanitizer config
*/
const cleanData: string = clean(
exportedData,
replacingTool.sanitizeConfig
);
/**
* Now using Conversion Config "import" we compose a new Block data
*/
let newBlockData = convertStringToBlockData(cleanData, replacingTool.conversionConfig);
/**
* Optional data overrides.
* Used for example, by the Multiple Toolbox Items feature, where a single Tool provides several Toolbox items with "data" overrides
*/
if (blockDataOverrides) {
newBlockData = Object.assign(newBlockData, blockDataOverrides);
}
return this.replace(blockToConvert, replacingTool.name, newBlockData);
}
/**
* Sets current Block Index -1 which means unknown
* and clear highlights
*/
public dropPointer(): void {
this.currentBlockIndex = -1;
this.clearFocused();
}
/**
@ -755,8 +884,17 @@ export default class BlockManager extends Module {
* we don't need to add an empty default block
* 2) in api.blocks.clear we should add empty block
*/
public clear(needToAddDefaultBlock = false): void {
this._blocks.removeAll();
public async clear(needToAddDefaultBlock = false): Promise<void> {
const queue = new PromiseQueue();
this.blocks.forEach((block) => {
queue.add(async () => {
await this.removeBlock(block, false);
});
});
await queue.completed;
this.dropPointer();
if (needToAddDefaultBlock) {

View file

@ -321,26 +321,28 @@ export default class BlockSelection extends Module {
}
/**
* select Block
* Select Block by its index
*
* @param {number?} index - Block index according to the BlockManager's indexes
*/
public selectBlockByIndex(index?): void {
public selectBlockByIndex(index: number): void {
const { BlockManager } = this.Editor;
/**
* Remove previous focused Block's state
*/
BlockManager.clearFocused();
const block = BlockManager.getBlockByIndex(index);
let block;
if (isNaN(index)) {
block = BlockManager.currentBlock;
} else {
block = BlockManager.getBlockByIndex(index);
if (block === undefined) {
return;
}
this.selectBlock(block);
}
/**
* Select passed Block
*
* @param {Block} block - Block to select
*/
public selectBlock(block: Block): void {
/** Save selection */
this.selection.save();
SelectionUtils.get()
@ -354,6 +356,17 @@ export default class BlockSelection extends Module {
this.Editor.InlineToolbar.close();
}
/**
* Remove selection from passed Block
*
* @param {Block} block - Block to unselect
*/
public unselectBlock(block: Block): void {
block.selected = false;
this.clearCache();
}
/**
* Clear anyBlockSelected cache
*/
@ -432,7 +445,7 @@ export default class BlockSelection extends Module {
/**
* select working Block
*/
this.selectBlockByIndex();
this.selectBlock(workingBlock);
/**
* Enable all Blocks selection if current Block is selected

View file

@ -11,7 +11,6 @@ import Selection from '../selection';
import Module from '../__module';
import Block from '../block';
import $ from '../dom';
import * as _ from '../utils';
/**
* @typedef {Caret} Caret
@ -46,8 +45,17 @@ export default class Caret extends Module {
* @returns {boolean}
*/
public get isAtStart(): boolean {
const { currentBlock } = this.Editor.BlockManager;
/**
* If Block does not contain inputs, treat caret as "at start"
*/
if (!currentBlock?.focusable) {
return true;
}
const selection = Selection.get();
const firstNode = $.getDeepestNode(this.Editor.BlockManager.currentBlock.currentInput);
const firstNode = $.getDeepestNode(currentBlock.currentInput);
let focusNode = selection.focusNode;
/** In case lastNode is native input */
@ -138,10 +146,19 @@ export default class Caret extends Module {
* @returns {boolean}
*/
public get isAtEnd(): boolean {
const { currentBlock } = this.Editor.BlockManager;
/**
* If Block does not contain inputs, treat caret as "at end"
*/
if (!currentBlock.focusable) {
return true;
}
const selection = Selection.get();
let focusNode = selection.focusNode;
const lastNode = $.getDeepestNode(this.Editor.BlockManager.currentBlock.currentInput, true);
const lastNode = $.getDeepestNode(currentBlock.currentInput, true);
/** In case lastNode is native input */
if ($.isNativeInput(lastNode)) {
@ -224,7 +241,31 @@ export default class Caret extends Module {
* @param {number} offset - caret offset regarding to the text node
*/
public setToBlock(block: Block, position: string = this.positions.DEFAULT, offset = 0): void {
const { BlockManager } = this.Editor;
const { BlockManager, BlockSelection } = this.Editor;
/**
* Clear previous selection since we possible will select the new Block
*/
BlockSelection.clearSelection();
/**
* If Block is not focusable, just select (highlight) it
*/
if (!block.focusable) {
/**
* Hide current cursor
*/
window.getSelection()?.removeAllRanges();
/**
* Highlight Block
*/
BlockSelection.selectBlock(block);
BlockManager.currentBlock = block;
return;
}
let element;
switch (position) {
@ -255,13 +296,7 @@ export default class Caret extends Module {
break;
}
/**
* @todo try to fix via Promises or use querySelectorAll to not to use timeout
*/
_.delay(() => {
this.set(nodeToSet as HTMLElement, offset);
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
}, 20)();
this.set(nodeToSet as HTMLElement, offset);
BlockManager.setCurrentBlockByChildNode(block.holder);
BlockManager.currentBlock.currentInput = element;
@ -304,16 +339,17 @@ export default class Caret extends Module {
* @param {number} offset - offset
*/
public set(element: HTMLElement, offset = 0): void {
const scrollOffset = 30;
const { top, bottom } = Selection.setCursor(element, offset);
/** If new cursor position is not visible, scroll to it */
const { innerHeight } = window;
/**
* If new cursor position is not visible, scroll to it
*/
if (top < 0) {
window.scrollBy(0, top);
}
if (bottom > innerHeight) {
window.scrollBy(0, bottom - innerHeight);
window.scrollBy(0, top - scrollOffset);
} else if (bottom > innerHeight) {
window.scrollBy(0, bottom - innerHeight + scrollOffset);
}
}
@ -387,17 +423,25 @@ export default class Caret extends Module {
* Before moving caret, we should check if caret position is at the end of Plugins node
* Using {@link Dom#getDeepestNode} to get a last node and match with current selection
*
* @returns {boolean}
* @param {boolean} force - pass true to skip check for caret position
*/
public navigateNext(): boolean {
public navigateNext(force = false): boolean {
const { BlockManager } = this.Editor;
const { currentBlock, nextContentfulBlock } = BlockManager;
const { currentBlock, nextBlock } = BlockManager;
const { nextInput } = currentBlock;
const isAtEnd = this.isAtEnd;
let blockToNavigate = nextBlock;
let nextBlock = nextContentfulBlock;
const navigationAllowed = force || isAtEnd;
if (!nextBlock && !nextInput) {
/** If next Tool`s input exists, focus on it. Otherwise set caret to the next Block */
if (nextInput && navigationAllowed) {
this.setToInput(nextInput, this.positions.START);
return true;
}
if (blockToNavigate === null) {
/**
* This code allows to exit from the last non-initial tool:
* https://github.com/codex-team/editor.js/issues/1103
@ -408,7 +452,7 @@ export default class Caret extends Module {
* 2. If there is a last block and it is non-default --> and caret not at the end <--, do nothing
* (https://github.com/codex-team/editor.js/issues/1414)
*/
if (currentBlock.tool.isDefault || !isAtEnd) {
if (currentBlock.tool.isDefault || !navigationAllowed) {
return false;
}
@ -416,16 +460,11 @@ export default class Caret extends Module {
* If there is no nextBlock, but currentBlock is not default,
* insert new default block at the end and navigate to it
*/
nextBlock = BlockManager.insertAtEnd();
blockToNavigate = BlockManager.insertAtEnd() as Block;
}
if (isAtEnd) {
/** If next Tool`s input exists, focus on it. Otherwise set caret to the next Block */
if (!nextInput) {
this.setToBlock(nextBlock, this.positions.START);
} else {
this.setToInput(nextInput, this.positions.START);
}
if (navigationAllowed) {
this.setToBlock(blockToNavigate, this.positions.START);
return true;
}
@ -438,28 +477,27 @@ export default class Caret extends Module {
* Before moving caret, we should check if caret position is start of the Plugins node
* Using {@link Dom#getDeepestNode} to get a last node and match with current selection
*
* @returns {boolean}
* @param {boolean} force - pass true to skip check for caret position
*/
public navigatePrevious(): boolean {
const { currentBlock, previousContentfulBlock } = this.Editor.BlockManager;
public navigatePrevious(force = false): boolean {
const { currentBlock, previousBlock } = this.Editor.BlockManager;
if (!currentBlock) {
return false;
}
const { previousInput } = currentBlock;
const navigationAllowed = force || this.isAtStart;
if (!previousContentfulBlock && !previousInput) {
return false;
/** If previous Tool`s input exists, focus on it. Otherwise set caret to the previous Block */
if (previousInput && navigationAllowed) {
this.setToInput(previousInput, this.positions.END);
return true;
}
if (this.isAtStart) {
/** If previous Tool`s input exists, focus on it. Otherwise set caret to the previous Block */
if (!previousInput) {
this.setToBlock(previousContentfulBlock, this.positions.END);
} else {
this.setToInput(previousInput, this.positions.END);
}
if (previousBlock !== null && navigationAllowed) {
this.setToBlock(previousBlock as Block, this.positions.END);
return true;
}
@ -503,13 +541,10 @@ export default class Caret extends Module {
sel.expandToTag(shadowCaret as HTMLElement);
setTimeout(() => {
const newRange = document.createRange();
const newRange = document.createRange();
newRange.selectNode(shadowCaret);
newRange.extractContents();
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
}, 50);
newRange.selectNode(shadowCaret);
newRange.extractContents();
}
/**
@ -534,7 +569,7 @@ export default class Caret extends Module {
fragment.appendChild(new Text());
}
const lastChild = fragment.lastChild;
const lastChild = fragment.lastChild as ChildNode;
range.deleteContents();
range.insertNode(fragment);
@ -542,7 +577,11 @@ export default class Caret extends Module {
/** Cross-browser caret insertion */
const newRange = document.createRange();
newRange.setStart(lastChild, lastChild.textContent.length);
const nodeToSetCaret = lastChild.nodeType === Node.TEXT_NODE ? lastChild : lastChild.firstChild;
if (nodeToSetCaret !== null && nodeToSetCaret.textContent !== null) {
newRange.setStart(nodeToSetCaret, nodeToSetCaret.textContent.length);
}
selection.removeAllRanges();
selection.addRange(newRange);

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;
}
/**
@ -130,11 +130,6 @@ export default class CrossBlockSelection extends Module {
default:
Caret.setToBlock(BlockManager.blocks[Math.max(fIndex, lIndex)], Caret.positions.END);
}
} else {
/**
* By default set caret at the end of the last selected block
*/
Caret.setToBlock(BlockManager.blocks[Math.max(fIndex, lIndex)], Caret.positions.END);
}
}
@ -187,6 +182,13 @@ export default class CrossBlockSelection extends Module {
private onMouseOver = (event: MouseEvent): void => {
const { BlockManager, BlockSelection } = this.Editor;
/**
* Probably, editor is not initialized yet
*/
if (event.relatedTarget === null && event.target === null) {
return;
}
const relatedBlock = BlockManager.getBlockByChildNode(event.relatedTarget as Node) || this.lastSelectedBlock;
const targetBlock = BlockManager.getBlockByChildNode(event.target as Node);

View file

@ -479,9 +479,14 @@ export default class Paste extends Module {
private handlePasteEvent = async (event: ClipboardEvent): Promise<void> => {
const { BlockManager, Toolbar } = this.Editor;
/**
* When someone pasting into a block, its more stable to set current block by event target, instead of relying on current block set before
*/
const currentBlock = BlockManager.setCurrentBlockByChildNode(event.target as HTMLElement);
/** If target is native input or is not Block, use browser behaviour */
if (
!BlockManager.currentBlock || (this.isNativeBehaviour(event.target) && !event.clipboardData.types.includes('Files'))
!currentBlock || (this.isNativeBehaviour(event.target) && !event.clipboardData.types.includes('Files'))
) {
return;
}
@ -489,14 +494,13 @@ export default class Paste extends Module {
/**
* If Tools is in list of errors, skip processing of paste event
*/
if (BlockManager.currentBlock && this.exceptionList.includes(BlockManager.currentBlock.name)) {
if (currentBlock && this.exceptionList.includes(currentBlock.name)) {
return;
}
event.preventDefault();
this.processDataTransfer(event.clipboardData);
BlockManager.clearFocused();
Toolbar.close();
};

View file

@ -1,119 +1,111 @@
import Module from '../__module';
import * as _ from '../utils';
import { OutputBlockData } from '../../../types';
import BlockTool from '../tools/block';
import type { BlockId, BlockToolData, OutputBlockData } from '../../../types';
import type BlockTool from '../tools/block';
import type { StubData } from '../../tools/stub';
import Block from '../block';
/**
* Editor.js Renderer Module
*
* @module Renderer
* @author CodeX Team
* @version 2.0.0
* Module that responsible for rendering Blocks on editor initialization
*/
export default class Renderer extends Module {
/**
* @typedef {object} RendererBlocks
* @property {string} type - tool name
* @property {object} data - tool data
*/
/**
* @example
* Renders passed blocks as one batch
*
* blocks: [
* {
* id : 'oDe-EVrGWA',
* type : 'paragraph',
* data : {
* text : 'Hello from Codex!'
* }
* },
* {
* id : 'Ld5BJjJCHs',
* type : 'paragraph',
* data : {
* text : 'Leave feedback if you like it!'
* }
* },
* ]
* @param blocksData - blocks to render
*/
public async render(blocksData: OutputBlockData[]): Promise<void> {
return new Promise((resolve) => {
const { Tools, BlockManager } = this.Editor;
/**
* Make plugin blocks from array of plugin`s data
*
* @param {OutputBlockData[]} blocks - blocks to render
*/
public async render(blocks: OutputBlockData[]): Promise<void> {
const chainData = blocks.map((block) => ({ function: (): Promise<void> => this.insertBlock(block) }));
if (blocksData.length === 0) {
BlockManager.insert();
} else {
/**
* Create Blocks instances
*/
const blocks = blocksData.map(({ type: tool, data, tunes, id }) => {
if (Tools.available.has(tool) === false) {
_.logLabeled(`Tool «${tool}» is not found. Check 'tools' property at the Editor.js config.`, 'warn');
/**
* Disable onChange callback on render to not to spam those events
*/
this.Editor.ModificationsObserver.disable();
data = this.composeStubDataForTool(tool, data, id);
tool = Tools.stubTool;
}
const sequence = await _.sequence(chainData as _.ChainData[]);
let block: Block;
this.Editor.ModificationsObserver.enable();
try {
block = BlockManager.composeBlock({
id,
tool,
data,
tunes,
});
} catch (error) {
_.log(`Block «${tool}» skipped because of plugins error`, 'error', {
data,
error,
});
this.Editor.UI.checkEmptiness();
/**
* If tool throws an error during render, we should render stub instead of it
*/
data = this.composeStubDataForTool(tool, data, id);
tool = Tools.stubTool;
return sequence;
block = BlockManager.composeBlock({
id,
tool,
data,
tunes,
});
}
return block;
});
/**
* Insert batch of Blocks
*/
BlockManager.insertMany(blocks);
}
/**
* Wait till browser will render inserted Blocks and resolve a promise
*/
window.requestIdleCallback(() => {
resolve();
}, { timeout: 2000 });
});
}
/**
* Get plugin instance
* Add plugin instance to BlockManager
* Insert block to working zone
* Create data for the Stub Tool that will be used instead of unavailable tool
*
* @param {object} item - Block data to insert
* @returns {Promise<void>}
* @param tool - unavailable tool name to stub
* @param data - data of unavailable block
* @param [id] - id of unavailable block
*/
public async insertBlock(item: OutputBlockData): Promise<void> {
const { Tools, BlockManager } = this.Editor;
const { type: tool, data, tunes, id } = item;
private composeStubDataForTool(tool: string, data: BlockToolData, id?: BlockId): StubData {
const { Tools } = this.Editor;
if (Tools.available.has(tool)) {
try {
BlockManager.insert({
id,
tool,
data,
tunes,
});
} catch (error) {
_.log(`Block «${tool}» skipped because of plugins error`, 'warn', {
data,
error,
});
throw Error(error);
let title = tool;
if (Tools.unavailable.has(tool)) {
const toolboxSettings = (Tools.unavailable.get(tool) as BlockTool).toolbox;
if (toolboxSettings !== undefined && toolboxSettings[0].title !== undefined) {
title = toolboxSettings[0].title;
}
} else {
/** If Tool is unavailable, create stub Block for it */
const stubData = {
savedData: {
id,
type: tool,
data,
},
title: tool,
};
if (Tools.unavailable.has(tool)) {
const toolboxSettings = (Tools.unavailable.get(tool) as BlockTool).toolbox;
const toolboxTitle = toolboxSettings[0]?.title;
stubData.title = toolboxTitle || stubData.title;
}
const stub = BlockManager.insert({
id,
tool: Tools.stubTool,
data: stubData,
});
stub.stretched = true;
_.log(`Tool «${tool}» is not found. Check 'tools' property at your initial Editor.js config.`, 'warn');
}
return {
savedData: {
id,
type: tool,
data,
},
title,
};
}
}

View file

@ -70,26 +70,11 @@ export default class Saver extends Module {
* @returns {OutputData}
*/
private makeOutput(allExtractedData): OutputData {
let totalTime = 0;
const blocks = [];
_.log('[Editor.js saving]:', 'groupCollapsed');
allExtractedData.forEach(({ id, tool, data, tunes, time, isValid }) => {
totalTime += time;
/**
* Capitalize Tool name
*/
_.log(`${tool.charAt(0).toUpperCase() + tool.slice(1)}`, 'group');
if (isValid) {
/** Group process info */
_.log(data);
_.log(undefined, 'groupEnd');
} else {
allExtractedData.forEach(({ id, tool, data, tunes, isValid }) => {
if (!isValid) {
_.log(`Block «${tool}» skipped because saved data is invalid`);
_.log(undefined, 'groupEnd');
return;
}
@ -113,9 +98,6 @@ export default class Saver extends Module {
blocks.push(output);
});
_.log('Total', 'log', totalTime);
_.log(undefined, 'groupEnd');
return {
time: +new Date(),
blocks,

View file

@ -7,13 +7,22 @@ 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 } 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
*/
interface BlockSettingsNodes {
wrapper: HTMLElement;
/**
* Block Settings wrapper. Undefined when before "make" method called
*/
wrapper: HTMLElement | undefined;
}
/**
@ -24,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 {
@ -53,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;
}
/**
@ -64,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:
@ -75,6 +86,12 @@ export default class BlockSettings extends Module<BlockSettingsNodes> {
*/
public make(): void {
this.nodes.wrapper = $.make('div', [ this.CSS.settings ]);
if (import.meta.env.MODE === 'test') {
this.nodes.wrapper.setAttribute('data-cy', 'block-tunes');
}
this.eventsDispatcher.on(EditorMobileLayoutToggled, this.close);
}
/**
@ -82,6 +99,8 @@ export default class BlockSettings extends Module<BlockSettingsNodes> {
*/
public destroy(): void {
this.removeAllNodes();
this.listeners.destroy();
this.eventsDispatcher.off(EditorMobileLayoutToggled, this.close);
}
/**
@ -89,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;
/**
@ -101,21 +120,22 @@ export default class BlockSettings extends Module<BlockSettingsNodes> {
/**
* Highlight content of a Block we are working with
*/
targetBlock.selected = true;
this.Editor.BlockSelection.selectBlock(targetBlock);
this.Editor.BlockSelection.clearCache();
/**
* Fill Tool's settings
*/
const [tunesItems, customHtmlTunesContainer] = targetBlock.getTunes();
/** Get tool's settings data */
const { toolTunes, commonTunes, customHtmlTunes } = targetBlock.getTunes();
/** Tell to subscribers that block settings is opened */
this.eventsDispatcher.emit(this.events.opened);
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),
customContent: customHtmlTunes,
customContentFlippableItems: this.getControls(customHtmlTunes),
scopeElement: this.Editor.API.methods.ui.nodes.redactor,
messages: {
nothingFound: I18n.ui(I18nInternalNS.ui.popover, 'Nothing found'),
@ -125,7 +145,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();
}
@ -133,14 +153,18 @@ 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;
}
this.opened = false;
/**
@ -160,7 +184,7 @@ export default class BlockSettings extends Module<BlockSettingsNodes> {
* Remove highlighted content of a Block we are working with
*/
if (!this.Editor.CrossBlockSelection.isCrossBlockSelectionStarted && this.Editor.BlockManager.currentBlock) {
this.Editor.BlockManager.currentBlock.selected = false;
this.Editor.BlockSelection.unselectBlock(this.Editor.BlockManager.currentBlock);
}
/** Tell to subscribers that block settings is closed */
@ -172,6 +196,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: 'separator',
});
}
const convertToItems = await this.getConvertToItems(currentBlock);
if (convertToItems.length > 0) {
items.push({
icon: IconReplace,
title: I18n.ui(I18nInternalNS.ui.popover, 'Convert to'),
children: {
items: convertToItems,
},
});
items.push({
type: 'separator',
});
}
items.push(...commonTunes);
return items.map(tune => this.resolveTuneAliases(tune));
}
/**
* Returns list of all available conversion menu items
*
* @param currentBlock - block we are about to open block tunes for
*/
private async getConvertToItems(currentBlock: Block): Promise<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;
}
/**
@ -201,7 +334,10 @@ export default class BlockSettings extends Module<BlockSettingsNodes> {
*
* @param item - item with resolved aliases
*/
private resolveTuneAliases(item: TunesMenuConfigItem): TunesMenuConfigItem {
private resolveTuneAliases(item: TunesMenuConfigItem): PopoverItemParams {
if (item.type === 'separator') {
return item;
}
const result = resolveAliases(item, { label: 'title' });
if (item.confirmation) {

View file

@ -1,11 +1,9 @@
import Module from '../../__module';
import $ from '../../dom';
import * as _ from '../../utils';
import { SavedData } from '../../../../types/data-formats';
import Flipper from '../../flipper';
import I18n from '../../i18n';
import { I18nInternalNS } from '../../i18n/namespace-internal';
import { clean } from '../../utils/sanitizer';
import { ToolboxConfigEntry, BlockToolData } from '../../../../types';
/**
@ -34,6 +32,7 @@ export default class ConversionToolbar extends Module<ConversionToolbarNodes> {
conversionTool: 'ce-conversion-tool',
conversionToolHidden: 'ce-conversion-tool--hidden',
conversionToolIcon: 'ce-conversion-tool__icon',
conversionToolSecondaryLabel: 'ce-conversion-tool__secondary-label',
conversionToolFocused: 'ce-conversion-tool--focused',
conversionToolActive: 'ce-conversion-tool--active',
@ -179,90 +178,19 @@ export default class ConversionToolbar extends Module<ConversionToolbarNodes> {
* For that Tools must provide import/export methods
*
* @param {string} replacingToolName - name of Tool which replaces current
* @param blockDataOverrides - Block data overrides. Could be passed in case if Multiple Toolbox items specified
* @param blockDataOverrides - If this conversion fired by the one of multiple Toolbox items, extend converted data with this item's "data" overrides
*/
public async replaceWithBlock(replacingToolName: string, blockDataOverrides?: BlockToolData): Promise<void> {
/**
* At first, we get current Block data
*/
const currentBlockTool = this.Editor.BlockManager.currentBlock.tool;
const savedBlock = await this.Editor.BlockManager.currentBlock.save() as SavedData;
const blockData = savedBlock.data;
const { BlockManager, BlockSelection, InlineToolbar, Caret } = this.Editor;
/**
* Getting a class of replacing Tool
*/
const replacingTool = this.Editor.Tools.blockTools.get(replacingToolName);
const newBlock = await BlockManager.convert(this.Editor.BlockManager.currentBlock, replacingToolName, blockDataOverrides);
/**
* Export property can be:
* 1) Function Tool defines which data to return
* 2) String the name of saved property
*
* In both cases returning value must be a string
*/
let exportData = '';
const exportProp = currentBlockTool.conversionConfig.export;
if (_.isFunction(exportProp)) {
exportData = exportProp(blockData);
} else if (_.isString(exportProp)) {
exportData = blockData[exportProp];
} else {
_.log('Conversion «export» property must be a string or function. ' +
'String means key of saved data object to export. Function should export processed string to export.');
return;
}
/**
* Clean exported data with replacing sanitizer config
*/
const cleaned: string = clean(
exportData,
replacingTool.sanitizeConfig
);
/**
* «import» property can be Function or String
* function accept imported string and compose tool data object
* string the name of data field to import
*/
let newBlockData = {};
const importProp = replacingTool.conversionConfig.import;
if (_.isFunction(importProp)) {
newBlockData = importProp(cleaned);
} else if (_.isString(importProp)) {
newBlockData[importProp] = cleaned;
} else {
_.log('Conversion «import» property must be a string or function. ' +
'String means key of tool data to import. Function accepts a imported string and return composed tool data.');
return;
}
/**
* If this conversion fired by the one of multiple Toolbox items,
* extend converted data with this item's "data" overrides
*/
if (blockDataOverrides) {
newBlockData = Object.assign(newBlockData, blockDataOverrides);
}
this.Editor.BlockManager.replace({
tool: replacingToolName,
data: newBlockData,
});
this.Editor.BlockSelection.clearSelection();
BlockSelection.clearSelection();
this.close();
this.Editor.InlineToolbar.close();
InlineToolbar.close();
_.delay(() => {
this.Editor.Caret.setToBlock(this.Editor.BlockManager.currentBlock);
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
}, 10)();
Caret.setToBlock(newBlock, Caret.positions.END);
}
/**
@ -283,7 +211,7 @@ export default class ConversionToolbar extends Module<ConversionToolbarNodes> {
if (!conversionConfig || !conversionConfig.import) {
return;
}
tool.toolbox.forEach((toolboxItem) =>
tool.toolbox?.forEach((toolboxItem) =>
this.addToolIfValid(name, toolboxItem)
);
});
@ -322,6 +250,16 @@ export default class ConversionToolbar extends Module<ConversionToolbarNodes> {
$.append(tool, icon);
$.append(tool, $.text(I18n.t(I18nInternalNS.toolNames, toolboxItem.title || _.capitalize(toolName))));
const shortcut = this.Editor.Tools.blockTools.get(toolName)?.shortcut;
if (shortcut) {
const shortcutEl = $.make('span', ConversionToolbar.CSS.conversionToolSecondaryLabel, {
innerText: _.beautifyShortcut(shortcut),
});
$.append(tool, shortcutEl);
}
$.append(this.nodes.tools, tool);
this.tools.push({
name: toolName,

View file

@ -3,12 +3,14 @@ import $ from '../../dom';
import * as _ from '../../utils';
import I18n from '../../i18n';
import { I18nInternalNS } from '../../i18n/namespace-internal';
import Tooltip from '../../utils/tooltip';
import * as tooltip from '../../utils/tooltip';
import { ModuleConfig } from '../../../types-internal/module-config';
import Block from '../../block';
import Toolbox, { ToolboxEvent } from '../../ui/toolbox';
import { IconMenu, IconPlus } from '@codexteam/icons';
import { BlockHovered } from '../../events/BlockHovered';
import { beautifyShortcut } from '../../utils';
import { getKeyboardKeyForCode } from '../../utils/keyboard';
/**
* @todo Tab on non-empty block should open Block Settings of the hoveredBlock (not where caret is set)
@ -32,12 +34,12 @@ import { BlockHovered } from '../../events/BlockHovered';
* HTML Elements used for Toolbar UI
*/
interface ToolbarNodes {
wrapper: HTMLElement;
content: HTMLElement;
actions: HTMLElement;
wrapper: HTMLElement | undefined;
content: HTMLElement | undefined;
actions: HTMLElement | undefined;
plusButton: HTMLElement;
settingsToggler: HTMLElement;
plusButton: HTMLElement | undefined;
settingsToggler: HTMLElement | undefined;
}
/**
*
@ -91,11 +93,6 @@ interface ToolbarNodes {
* @property {Element} nodes.defaultSettings - Default Settings section of Settings Panel
*/
export default class Toolbar extends Module<ToolbarNodes> {
/**
* Tooltip utility Instance
*/
private tooltip: Tooltip;
/**
* Block near which we display the Toolbox
*/
@ -103,8 +100,9 @@ export default class Toolbar extends Module<ToolbarNodes> {
/**
* Toolbox class instance
* It will be created in requestIdleCallback so it can be null in some period of time
*/
private toolboxInstance: Toolbox;
private toolboxInstance: Toolbox | null = null;
/**
* @class
@ -117,7 +115,6 @@ export default class Toolbar extends Module<ToolbarNodes> {
config,
eventsDispatcher,
});
this.tooltip = new Tooltip();
}
/**
@ -155,18 +152,27 @@ export default class Toolbar extends Module<ToolbarNodes> {
* Public interface for accessing the Toolbox
*/
public get toolbox(): {
opened: boolean;
opened: boolean | undefined; // undefined is for the case when Toolbox is not initialized yet
close: () => void;
open: () => void;
toggle: () => void;
hasFocus: () => boolean;
hasFocus: () => boolean | undefined;
} {
return {
opened: this.toolboxInstance.opened,
close: (): void => {
this.toolboxInstance.close();
opened: this.toolboxInstance?.opened,
close: () => {
this.toolboxInstance?.close();
},
open: (): void => {
open: () => {
/**
* If Toolbox is not initialized yet, do nothing
*/
if (this.toolboxInstance === null) {
_.log('toolbox.open() called before initialization is finished', 'warn');
return;
}
/**
* Set current block to cover the case when the Toolbar showed near hovered Block but caret is set to another Block.
*/
@ -174,8 +180,19 @@ export default class Toolbar extends Module<ToolbarNodes> {
this.toolboxInstance.open();
},
toggle: (): void => this.toolboxInstance.toggle(),
hasFocus: (): boolean => this.toolboxInstance.hasFocus(),
toggle: () => {
/**
* If Toolbox is not initialized yet, do nothing
*/
if (this.toolboxInstance === null) {
_.log('toolbox.toggle() called before initialization is finished', 'warn');
return;
}
this.toolboxInstance.toggle();
},
hasFocus: () => this.toolboxInstance?.hasFocus(),
};
}
@ -203,6 +220,7 @@ export default class Toolbar extends Module<ToolbarNodes> {
};
}
/**
* Toggles read-only mode
*
@ -210,8 +228,10 @@ export default class Toolbar extends Module<ToolbarNodes> {
*/
public toggleReadOnly(readOnlyEnabled: boolean): void {
if (!readOnlyEnabled) {
this.drawUI();
this.enableModuleBindings();
window.requestIdleCallback(() => {
this.drawUI();
this.enableModuleBindings();
}, { timeout: 2000 });
} else {
this.destroy();
this.Editor.BlockSettings.destroy();
@ -225,6 +245,15 @@ export default class Toolbar extends Module<ToolbarNodes> {
* @param block - block to move Toolbar near it
*/
public moveAndOpen(block: Block = this.Editor.BlockManager.currentBlock): void {
/**
* Some UI elements creates inside requestIdleCallback, so the can be not ready yet
*/
if (this.toolboxInstance === null) {
_.log('Can\'t open Toolbar since Editor initialization is not finished yet', 'warn');
return;
}
/**
* Close Toolbox when we move toolbar
*/
@ -290,12 +319,20 @@ export default class Toolbar extends Module<ToolbarNodes> {
return;
}
this.nodes.wrapper.classList.remove(this.CSS.toolbarOpened);
this.nodes.wrapper?.classList.remove(this.CSS.toolbarOpened);
/** Close components */
this.blockActions.hide();
this.toolboxInstance.close();
this.toolboxInstance?.close();
this.Editor.BlockSettings.close();
this.reset();
}
/**
* Reset the Toolbar position to prevent DOM height growth, for example after blocks deletion
*/
private reset(): void {
this.nodes.wrapper.style.top = 'unset';
}
/**
@ -305,23 +342,23 @@ export default class Toolbar extends Module<ToolbarNodes> {
* This flag allows to open Toolbar without Actions.
*/
private open(withBlockActions = true): void {
_.delay(() => {
this.nodes.wrapper.classList.add(this.CSS.toolbarOpened);
this.nodes.wrapper.classList.add(this.CSS.toolbarOpened);
if (withBlockActions) {
this.blockActions.show();
} else {
this.blockActions.hide();
}
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
}, 50)();
if (withBlockActions) {
this.blockActions.show();
} else {
this.blockActions.hide();
}
}
/**
* Draws Toolbar elements
*/
private make(): void {
private async make(): Promise<void> {
this.nodes.wrapper = $.make('div', this.CSS.toolbar);
/**
* @todo detect test environment and add data-cy="toolbar" to use it in tests instead of class name
*/
/**
* Make Content Zone and Actions Zone
@ -347,7 +384,7 @@ export default class Toolbar extends Module<ToolbarNodes> {
$.append(this.nodes.actions, this.nodes.plusButton);
this.readOnlyMutableListeners.on(this.nodes.plusButton, 'click', () => {
this.tooltip.hide(true);
tooltip.hide(true);
this.plusButtonClicked();
}, false);
@ -358,10 +395,10 @@ export default class Toolbar extends Module<ToolbarNodes> {
tooltipContent.appendChild(document.createTextNode(I18n.ui(I18nInternalNS.ui.toolbar.toolbox, 'Add')));
tooltipContent.appendChild($.make('div', this.CSS.plusButtonShortcut, {
textContent: '⇥ Tab',
textContent: '/',
}));
this.tooltip.onHover(this.nodes.plusButton, tooltipContent, {
tooltip.onHover(this.nodes.plusButton, tooltipContent, {
hidingDelay: 400,
});
@ -377,13 +414,18 @@ export default class Toolbar extends Module<ToolbarNodes> {
$.append(this.nodes.actions, this.nodes.settingsToggler);
this.tooltip.onHover(
this.nodes.settingsToggler,
I18n.ui(I18nInternalNS.ui.blockTunes.toggler, 'Click to tune'),
{
hidingDelay: 400,
}
);
const blockTunesTooltip = $.make('div');
const blockTunesTooltipEl = $.text(I18n.ui(I18nInternalNS.ui.blockTunes.toggler, 'Click to tune'));
const slashRealKey = await getKeyboardKeyForCode('Slash', '/');
blockTunesTooltip.appendChild(blockTunesTooltipEl);
blockTunesTooltip.appendChild($.make('div', this.CSS.plusButtonShortcut, {
textContent: beautifyShortcut(`CMD + ${slashRealKey}`),
}));
tooltip.onHover(this.nodes.settingsToggler, blockTunesTooltip, {
hidingDelay: 400,
});
/**
* Appending Toolbar components to itself
@ -438,9 +480,10 @@ export default class Toolbar extends Module<ToolbarNodes> {
}
});
return this.toolboxInstance.make();
return this.toolboxInstance.getElement();
}
/**
* Handler for Plus Button
*/
@ -451,7 +494,7 @@ export default class Toolbar extends Module<ToolbarNodes> {
*/
this.Editor.BlockManager.currentBlock = this.hoveredBlock;
this.toolboxInstance.toggle();
this.toolboxInstance?.toggle();
}
/**
@ -473,11 +516,11 @@ export default class Toolbar extends Module<ToolbarNodes> {
this.settingsTogglerClicked();
if (this.toolboxInstance.opened) {
if (this.toolboxInstance?.opened) {
this.toolboxInstance.close();
}
this.tooltip.hide(true);
tooltip.hide(true);
}, true);
/**
@ -493,7 +536,7 @@ export default class Toolbar extends Module<ToolbarNodes> {
/**
* Do not move toolbar if Block Settings or Toolbox opened
*/
if (this.Editor.BlockSettings.opened || this.toolboxInstance.opened) {
if (this.Editor.BlockSettings.opened || this.toolboxInstance?.opened) {
return;
}
@ -546,7 +589,7 @@ export default class Toolbar extends Module<ToolbarNodes> {
/**
* Make Toolbar
*/
this.make();
void this.make();
}
/**
@ -558,6 +601,5 @@ export default class Toolbar extends Module<ToolbarNodes> {
if (this.toolboxInstance) {
this.toolboxInstance.destroy();
}
this.tooltip.destroy();
}
}

View file

@ -7,7 +7,7 @@ import Flipper from '../../flipper';
import I18n from '../../i18n';
import { I18nInternalNS } from '../../i18n/namespace-internal';
import Shortcuts from '../../utils/shortcuts';
import Tooltip from '../../utils/tooltip';
import * as tooltip from '../../utils/tooltip';
import { ModuleConfig } from '../../../types-internal/module-config';
import InlineTool from '../../tools/inline';
import { CommonInternalSettings } from '../../tools/base';
@ -17,16 +17,16 @@ import { IconChevronDown } from '@codexteam/icons';
* Inline Toolbar elements
*/
interface InlineToolbarNodes {
wrapper: HTMLElement;
togglerAndButtonsWrapper: HTMLElement;
buttons: HTMLElement;
conversionToggler: HTMLElement;
conversionTogglerContent: HTMLElement;
wrapper: HTMLElement | undefined;
togglerAndButtonsWrapper: HTMLElement | undefined;
buttons: HTMLElement | undefined;
conversionToggler: HTMLElement | undefined;
conversionTogglerContent: HTMLElement | undefined;
/**
* Zone below the buttons where Tools can create additional actions by 'renderActions()' method
* For example, input for the 'link' tool or textarea for the 'comment' tool
*/
actions: HTMLElement;
actions: HTMLElement | undefined;
}
/**
@ -97,10 +97,6 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
*/
private flipper: Flipper = null;
/**
* Tooltip utility Instance
*/
private tooltip: Tooltip;
/**
* @class
* @param moduleConfiguration - Module Configuration
@ -112,7 +108,6 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
config,
eventsDispatcher,
});
this.tooltip = new Tooltip();
}
/**
@ -122,7 +117,9 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
*/
public toggleReadOnly(readOnlyEnabled: boolean): void {
if (!readOnlyEnabled) {
this.make();
window.requestIdleCallback(() => {
this.make();
}, { timeout: 2000 });
} else {
this.destroy();
this.Editor.ConversionToolbar.destroy();
@ -141,66 +138,21 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
* Avoid to use it just for closing IT, better call .close() clearly.
* @param [needToShowConversionToolbar] - pass false to not to show Conversion Toolbar
*/
public tryToShow(needToClose = false, needToShowConversionToolbar = true): void {
if (!this.allowedToShow()) {
if (needToClose) {
this.close();
}
public async tryToShow(needToClose = false, needToShowConversionToolbar = true): Promise<void> {
if (needToClose) {
this.close();
}
if (!this.allowedToShow()) {
return;
}
await this.addToolsFiltered(needToShowConversionToolbar);
this.move();
this.open(needToShowConversionToolbar);
this.Editor.Toolbar.close();
}
/**
* Move Toolbar to the selected text
*/
public move(): void {
const selectionRect = SelectionUtils.rect as DOMRect;
const wrapperOffset = this.Editor.UI.nodes.wrapper.getBoundingClientRect();
const newCoords = {
x: selectionRect.x - wrapperOffset.left,
y: selectionRect.y +
selectionRect.height -
// + window.scrollY
wrapperOffset.top +
this.toolbarVerticalMargin,
};
/**
* If we know selections width, place InlineToolbar to center
*/
if (selectionRect.width) {
newCoords.x += Math.floor(selectionRect.width / 2);
}
/**
* Inline Toolbar has -50% translateX, so we need to check real coords to prevent overflowing
*/
const realLeftCoord = newCoords.x - this.width / 2;
const realRightCoord = newCoords.x + this.width / 2;
/**
* By default, Inline Toolbar has top-corner at the center
* We are adding a modifiers for to move corner to the left or right
*/
this.nodes.wrapper.classList.toggle(
this.CSS.inlineToolbarLeftOriented,
realLeftCoord < this.Editor.UI.contentRect.left
);
this.nodes.wrapper.classList.toggle(
this.CSS.inlineToolbarRightOriented,
realRightCoord > this.Editor.UI.contentRect.right
);
this.nodes.wrapper.style.left = Math.floor(newCoords.x) + 'px';
this.nodes.wrapper.style.top = Math.floor(newCoords.y) + 'px';
}
/**
* Hides Inline Toolbar
*/
@ -229,63 +181,23 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
}
});
this.reset();
this.opened = false;
this.flipper.deactivate();
this.Editor.ConversionToolbar.close();
}
/**
* Shows Inline Toolbar
*
* @param [needToShowConversionToolbar] - pass false to not to show Conversion Toolbar
*/
public open(needToShowConversionToolbar = true): void {
if (this.opened) {
return;
}
/**
* Filter inline-tools and show only allowed by Block's Tool
*/
this.addToolsFiltered();
/**
* Show Inline Toolbar
*/
this.nodes.wrapper.classList.add(this.CSS.inlineToolbarShowed);
this.buttonsList = this.nodes.buttons.querySelectorAll(`.${this.CSS.inlineToolButton}`);
this.opened = true;
if (needToShowConversionToolbar && this.Editor.ConversionToolbar.hasTools()) {
/**
* Change Conversion Dropdown content for current tool
*/
this.setConversionTogglerContent();
} else {
/**
* hide Conversion Dropdown with there are no tools
*/
this.nodes.conversionToggler.hidden = true;
}
/**
* Get currently visible buttons to pass it to the Flipper
*/
let visibleTools = Array.from(this.buttonsList);
visibleTools.unshift(this.nodes.conversionToggler);
visibleTools = visibleTools.filter((tool) => !(tool as HTMLElement).hidden);
this.flipper.activate(visibleTools as HTMLElement[]);
}
/**
* Check if node is contained by Inline Toolbar
*
* @param {Node} node node to check
*/
public containsNode(node: Node): boolean {
if (this.nodes.wrapper === undefined) {
return false;
}
return this.nodes.wrapper.contains(node);
}
@ -302,7 +214,6 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
}
this.removeAllNodes();
this.tooltip.destroy();
}
/**
@ -313,6 +224,11 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
this.CSS.inlineToolbar,
...(this.isRtl ? [ this.Editor.UI.CSS.editorRtlFix ] : []),
]);
if (import.meta.env.MODE === 'test') {
this.nodes.wrapper.setAttribute('data-cy', 'inline-toolbar');
}
/**
* Creates a different wrapper for toggler and buttons.
*/
@ -359,8 +275,11 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
/**
* Recalculate initial width with all buttons
* We use RIC to prevent forced layout during editor initialization to make it faster
*/
this.recalculateWidth();
window.requestAnimationFrame(() => {
this.recalculateWidth();
});
/**
* Allow to leaf buttons by arrows / tab
@ -369,6 +288,74 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
this.enableFlipper();
}
/**
* Shows Inline Toolbar
*/
private open(): void {
if (this.opened) {
return;
}
/**
* Show Inline Toolbar
*/
this.nodes.wrapper.classList.add(this.CSS.inlineToolbarShowed);
this.buttonsList = this.nodes.buttons.querySelectorAll(`.${this.CSS.inlineToolButton}`);
this.opened = true;
/**
* Get currently visible buttons to pass it to the Flipper
*/
let visibleTools = Array.from(this.buttonsList);
visibleTools.unshift(this.nodes.conversionToggler);
visibleTools = visibleTools.filter((tool) => !(tool as HTMLElement).hidden);
this.flipper.activate(visibleTools as HTMLElement[]);
}
/**
* Move Toolbar to the selected text
*/
private move(): void {
const selectionRect = SelectionUtils.rect as DOMRect;
const wrapperOffset = this.Editor.UI.nodes.wrapper.getBoundingClientRect();
const newCoords = {
x: selectionRect.x - wrapperOffset.x,
y: selectionRect.y +
selectionRect.height -
// + window.scrollY
wrapperOffset.top +
this.toolbarVerticalMargin,
};
const realRightCoord = newCoords.x + this.width + wrapperOffset.x;
/**
* Prevent InlineToolbar from overflowing the content zone on the right side
*/
if (realRightCoord > this.Editor.UI.contentRect.right) {
newCoords.x = this.Editor.UI.contentRect.right - this.width - wrapperOffset.x;
}
this.nodes.wrapper.style.left = Math.floor(newCoords.x) + 'px';
this.nodes.wrapper.style.top = Math.floor(newCoords.y) + 'px';
}
/**
* Clear orientation classes and reset position
*/
private reset(): void {
this.nodes.wrapper.classList.remove(
this.CSS.inlineToolbarLeftOriented,
this.CSS.inlineToolbarRightOriented
);
this.nodes.wrapper.style.left = '0';
this.nodes.wrapper.style.top = '0';
}
/**
* Need to show Inline Toolbar or not
*/
@ -440,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) => {
/**
@ -460,7 +451,7 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
});
if (_.isMobileScreen() === false ) {
this.tooltip.onHover(this.nodes.conversionToggler, I18n.ui(I18nInternalNS.ui.inlineToolbar.converter, 'Convert to'), {
tooltip.onHover(this.nodes.conversionToggler, I18n.ui(I18nInternalNS.ui.inlineToolbar.converter, 'Convert to'), {
placement: 'top',
hidingDelay: 100,
});
@ -511,8 +502,10 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
/**
* Append only allowed Tools
*
* @param {boolean} needToShowConversionToolbar - pass false to not to show Conversion Toolbar (e.g. for Footnotes-like tools)
*/
private addToolsFiltered(): void {
private async addToolsFiltered(needToShowConversionToolbar = true): Promise<void> {
const currentSelection = SelectionUtils.get();
const currentBlock = this.Editor.BlockManager.getBlock(currentSelection.anchorNode as HTMLElement);
@ -527,6 +520,18 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
this.addTool(tool);
});
if (needToShowConversionToolbar && this.Editor.ConversionToolbar.hasTools()) {
/**
* Change Conversion Dropdown content for current tool
*/
await this.setConversionTogglerContent();
} else {
/**
* hide Conversion Dropdown with there are no tools
*/
this.nodes.conversionToggler.hidden = true;
}
/**
* Recalculate width because some buttons can be hidden
*/
@ -589,7 +594,7 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
}
if (_.isMobileScreen() === false ) {
this.tooltip.onHover(button, tooltipContent, {
tooltip.onHover(button, tooltipContent, {
placement: 'top',
hidingDelay: 100,
});

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
*/
@ -22,7 +24,6 @@ interface UINodes {
holder: HTMLElement;
wrapper: HTMLElement;
redactor: HTMLElement;
loader: HTMLElement;
}
/**
@ -49,14 +50,13 @@ export default class UI extends Module<UINodes> {
*/
public get CSS(): {
editorWrapper: string; editorWrapperNarrow: string; editorZone: string; editorZoneHidden: string;
editorLoader: string; editorEmpty: string; editorRtlFix: string;
editorEmpty: string; editorRtlFix: string;
} {
return {
editorWrapper: 'codex-editor',
editorWrapperNarrow: 'codex-editor--narrow',
editorZone: 'codex-editor__redactor',
editorZoneHidden: 'codex-editor__redactor--hidden',
editorLoader: 'codex-editor__loader',
editorEmpty: 'codex-editor--empty',
editorRtlFix: 'codex-editor--rtl',
};
@ -115,23 +115,6 @@ export default class UI extends Module<UINodes> {
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
}, 200);
/**
* Adds loader to editor while content is not ready
*/
public addLoader(): void {
this.nodes.loader = $.make('div', this.CSS.editorLoader);
this.nodes.wrapper.prepend(this.nodes.loader);
this.nodes.redactor.classList.add(this.CSS.editorZoneHidden);
}
/**
* Removes loader when content has loaded
*/
public removeLoader(): void {
this.nodes.loader.remove();
this.nodes.redactor.classList.remove(this.CSS.editorZoneHidden);
}
/**
* Making main interface
*/
@ -139,18 +122,13 @@ export default class UI extends Module<UINodes> {
/**
* Detect mobile version
*/
this.checkIsMobile();
this.setIsMobile();
/**
* Make main UI elements
*/
this.make();
/**
* Loader for rendering process
*/
this.addLoader();
/**
* Load and append CSS
*/
@ -174,12 +152,20 @@ export default class UI extends Module<UINodes> {
*/
if (!readOnlyEnabled) {
/**
* Unbind all events
* Postpone events binding to the next tick to make sure all ui elements are ready
*/
this.enableModuleBindings();
window.requestIdleCallback(() => {
/**
* Bind events for the UI elements
*/
this.enableModuleBindings();
}, {
timeout: 2000,
});
} else {
/**
* Bind events for the UI elements
* Unbind all events
*
*/
this.disableModuleBindings();
}
@ -249,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;
}
/**
@ -277,6 +274,8 @@ export default class UI extends Module<UINodes> {
/**
* If Editor has injected into the narrow container, enable Narrow Mode
*
* @todo Forced layout. Get rid of this feature
*/
if (this.nodes.holder.offsetWidth < this.contentRect.width) {
this.nodes.wrapper.classList.add(this.CSS.editorWrapperNarrow);
@ -316,6 +315,15 @@ export default class UI extends Module<UINodes> {
textContent: styles.toString(),
});
/**
* If user enabled Content Security Policy, he can pass nonce through the config
*
* @see https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/nonce
*/
if (this.config.style && !_.isEmpty(this.config.style) && this.config.style.nonce) {
tag.setAttribute('nonce', this.config.style.nonce);
}
/**
* Append styles at the top of HEAD tag
*/
@ -332,11 +340,17 @@ export default class UI extends Module<UINodes> {
this.readOnlyMutableListeners.on(this.nodes.redactor, 'mousedown', (event: MouseEvent | TouchEvent) => {
this.documentTouched(event);
}, true);
}, {
capture: true,
passive: true,
});
this.readOnlyMutableListeners.on(this.nodes.redactor, 'touchstart', (event: MouseEvent | TouchEvent) => {
this.documentTouched(event);
}, true);
}, {
capture: true,
passive: true,
});
this.readOnlyMutableListeners.on(document, 'keydown', (event: KeyboardEvent) => {
this.documentKeydown(event);
@ -349,9 +363,11 @@ export default class UI extends Module<UINodes> {
/**
* Handle selection change to manipulate Inline Toolbar appearance
*/
this.readOnlyMutableListeners.on(document, 'selectionchange', () => {
const selectionChangeDebounced = _.debounce(() => {
this.selectionChanged();
}, true);
}, selectionChangeDebounceTimeout);
this.readOnlyMutableListeners.on(document, 'selectionchange', selectionChangeDebounced, true);
this.readOnlyMutableListeners.on(window, 'resize', () => {
this.resizeDebouncer();
@ -422,7 +438,7 @@ export default class UI extends Module<UINodes> {
/**
* Detect mobile version
*/
this.checkIsMobile();
this.setIsMobile();
}
/**
@ -437,6 +453,7 @@ export default class UI extends Module<UINodes> {
break;
case _.keyCodes.BACKSPACE:
case _.keyCodes.DELETE:
this.backspacePressed(event);
break;
@ -500,7 +517,9 @@ export default class UI extends Module<UINodes> {
if (BlockSelection.anyBlockSelected && !Selection.isSelectionExists) {
const selectionPositionIndex = BlockManager.removeSelectedBlocks();
Caret.setToBlock(BlockManager.insertDefaultBlockAtIndex(selectionPositionIndex, true), Caret.positions.START);
const newBlock = BlockManager.insertDefaultBlockAtIndex(selectionPositionIndex, true);
Caret.setToBlock(newBlock, Caret.positions.START);
/** Clear selection */
BlockSelection.clearSelection(event);
@ -530,7 +549,7 @@ export default class UI extends Module<UINodes> {
if (this.Editor.Toolbar.toolbox.opened) {
this.Editor.Toolbar.toolbox.close();
this.Editor.Caret.setToBlock(this.Editor.BlockManager.currentBlock);
this.Editor.Caret.setToBlock(this.Editor.BlockManager.currentBlock, this.Editor.Caret.positions.END);
} else if (this.Editor.BlockSettings.opened) {
this.Editor.BlockSettings.close();
} else if (this.Editor.ConversionToolbar.opened) {
@ -549,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;
/**
@ -584,12 +608,11 @@ export default class UI extends Module<UINodes> {
*/
const newBlock = this.Editor.BlockManager.insert();
this.Editor.Caret.setToBlock(newBlock);
/**
* And highlight
* Prevent default enter behaviour to prevent adding a new line (<div><br></div>) to the inserted block
*/
this.Editor.BlockManager.highlightCurrentNode();
event.preventDefault();
this.Editor.Caret.setToBlock(newBlock);
/**
* Move toolbar and show plus button because new Block is empty
@ -637,8 +660,8 @@ export default class UI extends Module<UINodes> {
* But allow clicking inside Block Settings.
* Also, do not process clicks on the Block Settings Toggler, because it has own click listener
*/
const isClickedInsideBlockSettings = this.Editor.BlockSettings.nodes.wrapper.contains(target);
const isClickedInsideBlockSettingsToggler = this.Editor.Toolbar.nodes.settingsToggler.contains(target);
const isClickedInsideBlockSettings = this.Editor.BlockSettings.nodes.wrapper?.contains(target);
const isClickedInsideBlockSettingsToggler = this.Editor.Toolbar.nodes.settingsToggler?.contains(target);
const doNotProcess = isClickedInsideBlockSettings || isClickedInsideBlockSettingsToggler;
if (this.Editor.BlockSettings.opened && !doNotProcess) {
@ -683,15 +706,7 @@ export default class UI extends Module<UINodes> {
* Select clicked Block as Current
*/
try {
/**
* Renew Current Block
*/
this.Editor.BlockManager.setCurrentBlockByChildNode(clickedNode);
/**
* Highlight Current Node
*/
this.Editor.BlockManager.highlightCurrentNode();
} catch (e) {
/**
* If clicked outside first-level Blocks and it is not RectSelection, set Caret to the last empty Block
@ -718,17 +733,10 @@ export default class UI extends Module<UINodes> {
* - otherwise, add a new empty Block and set a Caret to that
*/
private redactorClicked(event: MouseEvent): void {
const { BlockSelection } = this.Editor;
if (!Selection.isCollapsed) {
return;
}
const stopPropagation = (): void => {
event.stopImmediatePropagation();
event.stopPropagation();
};
/**
* case when user clicks on anchor element
* if it is clicked via ctrl key, then we open new window with url
@ -737,7 +745,8 @@ export default class UI extends Module<UINodes> {
const ctrlKey = event.metaKey || event.ctrlKey;
if ($.isAnchor(element) && ctrlKey) {
stopPropagation();
event.stopImmediatePropagation();
event.stopPropagation();
const href = element.getAttribute('href');
const validUrl = _.getValidUrl(href);
@ -747,10 +756,22 @@ export default class UI extends Module<UINodes> {
return;
}
this.processBottomZoneClick(event);
}
/**
* Check if user clicks on the Editor's bottom zone:
* - set caret to the last block
* - or add new empty block
*
* @param event - click event
*/
private processBottomZoneClick(event: MouseEvent): void {
const lastBlock = this.Editor.BlockManager.getBlockByIndex(-1);
const lastBlockBottomCoord = $.offset(lastBlock.holder).bottom;
const clickedCoord = event.pageY;
const { BlockSelection } = this.Editor;
const isClickedBottom = event.target instanceof Element &&
event.target.isEqualNode(this.nodes.redactor) &&
/**
@ -764,7 +785,8 @@ export default class UI extends Module<UINodes> {
lastBlockBottomCoord < clickedCoord;
if (isClickedBottom) {
stopPropagation();
event.stopImmediatePropagation();
event.stopPropagation();
const { BlockManager, Caret, Toolbar } = this.Editor;
@ -852,9 +874,6 @@ export default class UI extends Module<UINodes> {
const isNeedToShowConversionToolbar = clickedOutsideBlockContent !== true;
/**
* @todo add debounce
*/
this.Editor.InlineToolbar.tryToShow(true, isNeedToShowConversionToolbar);
}
}

View file

@ -136,3 +136,27 @@ if (!Element.prototype.scrollIntoViewIfNeeded) {
}
};
}
/**
* RequestIdleCallback polyfill (shims)
*
* @see https://developer.chrome.com/blog/using-requestidlecallback/
* @param cb - callback to be executed when the browser is idle
*/
window.requestIdleCallback = window.requestIdleCallback || function (cb) {
const start = Date.now();
return setTimeout(function () {
cb({
didTimeout: false,
timeRemaining: function () {
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
return Math.max(0, 50 - (Date.now() - start));
},
});
}, 1);
};
window.cancelIdleCallback = window.cancelIdleCallback || function (id) {
clearTimeout(id);
};

View file

@ -225,7 +225,7 @@ export default class SelectionUtils {
*
* @param selection - Selection object to get Range from
*/
public static getRangeFromSelection(selection: Selection): Range {
public static getRangeFromSelection(selection: Selection): Range | null {
return selection && selection.rangeCount ? selection.getRangeAt(0) : null;
}

View file

@ -136,7 +136,7 @@ export default class BlockTool extends BaseTool<IBlockTool> {
/**
* Returns Tool conversion configuration
*/
public get conversionConfig(): ConversionConfig {
public get conversionConfig(): ConversionConfig | undefined {
return this.constructable[InternalBlockToolSettings.ConversionConfig];
}

View file

@ -5,9 +5,13 @@ import BlockTool from '../tools/block';
import ToolsCollection from '../tools/collection';
import { API, BlockToolData, ToolboxConfigEntry, PopoverItem, BlockAPI } from '../../../types';
import EventsDispatcher from '../utils/events';
import Popover, { PopoverEvent } from '../utils/popover';
import I18n from '../i18n';
import { I18nInternalNS } from '../i18n/namespace-internal';
import { PopoverEvent } from '../utils/popover/popover.types';
import Listeners from '../utils/listeners';
import Dom from '../dom';
import { Popover, PopoverDesktop, PopoverMobile } from '../utils/popover';
import { EditorMobileLayoutToggled } from '../events';
/**
* @todo the first Tab on the Block focus Plus Button, the second focus Block Tunes Toggler, the third focus next Block
@ -75,6 +79,11 @@ export default class Toolbox extends EventsDispatcher<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,32 +136,26 @@ export default class Toolbox extends EventsDispatcher<ToolboxEventMap> {
this.api = api;
this.tools = tools;
this.i18nLabels = i18nLabels;
this.enableShortcuts();
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);
}
/**
* Makes the Toolbox
* Returns root block settings element
*/
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);
public getElement(): HTMLElement | null {
return this.nodes.toolbox;
}
@ -161,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;
}
/**
@ -172,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);
}
/**
@ -222,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
*/
@ -305,8 +356,26 @@ 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();
const currentBlock = this.api.blocks.getBlockByIndex(currentBlockIndex);
/**
* Try to convert current Block to shortcut's tool
* If conversion is not possible, insert a new Block below
*/
if (currentBlock) {
try {
const newBlock = await this.api.blocks.convert(currentBlock.id, toolName);
this.api.caret.setToBlock(newBlock, 'end');
return;
} catch (error) {}
}
this.insertNewBlock(toolName);
},
});

View file

@ -56,6 +56,7 @@ export const keyCodes = {
RIGHT: 39,
DELETE: 46,
META: 91,
SLASH: 191,
};
/**
@ -311,6 +312,7 @@ export function isPrintableKey(keyCode: number): boolean {
* @param {Function} success - success callback
* @param {Function} fallback - callback that fires in case of errors
* @returns {Promise}
* @deprecated use PromiseQueue.ts instead
*/
export async function sequence(
chains: ChainData[],

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

@ -0,0 +1,121 @@
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, 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.
*
* 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 {
/**
* 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');
}
/**
* Using conversionConfig, convert block data to string.
*
* @param blockData - block data to convert
* @param conversionConfig - tool's conversion config
*/
export function convertBlockDataToString(blockData: BlockToolData, conversionConfig?: ConversionConfig ): string {
const exportProp = conversionConfig?.export;
if (isFunction(exportProp)) {
return exportProp(blockData);
} else if (isString(exportProp)) {
return blockData[exportProp];
} else {
/**
* Tool developer provides 'export' property, but it is not correct. Warn him.
*/
if (exportProp !== undefined) {
log('Conversion «export» property must be a string or function. ' +
'String means key of saved data object to export. Function should export processed string to export.');
}
return '';
}
}
/**
* Using conversionConfig, convert string to block data.
*
* @param stringToImport - string to convert
* @param conversionConfig - tool's conversion config
*/
export function convertStringToBlockData(stringToImport: string, conversionConfig?: ConversionConfig): BlockToolData {
const importProp = conversionConfig?.import;
if (isFunction(importProp)) {
return importProp(stringToImport);
} else if (isString(importProp)) {
return {
[importProp]: stringToImport,
};
} else {
/**
* Tool developer provides 'import' property, but it is not correct. Warn him.
*/
if (importProp !== undefined) {
log('Conversion «import» property must be a string or function. ' +
'String means key of tool data to import. Function accepts a imported string and return composed tool data.');
}
return {};
}
}

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
@ -94,6 +94,12 @@ export default class EventsDispatcher<EventMap> {
* @param callback - event handler
*/
public off<Name extends keyof EventMap>(eventName: Name, callback: Listener<EventMap[Name]>): void {
if (this.subscribers[eventName] === undefined) {
console.warn(`EventDispatcher .off(): there is no subscribers for event "${eventName.toString()}". Probably, .off() called before .on()`);
return;
}
for (let i = 0; i < this.subscribers[eventName].length; i++) {
if (this.subscribers[eventName][i] === callback) {
delete this.subscribers[eventName][i];
@ -107,6 +113,6 @@ export default class EventsDispatcher<EventMap> {
* clears subscribers list
*/
public destroy(): void {
this.subscribers = null;
this.subscribers = {} as Subscriptions<EventMap>;
}
}

View file

@ -0,0 +1,54 @@
declare global {
/**
* https://developer.mozilla.org/en-US/docs/Web/API/KeyboardLayoutMap
*/
interface KeyboardLayoutMap {
get(key: string): string | undefined;
has(key: string): boolean;
size: number;
entries(): IterableIterator<[string, string]>;
keys(): IterableIterator<string>;
values(): IterableIterator<string>;
forEach(callbackfn: (value: string, key: string, map: KeyboardLayoutMap) => void, thisArg?: unknown): void;
}
/**
* The getLayoutMap() method of the Keyboard interface returns a Promise
* that resolves with an instance of KeyboardLayoutMap which is a map-like object
* with functions for retrieving the strings associated with specific physical keys.
* https://developer.mozilla.org/en-US/docs/Web/API/Keyboard/getLayoutMap
*/
interface Keyboard {
getLayoutMap(): Promise<KeyboardLayoutMap>;
}
interface Navigator {
/**
* Keyboard API. Not supported by Firefox and Safari.
*/
keyboard?: Keyboard;
}
}
/**
* Returns real layout-related keyboard key for a given key code.
* For example, for "Slash" it will return "/" on US keyboard and "-" on Spanish keyboard.
*
* Works with Keyboard API which is not supported by Firefox and Safari. So fallback is used for these browsers.
*
* @see https://developer.mozilla.org/en-US/docs/Web/API/Keyboard
* @param code - {@link https://www.w3.org/TR/uievents-code/#key-alphanumeric-writing-system}
* @param fallback - fallback value to be returned if Keyboard API is not supported (Safari, Firefox)
*/
export async function getKeyboardKeyForCode(code: string, fallback: string): Promise<string> {
const keyboard = navigator.keyboard;
if (!keyboard) {
return fallback;
}
const map = await keyboard.getLayoutMap();
const key = map.get(code);
return key || fallback;
}

View file

@ -8,19 +8,28 @@ export function isMutationBelongsToElement(mutationRecord: MutationRecord, eleme
const { type, target, addedNodes, removedNodes } = mutationRecord;
/**
* Check typing and attributes changes
* Covers all types of mutations happened to the element or it's descendants with the only one exception - removing/adding the element itself;
*/
if (['characterData', 'attributes'].includes(type)) {
const targetElement = target.nodeType === Node.TEXT_NODE ? target.parentNode : target;
return element.contains(targetElement);
if (element.contains(target)) {
return true;
}
/**
* Check new/removed nodes
* In case of removing/adding the element itself, mutation type will be 'childList' and 'removedNodes'/'addedNodes' will contain the element.
*/
const addedNodesBelongsToBlock = Array.from(addedNodes).some(node => element.contains(node));
const removedNodesBelongsToBlock = Array.from(removedNodes).some(node => element.contains(node));
if (type === 'childList') {
const elementAddedItself = Array.from(addedNodes).some(node => node === element);
return addedNodesBelongsToBlock || removedNodesBelongsToBlock;
if (elementAddedItself) {
return true;
}
const elementRemovedItself = Array.from(removedNodes).some(node => node === element);
if (elementRemovedItself) {
return true;
}
}
return false;
}

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,25 @@
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
} from '../popover-item.types';
import { PopoverItem } from '../popover-item';
import { css } from './popover-item-default.const';
/**
* Represents sigle popover item node
*
* @todo move nodes initialization to constructor
* @todo replace multiple make() usages with constructing separate instaces
* @todo split regular popover item and popover item with confirmation to separate classes
*/
export class 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 +54,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);
}
/**
@ -62,52 +75,21 @@ export class PopoverItem {
/**
* Popover item params
*/
private params: PopoverItemParams;
private params: PopoverItemDefaultParams;
/**
* 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
*/
constructor(params: PopoverItemParams) {
constructor(params: PopoverItemDefaultParams) {
super();
this.params = params;
this.nodes.root = this.make(params);
}
@ -115,7 +97,7 @@ export class PopoverItem {
/**
* 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,53 @@ export class PopoverItem {
this.disableSpecialHoverAndFocusBehavior();
}
/**
* Returns list of item children
*/
public get children(): PopoverItemParams[] {
return 'children' in this.params && this.params.children?.items !== undefined ? this.params.children.items : [];
}
/**
* Constructs HTML element corresponding to popover item params
*
* @param params - item construction params
*/
private make(params: PopoverItemParams): HTMLElement {
const el = Dom.make('div', PopoverItem.CSS.container);
private make(params: PopoverItemDefaultParams): HTMLElement {
const el = Dom.make('div', css.container);
if (params.name) {
el.dataset.itemName = params.name;
}
this.nodes.icon = Dom.make('div', PopoverItem.CSS.icon, {
this.nodes.icon = Dom.make('div', [css.icon, css.iconTool], {
innerHTML: params.icon || IconDotCircle,
});
el.appendChild(this.nodes.icon);
el.appendChild(Dom.make('div', PopoverItem.CSS.title, {
el.appendChild(Dom.make('div', css.title, {
innerHTML: params.title || '',
}));
if (params.secondaryLabel) {
el.appendChild(Dom.make('div', PopoverItem.CSS.secondaryTitle, {
el.appendChild(Dom.make('div', css.secondaryTitle, {
textContent: params.secondaryLabel,
}));
}
if (this.children.length > 0) {
el.appendChild(Dom.make('div', [css.icon, css.iconChevronRight], {
innerHTML: IconChevronRight,
}));
}
if (params.isActive) {
el.classList.add(PopoverItem.CSS.active);
el.classList.add(css.active);
}
if (params.isDisabled) {
el.classList.add(PopoverItem.CSS.disabled);
el.classList.add(css.disabled);
}
return el;
@ -210,16 +205,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 +229,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 +247,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 +260,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 +282,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 +299,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,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,16 @@
/**
* Popover item abstract class
*/
export abstract class PopoverItem {
/**
* 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,123 @@
/**
* Represents popover item separator.
* Special item type that is used to separate items in the popover.
*/
export interface PopoverItemSeparatorParams {
/**
* Item type
*/
type: 'separator'
}
/**
* Common parameters for all kinds of default popover items: with or without confirmation
*/
interface PopoverItemDefaultBaseParams {
/**
* Item type
*/
type?: '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;
}
/**
* 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;

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,32 +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,
}) 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
*/
@ -146,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,528 +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 | null {
return this.nodes.wrapper;
}
/**
* 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) {
setTimeout(() => {
this.search.focus();
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
}, 100);
}
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,327 @@
import { PopoverItem, PopoverItemDefault, PopoverItemSeparator } 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';
/**
* 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 usual interactive popover items that can be clicked, hovered, etc.
* (excluding separators)
*/
protected get itemsInteractive(): PopoverItemDefault[] {
return this.items.filter(item => item instanceof PopoverItemDefault) as PopoverItemDefault[];
}
/**
* Instance of the Search Input
*/
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
*/
constructor(protected readonly params: PopoverParams) {
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.customContent) {
this.addCustomContent(params.customContent);
}
if (params.searchable) {
this.addSearch();
}
}
/**
* Returns HTML element corresponding to the popover
*/
public getElement(): HTMLElement {
return this.nodes.popover as HTMLElement;
}
/**
* Open popover
*/
public show(): void {
this.nodes.popover.classList.add(css.popoverOpened);
if (this.search !== undefined) {
this.search.focus();
}
}
/**
* Closes popover
*/
public hide(): void {
this.nodes.popover.classList.remove(css.popoverOpened);
this.nodes.popover.classList.remove(css.popoverOpenTop);
this.itemsInteractive.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 'separator':
return new PopoverItemSeparator();
default:
return new PopoverItemDefault(item);
}
});
}
/**
* 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.itemsInteractive.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) {
/** Should hide separators if nothing found message displayed or if there is some search query applied */
isHidden = isNothingFound || !isEmptyQuery;
}
item.toggleHidden(isHidden);
});
this.toggleNothingFoundMessage(isNothingFound);
this.toggleCustomContent(isEmptyQuery);
};
/**
* Adds search to the popover
*/
private addSearch(): void {
this.search = new SearchInput({
items: this.itemsInteractive,
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);
}
/**
* Adds custom html content to the popover
*
* @param content - html content to append
*/
private addCustomContent(content: HTMLElement): void {
this.nodes.customContent = content;
this.nodes.customContent.classList.add(css.customContent);
this.nodes.popoverContainer.insertBefore(content, this.nodes.popoverContainer.firstChild);
}
/**
* Handles clicks inside popover
*
* @param event - item to handle click of
*/
private handleClick(event: Event): void {
const item = this.getTargetItem(event);
if (item === undefined) {
return;
}
if (item.isDisabled) {
return;
}
if (item.children.length > 0) {
this.showNestedItems(item);
return;
}
/** Cleanup other items state */
this.itemsInteractive.filter(x => x !== item).forEach(x => x.reset());
item.handleClick();
this.toggleItemActivenessIfNeeded(item);
if (item.closeOnActivate) {
this.hide();
}
}
/**
* Toggles nothing found message visibility
*
* @param isDisplayed - true if the message should be displayed
*/
private toggleNothingFoundMessage(isDisplayed: boolean): void {
this.nodes.nothingFoundMessage.classList.toggle(css.nothingFoundMessageDisplayed, isDisplayed);
}
/**
* Toggles custom content visibility
*
* @param isDisplayed - true if custom content should be displayed
*/
private toggleCustomContent(isDisplayed: boolean): void {
this.nodes.customContent?.classList.toggle(css.customContentHidden, isDisplayed);
}
/**
* - Toggles item active state, if clicked popover item has property 'toggle' set to true.
*
* - Performs radiobutton-like behavior if the item has property 'toggle' set to string key.
* (All the other items with the same key get inactive, and the item gets active)
*
* @param clickedItem - popover item that was clicked
*/
private toggleItemActivenessIfNeeded(clickedItem: PopoverItemDefault): void {
if (clickedItem.toggle === true) {
clickedItem.toggleActive();
}
if (typeof clickedItem.toggle === 'string') {
const itemsInToggleGroup = this.itemsInteractive.filter(item => item.toggle === clickedItem.toggle);
/** If there's only one item in toggle group, toggle it */
if (itemsInToggleGroup.length === 1) {
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,359 @@
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';
/**
* Desktop popover.
* On desktop devices popover behaves like a floating element. Nested popover appears at right or left side.
*/
export class PopoverDesktop extends PopoverAbstract {
/**
* Flipper - module for keyboard iteration between elements
*/
public flipper: Flipper;
/**
* List of html elements inside custom content area that should be available for keyboard navigation
*/
private customContentFlippableItems: HTMLElement[] | undefined;
/**
* Reference to nested popover if exists.
* Undefined by default, PopoverDesktop when exists and null after destroyed.
*/
private nestedPopover: PopoverDesktop | undefined | null;
/**
* Last hovered item inside popover.
* Is used to determine if cursor is moving inside one item or already moved away to another one.
* Helps prevent reopening nested popover while cursor is moving inside one item area.
*/
private previouslyHoveredItem: PopoverItem | null = null;
/**
* Popover nesting level. 0 value means that it is a root popover
*/
private nestingLevel = 0;
/**
* Element of the page that creates 'scope' of the popover.
* If possible, popover will not cross specified element's borders when opening.
*/
private scopeElement: HTMLElement = document.body;
/**
* Construct the instance
*
* @param params - popover params
*/
constructor(params: PopoverParams) {
super(params);
if (params.nestingLevel !== undefined) {
this.nestingLevel = params.nestingLevel;
}
if (this.nestingLevel > 0) {
this.nodes.popover.classList.add(css.popoverNested);
}
if (params.customContentFlippableItems) {
this.customContentFlippableItems = params.customContentFlippableItems;
}
if (params.scopeElement !== undefined) {
this.scopeElement = params.scopeElement;
}
if (this.nodes.popoverContainer !== null) {
this.listeners.on(this.nodes.popoverContainer, 'mouseover', (event: Event) => this.handleHover(event));
}
this.flipper = new Flipper({
items: this.flippableElements,
focusedItemClass: popoverItemCls.focused,
allowedKeys: [
keyCodes.TAB,
keyCodes.UP,
keyCodes.DOWN,
keyCodes.ENTER,
],
});
this.flipper.onFlip(this.onFlip);
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.flipper.deactivate();
this.destroyNestedPopoverIfExists();
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.
* Contains both usual popover items elements and custom html content.
*/
private get flippableElements(): HTMLElement[] {
const popoverItemsElements = this.itemsInteractive.map(item => item.getElement());
const customContentControlsElements = this.customContentFlippableItems || [];
/**
* Combine elements inside custom content area with popover items elements
*/
return customContentControlsElements.concat(popoverItemsElements as HTMLElement[]);
}
/**
* Called on flipper navigation
*/
private onFlip = (): void => {
const focusedItem = this.itemsInteractive.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,154 @@
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 } 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);
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 title - new popover header text
* @param items - new popover items
*/
private updateItemsAndHeader(items: PopoverItemParams[], title?: string ): void {
/** Re-render header */
if (this.header !== null && this.header !== undefined) {
this.header.destroy();
this.header = null;
}
if (title !== undefined) {
this.header = new PopoverHeader({
text: title,
onBackButtonClick: () => {
this.history.pop();
this.updateItemsAndHeader(this.history.currentItems, this.history.currentTitle);
},
});
const headerEl = this.header.getElement();
if (headerEl !== null) {
this.nodes.popoverContainer.insertBefore(headerEl, this.nodes.popoverContainer.firstChild);
}
}
/** Re-render items */
this.items.forEach(item => item.getElement()?.remove());
this.items = 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,27 @@
import { bem } from '../bem';
/**
* Popover block CSS class constructor
*/
const className = bem('ce-popover');
/**
* CSS class names to be used in popover
*/
export const css = {
popover: className(),
popoverContainer: className('container'),
popoverOpenTop: className(null, 'open-top'),
popoverOpenLeft: className(null, 'open-left'),
popoverOpened: className(null, 'opened'),
search: className('search'),
nothingFoundMessage: className('nothing-found-message'),
nothingFoundMessageDisplayed: className('nothing-found-message', 'displayed'),
customContent: className('custom-content'),
customContentHidden: className('custom-content', 'hidden'),
items: className('items'),
overlay: className('overlay'),
overlayHidden: className('overlay', 'hidden'),
popoverNested: className(null, 'nested'),
popoverHeader: className('header'),
};

View file

@ -0,0 +1,109 @@
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;
/**
* Arbitrary html element to be inserted before items list
*/
customContent?: HTMLElement;
/**
* List of html elements inside custom content area that should be available for keyboard navigation
*/
customContentFlippableItems?: HTMLElement[];
/**
* True if popover should contain search field
*/
searchable?: boolean;
/**
* Popover texts overrides
*/
messages?: PopoverMessages
/**
* CSS class name for popover root element
*/
class?: string;
/**
* Popover nesting level. 0 value means that it is a root popover
*/
nestingLevel?: number;
}
/**
* Texts used inside popover
*/
export interface PopoverMessages {
/** Text displayed when search has no results */
nothingFound?: string;
/** Search input label */
search?: string
}
/**
* Event that can be triggered by the Popover
*/
export enum PopoverEvent {
/**
* When popover closes
*/
Close = 'close'
}
/**
* Events fired by the Popover
*/
export interface PopoverEventMap {
/**
* Fired when popover closes
*/
[PopoverEvent.Close]: undefined;
}
/**
* HTML elements required to display popover
*/
export interface PopoverNodes {
/** Root popover element */
popover: HTMLElement;
/** Wraps all the visible popover elements, has background and rounded corners */
popoverContainer: HTMLElement;
/** Message displayed when no items found while searching */
nothingFoundMessage: HTMLElement;
/** Popover items wrapper */
items: HTMLElement;
/** Custom html content area */
customContent: HTMLElement | undefined;
}
/**
* HTML elements required to display mobile popover
*/
export interface PopoverMobileNodes extends PopoverNodes {
/** Popover header element */
header: HTMLElement;
/** Overlay, displayed under popover on mobile */
overlay: HTMLElement;
}

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

@ -0,0 +1,28 @@
/**
* Class allows to make a queue of async jobs and wait until they all will be finished one by one
*
* @example const queue = new PromiseQueue();
* queue.add(async () => { ... });
* queue.add(async () => { ... });
* await queue.completed;
*/
export default class PromiseQueue {
/**
* Queue of promises to be executed
*/
public completed = Promise.resolve();
/**
* Add new promise to queue
*
* @param operation - promise should be added to queue
*/
public add(operation: (value: void) => void | PromiseLike<void>): Promise<void> {
return new Promise((resolve, reject) => {
this.completed = this.completed
.then(operation)
.then(resolve)
.catch(reject);
});
}
}

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

@ -6,53 +6,66 @@ import CodeXTooltips from 'codex-tooltip';
import type { TooltipOptions, TooltipContent } from 'codex-tooltip/types';
/**
* Tooltip
* Tooltips lib: CodeX Tooltips
*
* Decorates any tooltip module like adapter
* @see https://github.com/codex-team/codex.tooltips
*/
export default class Tooltip {
/**
* Tooltips lib: CodeX Tooltips
*
* @see https://github.com/codex-team/codex.tooltips
*/
private lib: CodeXTooltips = new CodeXTooltips();
let lib: null | CodeXTooltips = null;
/**
* Release the library
*/
public destroy(): void {
this.lib.destroy();
/**
* If library is needed, but it is not initialized yet, this function will initialize it
*
* For example, if editor was destroyed and then initialized again
*/
function prepare(): void {
if (lib) {
return;
}
/**
* Shows tooltip on element with passed HTML content
*
* @param {HTMLElement} element - any HTML element in DOM
* @param content - tooltip's content
* @param options - showing settings
*/
public show(element: HTMLElement, content: TooltipContent, options?: TooltipOptions): void {
this.lib.show(element, content, options);
}
/**
* Hides tooltip
*
* @param skipHidingDelay pass true to immediately hide the tooltip
*/
public hide(skipHidingDelay = false): void {
this.lib.hide(skipHidingDelay);
}
/**
* Binds 'mouseenter' and 'mouseleave' events that shows/hides the Tooltip
*
* @param {HTMLElement} element - any HTML element in DOM
* @param content - tooltip's content
* @param options - showing settings
*/
public onHover(element: HTMLElement, content: TooltipContent, options?: TooltipOptions): void {
this.lib.onHover(element, content, options);
}
lib = new CodeXTooltips();
}
/**
* Shows tooltip on element with passed HTML content
*
* @param {HTMLElement} element - any HTML element in DOM
* @param content - tooltip's content
* @param options - showing settings
*/
export function show(element: HTMLElement, content: TooltipContent, options?: TooltipOptions): void {
prepare();
lib?.show(element, content, options);
}
/**
* Hides tooltip
*
* @param skipHidingDelay pass true to immediately hide the tooltip
*/
export function hide(skipHidingDelay = false): void {
prepare();
lib?.hide(skipHidingDelay);
}
/**
* Binds 'mouseenter' and 'mouseleave' events that shows/hides the Tooltip
*
* @param {HTMLElement} element - any HTML element in DOM
* @param content - tooltip's content
* @param options - showing settings
*/
export function onHover(element: HTMLElement, content: TooltipContent, options?: TooltipOptions): void {
prepare();
lib?.onHover(element, content, options);
}
/**
* Release the library
*/
export function destroy(): void {
lib?.destroy();
lib = null;
}

11
src/env.d.ts vendored Normal file
View file

@ -0,0 +1,11 @@
interface ImportMetaEnv {
/**
* Build environment.
* For example, used to detect building for tests and add "data-cy" attributes for DOM querying.
*/
readonly MODE: "test" | "development" | "production";
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View file

@ -89,10 +89,3 @@
font-style: italic;
}
}
.codex-editor--narrow .ce-block--focused {
@media (--not-mobile) {
margin-right: calc(var(--narrow-mode-right-padding) * -1);
padding-right: var(--narrow-mode-right-padding);
}
}

View file

@ -7,7 +7,7 @@
transition: transform 100ms ease, opacity 100ms ease;
transform: translateY(-8px);
left: -1px;
width: 150px;
width: 190px;
margin-top: 5px;
box-sizing: content-box;
@ -78,4 +78,19 @@
animation: bounceIn 0.75s 1;
animation-fill-mode: forwards;
}
&__secondary-label {
color: var(--grayText);
font-size: 12px;
margin-left: auto;
white-space: nowrap;
letter-spacing: -0.1em;
padding-right: 5px;
margin-bottom: -2px;
opacity: 0.6;
@media (--mobile){
display: none;
}
}
}

View file

@ -2,11 +2,10 @@
--y-offset: 8px;
@apply --overlay-pane;
transform: translateX(-50%) translateY(8px) scale(0.94);
opacity: 0;
visibility: hidden;
transition: transform 150ms ease, opacity 250ms ease;
will-change: transform, opacity;
transition: opacity 250ms ease;
will-change: opacity, left, top;
top: 0;
left: 0;
z-index: 3;
@ -14,24 +13,6 @@
&--showed {
opacity: 1;
visibility: visible;
transform: translateX(-50%)
}
&--left-oriented {
transform: translateX(-23px) translateY(8px) scale(0.94);
}
&--left-oriented&--showed {
transform: translateX(-23px);
}
&--right-oriented {
transform: translateX(-100%) translateY(8px) scale(0.94);
margin-left: 23px;
}
&--right-oriented&--showed {
transform: translateX(-100%);
}
[hidden] {

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,21 +109,21 @@
}
}
&--open-top {
top: calc(-1 * (var(--offset-from-target) + var(--popover-height)));
}
@media (--mobile) {
--offset: 5px;
position: fixed;
max-width: none;
min-width: calc(100% - var(--offset) * 2);
left: var(--offset);
right: var(--offset);
bottom: calc(var(--offset) + env(safe-area-inset-bottom));
top: auto;
border-radius: 10px;
.ce-popover__container {
--offset: 5px;
position: fixed;
max-width: none;
min-width: calc(100% - var(--offset) * 2);
left: var(--offset);
right: var(--offset);
bottom: calc(var(--offset) + env(safe-area-inset-bottom));
top: auto;
border-radius: 10px;
}
.ce-popover__search {
display: none;
@ -134,21 +162,60 @@
&__custom-content--hidden {
display: none;
}
&--nested {
.ce-popover__container {
/* Variable --nesting-level is set via js in showNestedPopoverForItem() method */
--popover-left: calc(var(--nesting-level) * (var(--width) - var(--nested-popover-overlap)));
/* Variable --trigger-item-top is set via js in showNestedPopoverForItem() method */
top: calc(var(--trigger-item-top) - var(--nested-popover-overlap));
position: absolute;
}
}
&--open-top.ce-popover--nested {
.ce-popover__container {
/** Bottom edge of nested popover should not be lower than bottom edge of parent popover when opened upwards */
top: calc(var(--trigger-item-top) - var(--popover-height) + var(--item-height) + var(--offset-from-target) + var(--nested-popover-overlap));
}
}
&--open-left {
.ce-popover--nested {
.ce-popover__container {
--popover-left: calc(-1 * (var(--nesting-level) + 1) * var(--width) + 100%);
}
}
}
}
/**
* 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 {
--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 +228,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 +245,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 +267,8 @@
white-space: nowrap;
text-overflow: ellipsis;
margin-right: auto;
@media (--mobile) {
font-size: 16px;
}
@ -205,7 +277,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 +444,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

@ -1,26 +1,25 @@
.ce-stub {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
padding: 3.5em 0;
margin: 17px 0;
border-radius: 3px;
background: #fcf7f7;
color: #b46262;
padding: 12px 18px;
margin: 10px 0;
border-radius: 10px;
background: var(--bg-light);
border: 1px solid var(--color-line-gray);
color: var(--grayText);
font-size: 14px;
svg {
width: var(--icon-size);
height: var(--icon-size);
}
&__info {
margin-left: 20px;
margin-left: 14px;
}
&__title {
margin-bottom: 3px;
font-weight: 600;
font-size: 18px;
font-weight: 500;
text-transform: capitalize;
}
&__subtitle {
font-size: 16px;
}
}

View file

@ -11,10 +11,6 @@
}
&__redactor {
&--hidden {
display: none;
}
/**
* Workaround firefox bug: empty content editable elements has collapsed height
* https://bugzilla.mozilla.org/show_bug.cgi?id=1098151#c18
@ -46,28 +42,6 @@
}
}
&__loader {
position: relative;
height: 30vh;
&::before {
content: '';
position: absolute;
left: 50%;
top: 50%;
width: 30px;
height: 30px;
margin-top: -15px;
margin-left: -15px;
border-radius: 50%;
border: 2px solid var(--color-gray-border);
border-top-color: transparent;
box-sizing: border-box;
animation: editor-loader-spin 800ms infinite linear;
will-change: transform;
}
}
&-copyable {
position: absolute;
height: 1px;
@ -107,29 +81,21 @@
path:not(.ce-block__content path) {
stroke: currentColor;
}
/**
* Set color for native selection
*/
::selection{
background-color: var(--inlineSelectionColor);
}
}
/**
* Set color for native selection
*/
::selection{
background-color: var(--inlineSelectionColor);
}
.codex-editor--toolbox-opened [contentEditable=true][data-placeholder]:focus::before {
opacity: 0 !important;
}
@keyframes editor-loader-spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.ce-scroll-locked {
overflow: hidden;
}

Some files were not shown because too many files have changed in this diff Show more