+ Editor.js is a block-style editor for rich media stories. It outputs clean data in JSON instead of heavy HTML markup. And more important thing is that Editor.js is designed to be API extendable and pluggable.
+
+
+ So there are a few key features:
+
+
+
Clean data output
+
API pluggable
+
Open source
+
+
+ What does it mean block-styled
+
+
+ In other editors, the workspace is provided by single contenteditable element in where you can create different HTML markup. All of us saw permanent bugs with moving text fragments or scaling images, while page parts are jumping and twitches. Or highlighting big parts of the text in the case when you just want to make few words to be a heading or bold.
+
+
+ The Editor.js workspace consists of separate Blocks: paragraphs, headings, images, lists, quotes, etc. Each of them is an independent contenteditable element (or more complex structure) provided by Plugin and united by Editor's Core.
+
+
+ At the same time, most useful features as arrow-navigation, copy & paste, cross block selection, and others works almost as in the familiar editors.
+
+
+ What is clean data
+
+
+ But the more interesting thing is, as mentioned above, that Editor.js returns clean data instead of HTML-markup. Take a look at the example.
+
+
+ If our entry consists of few paragraphs and a heading, in popular Medium editor after saving we will have something like this:
+
+
+ As you can see, there are only data we need: a list of structural Blocks with their content description.
+
+
+ You can use this data to easily render in Web, native mobile/desktop application, pass to Audio Readers, create templates for Facebook Instant Articles, AMP, RSS, create chat-bots, and many others.
+
+
+ Also, the clean data can be useful for backend processing: sanitizing, validation, injecting an advertising or other stuff, extracting Headings, make covers for social networks from Image Blocks, and other.
+
+
+ API pluggable?
+
+
+ A key value of the Editor is the API. All main functional units of the editor — Blocks, Inline Formatting Tools, Block Tunes — are provided by external plugins that use Editor's API.
+
+
+ We decide to extract all these Tools to separate scripts to make Editor's Core more abstract and make API more powerful. Any challenges and tasks you are facing can be implemented by your own plugins using the API.
+
+
+ At the same time, API is created to be easy-to-understand and simple-to-use.
+
+
+ Open Source, so?
+
+
+ Editor.js is more than just an editor. It is a big open-source community of developers and contributors. Anyone can suggest an improvement or a bug fix. Anyone can create new cool API features and plugins.
+
+
+ We will support each developer of Editor.js plugins: the best solutions will be collected to the Awesome List and promoted to the community. Together we can create a big suite of different Blocks, Inline Tools, Block Tunes that can hit a wide specter of tasks.
+
+
+ Thanks for your interest. Hope you enjoy Editor.js.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/example/example-rtl.html b/example/example-rtl.html
index c8fa6662..298381dc 100644
--- a/example/example-rtl.html
+++ b/example/example-rtl.html
@@ -61,7 +61,7 @@
-
+
diff --git a/example/tools/embed b/example/tools/embed
index b3c87948..35742f01 160000
--- a/example/tools/embed
+++ b/example/tools/embed
@@ -1 +1 @@
-Subproject commit b3c87948d5a5926f2557c2029d64aa819ca61920
+Subproject commit 35742f01ae5875d442b145121d3c9b71b23aea56
diff --git a/example/tools/link b/example/tools/link
index d452b461..0fc365ef 160000
--- a/example/tools/link
+++ b/example/tools/link
@@ -1 +1 @@
-Subproject commit d452b46106766b5fc4c5747e99888f613a3764fd
+Subproject commit 0fc365ef256decb8f765fb72b060d5bef9254aa3
diff --git a/example/tools/list b/example/tools/list
index 004a0645..58b5dc70 160000
--- a/example/tools/list
+++ b/example/tools/list
@@ -1 +1 @@
-Subproject commit 004a06458d3881e8d922506381c21847900e3c62
+Subproject commit 58b5dc7072ad92a048ebe9488d0ca8e7bfa069d2
diff --git a/example/tools/nested-list b/example/tools/nested-list
index 8375ae17..9add9538 160000
--- a/example/tools/nested-list
+++ b/example/tools/nested-list
@@ -1 +1 @@
-Subproject commit 8375ae17756fa2677d57e716e12096437d01e8f8
+Subproject commit 9add95389afca0711c05260a92283fae8eb209eb
diff --git a/example/tools/raw b/example/tools/raw
index 84cc4f39..4f8010dd 160000
--- a/example/tools/raw
+++ b/example/tools/raw
@@ -1 +1 @@
-Subproject commit 84cc4f393db0939c6246c9a579377f2540dac289
+Subproject commit 4f8010dd640d847778220d9b1947fa8235b17f87
diff --git a/example/tools/table b/example/tools/table
index ddbc1147..ad0d9012 160000
--- a/example/tools/table
+++ b/example/tools/table
@@ -1 +1 @@
-Subproject commit ddbc1147e127a727cfac4dbe0326b78e02dceb9e
+Subproject commit ad0d9012d149e3ca4b41a5ce096b31767cc8c1fd
diff --git a/example/tools/text-variant-tune b/example/tools/text-variant-tune
new file mode 160000
index 00000000..02538b1d
--- /dev/null
+++ b/example/tools/text-variant-tune
@@ -0,0 +1 @@
+Subproject commit 02538b1daea722c854cc61b6fbca01b746c21717
diff --git a/package.json b/package.json
index dd2521e4..2c5452ae 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@editorjs/editorjs",
- "version": "2.23.0-rc.1",
+ "version": "2.24.2",
"description": "Editor.js — Native JS, based on API and Open Source",
"main": "dist/editor.js",
"types": "./types/index.d.ts",
@@ -24,14 +24,15 @@
"svg": "svg-sprite-generate -d src/assets/ -o dist/sprite.svg",
"ci:pull_paragraph": "git submodule update --init ./src/tools/paragraph",
"pull_tools": "git submodule update --init --recursive",
- "_tools:checkout": "git submodule foreach 'git checkout master || git checkout main'",
+ "_tools:checkout": "git submodule foreach \"git checkout master || git checkout main\"",
"_tools:pull": "git submodule foreach git pull",
"_tools:yarn": "git submodule foreach yarn",
"_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:open": "yarn build && cypress open",
+ "devserver:start": "yarn build && node ./devserver.js"
},
"author": "CodeX",
"license": "Apache-2.0",
@@ -50,6 +51,8 @@
"@codexteam/shortcuts": "^1.1.1",
"@cypress/code-coverage": "^3.9.2",
"@cypress/webpack-preprocessor": "^5.6.0",
+ "@editorjs/code": "^2.7.0",
+ "@editorjs/delimiter": "^1.2.0",
"@editorjs/header": "^2.6.1",
"@editorjs/simple-image": "^1.4.1",
"@types/node": "^14.14.35",
@@ -96,7 +99,7 @@
},
"dependencies": {
"codex-notifier": "^1.1.2",
- "codex-tooltip": "^1.0.4",
+ "codex-tooltip": "^1.0.5",
"nanoid": "^3.1.22"
}
}
diff --git a/src/assets/search.svg b/src/assets/search.svg
new file mode 100644
index 00000000..1485338b
--- /dev/null
+++ b/src/assets/search.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/components/block/index.ts b/src/components/block/index.ts
index d3eabc8c..9a9f9851 100644
--- a/src/components/block/index.ts
+++ b/src/components/block/index.ts
@@ -52,7 +52,7 @@ interface BlockConstructorOptions {
/**
* Tunes data for current Block
*/
- tunesData: {[name: string]: BlockTuneData};
+ tunesData: { [name: string]: BlockTuneData };
}
/**
@@ -98,7 +98,7 @@ export default class Block extends EventsDispatcher {
*
* @returns {{wrapper: string, content: string}}
*/
- public static get CSS(): {[name: string]: string} {
+ public static get CSS(): { [name: string]: string } {
return {
wrapper: 'ce-block',
wrapperStretched: 'ce-block--stretched',
@@ -170,7 +170,7 @@ export default class Block extends EventsDispatcher {
* If there is saved data for Tune which is not available at the moment,
* we will store it here and provide back on save so data is not lost
*/
- private unavailableTunesData: {[name: string]: BlockTuneData} = {};
+ private unavailableTunesData: { [name: string]: BlockTuneData } = {};
/**
* Editor`s API module
@@ -201,11 +201,15 @@ export default class Block extends EventsDispatcher {
/**
* Is fired when DOM mutation has been happened
*/
- private didMutated = _.debounce((mutations: MutationRecord[] = []): void => {
- const shouldFireUpdate = !mutations.some(({ addedNodes = [], removedNodes }) => {
- return [...Array.from(addedNodes), ...Array.from(removedNodes)]
- .some(node => $.isElement(node) && (node as HTMLElement).dataset.mutationFree === 'true');
- });
+ private didMutated = _.debounce((mutationsOrInputEvent: MutationRecord[] | InputEvent = []): void => {
+ const shouldFireUpdate = mutationsOrInputEvent instanceof InputEvent ||
+ !mutationsOrInputEvent.some(({
+ addedNodes = [],
+ removedNodes,
+ }) => {
+ return [...Array.from(addedNodes), ...Array.from(removedNodes)]
+ .some(node => $.isElement(node) && (node as HTMLElement).dataset.mutationFree === 'true');
+ });
/**
* In case some mutation free elements are added or removed, do not trigger didMutated event
@@ -575,9 +579,9 @@ export default class Block extends EventsDispatcher {
*
* @returns {object}
*/
- public async save(): Promise {
+ public async save(): Promise {
const extractedBlock = await this.toolInstance.save(this.pluginsContent as HTMLElement);
- const tunesData: {[name: string]: BlockTuneData} = this.unavailableTunesData;
+ const tunesData: { [name: string]: BlockTuneData } = this.unavailableTunesData;
[
...this.tunesInstances.entries(),
@@ -706,7 +710,7 @@ export default class Block extends EventsDispatcher {
* Allows to say Editor that Block was changed. Used to manually trigger Editor's 'onChange' callback
* Can be useful for block changes invisible for editor core.
*/
- public dispatchChange(): void{
+ public dispatchChange(): void {
this.didMutated();
}
@@ -775,7 +779,7 @@ export default class Block extends EventsDispatcher {
* @param tunesData - current Block tunes data
* @private
*/
- private composeTunes(tunesData: {[name: string]: BlockTuneData}): void {
+ private composeTunes(tunesData: { [name: string]: BlockTuneData }): void {
Array.from(this.tunes.values()).forEach((tune) => {
const collection = tune.isInternal ? this.defaultTunesInstances : this.tunesInstances;
diff --git a/src/components/flipper.ts b/src/components/flipper.ts
index a97216a6..7ab00fbb 100644
--- a/src/components/flipper.ts
+++ b/src/components/flipper.ts
@@ -17,21 +17,23 @@ export interface FlipperOptions {
*/
items?: HTMLElement[];
- /**
- * Defines arrows usage. By default Flipper leafs items also via RIGHT/LEFT.
- *
- * true by default
- *
- * Pass 'false' if you don't need this behaviour
- * (for example, Inline Toolbar should be closed by arrows,
- * because it means caret moving with selection clearing)
- */
- allowArrows?: boolean;
-
/**
* Optional callback for button click
*/
activateCallback?: (item: HTMLElement) => void;
+
+ /**
+ * List of keys allowed for handling.
+ * Can include codes of the following keys:
+ * - Tab
+ * - Enter
+ * - Arrow up
+ * - Arrow down
+ * - Arrow right
+ * - Arrow left
+ * If not specified all keys are enabled
+ */
+ allowedKeys?: number[];
}
/**
@@ -53,11 +55,9 @@ export default class Flipper {
private activated = false;
/**
- * Flag that allows arrows usage to flip items
- *
- * @type {boolean}
+ * List codes of the keys allowed for handling
*/
- private readonly allowArrows: boolean = true;
+ private readonly allowedKeys: number[];
/**
* Call back for button click/enter
@@ -68,9 +68,9 @@ export default class Flipper {
* @param {FlipperOptions} options - different constructing settings
*/
constructor(options: FlipperOptions) {
- this.allowArrows = _.isBoolean(options.allowArrows) ? options.allowArrows : true;
this.iterator = new DomIterator(options.items, options.focusedItemClass);
this.activateCallback = options.activateCallback;
+ this.allowedKeys = options.allowedKeys || Flipper.usedKeys;
}
/**
@@ -120,15 +120,6 @@ export default class Flipper {
document.removeEventListener('keydown', this.onKeyDown);
}
- /**
- * Return current focused button
- *
- * @returns {HTMLElement|null}
- */
- public get currentItem(): HTMLElement|null {
- return this.iterator.currentItem;
- }
-
/**
* Focus first item
*/
@@ -142,6 +133,7 @@ export default class Flipper {
*/
public flipLeft(): void {
this.iterator.previous();
+ this.flipCallback();
}
/**
@@ -149,6 +141,14 @@ export default class Flipper {
*/
public flipRight(): void {
this.iterator.next();
+ this.flipCallback();
+ }
+
+ /**
+ * Return true if some button is focused
+ */
+ public hasFocus(): boolean {
+ return !!this.iterator.currentItem;
}
/**
@@ -206,23 +206,7 @@ export default class Flipper {
* @returns {boolean}
*/
private isEventReadyForHandling(event: KeyboardEvent): boolean {
- const handlingKeyCodeList = [
- _.keyCodes.TAB,
- _.keyCodes.ENTER,
- ];
-
- const isCurrentItemIsFocusedInput = this.iterator.currentItem == document.activeElement;
-
- if (this.allowArrows && !isCurrentItemIsFocusedInput) {
- handlingKeyCodeList.push(
- _.keyCodes.LEFT,
- _.keyCodes.RIGHT,
- _.keyCodes.UP,
- _.keyCodes.DOWN
- );
- }
-
- return this.activated && handlingKeyCodeList.indexOf(event.keyCode) !== -1;
+ return this.activated && this.allowedKeys.includes(event.keyCode);
}
/**
@@ -266,4 +250,13 @@ export default class Flipper {
event.preventDefault();
event.stopPropagation();
}
+
+ /**
+ * Fired after flipping in any direction
+ */
+ private flipCallback(): void {
+ if (this.iterator.currentItem) {
+ this.iterator.currentItem.scrollIntoViewIfNeeded();
+ }
+ }
}
diff --git a/src/components/i18n/locales/en/messages.json b/src/components/i18n/locales/en/messages.json
index f02cac61..d44df52c 100644
--- a/src/components/i18n/locales/en/messages.json
+++ b/src/components/i18n/locales/en/messages.json
@@ -13,7 +13,9 @@
},
"toolbar": {
"toolbox": {
- "Add": ""
+ "Add": "",
+ "Filter": "",
+ "Nothing found": ""
}
}
},
diff --git a/src/components/modules/api/readonly.ts b/src/components/modules/api/readonly.ts
index 19a8c2d9..7b804839 100644
--- a/src/components/modules/api/readonly.ts
+++ b/src/components/modules/api/readonly.ts
@@ -10,9 +10,14 @@ export default class ReadOnlyAPI extends Module {
* Available methods
*/
public get methods(): ReadOnly {
+ const getIsEnabled = (): boolean => this.isEnabled;
+
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
return {
toggle: (state): Promise => this.toggle(state),
- isEnabled: this.isEnabled,
+ get isEnabled(): boolean {
+ return getIsEnabled();
+ },
};
}
diff --git a/src/components/modules/paste.ts b/src/components/modules/paste.ts
index 178e3cc1..e901897b 100644
--- a/src/components/modules/paste.ts
+++ b/src/components/modules/paste.ts
@@ -662,7 +662,7 @@ export default class Paste extends Module {
/** If there is no pattern substitute - insert string as it is */
if (BlockManager.currentBlock && BlockManager.currentBlock.currentInput) {
- const currentToolSanitizeConfig = BlockManager.currentBlock.tool.sanitizeConfig;
+ const currentToolSanitizeConfig = BlockManager.currentBlock.tool.baseSanitizeConfig;
document.execCommand(
'insertHTML',
diff --git a/src/components/modules/rectangleSelection.ts b/src/components/modules/rectangleSelection.ts
index f0124815..03f249f7 100644
--- a/src/components/modules/rectangleSelection.ts
+++ b/src/components/modules/rectangleSelection.ts
@@ -376,7 +376,6 @@ export default class RectangleSelection extends Module {
this.inverseSelection();
SelectionUtils.get().removeAllRanges();
- event.preventDefault();
}
/**
diff --git a/src/components/modules/saver.ts b/src/components/modules/saver.ts
index 5219cd54..a2bb25c7 100644
--- a/src/components/modules/saver.ts
+++ b/src/components/modules/saver.ts
@@ -29,16 +29,11 @@ export default class Saver extends Module {
* @returns {OutputData}
*/
public async save(): Promise {
- const { BlockManager, Tools, ModificationsObserver } = this.Editor;
+ const { BlockManager, Tools } = this.Editor;
const blocks = BlockManager.blocks,
chainData = [];
try {
- /**
- * Disable onChange callback on save to not to spam those events
- */
- ModificationsObserver.disable();
-
blocks.forEach((block: Block) => {
chainData.push(this.getSavedData(block));
});
@@ -65,8 +60,6 @@ export default class Saver extends Module {
return this.makeOutput(withFragments);
} catch (e) {
_.logLabeled(`Saving failed due to the Error %o`, 'error', e);
- } finally {
- ModificationsObserver.enable();
}
}
diff --git a/src/components/modules/toolbar/index.ts b/src/components/modules/toolbar/index.ts
index d60ffea1..9f5a1db2 100644
--- a/src/components/modules/toolbar/index.ts
+++ b/src/components/modules/toolbar/index.ts
@@ -13,6 +13,10 @@ import Toolbox, { ToolboxEvent } from '../../ui/toolbox';
* @todo Tab on non-empty block should open Block Settings of the hoveredBlock (not where caret is set)
* - make Block Settings a standalone module
*
+ * @todo - Keyboard-only mode bug:
+ * press Tab, flip to the Checkbox. press Enter (block will be added), Press Tab
+ * (Block Tunes will be opened with Move up focused), press Enter, press Tab ———— both Block Tunes and Toolbox will be opened
+ *
* @todo TESTCASE - show toggler after opening and closing the Inline Toolbar
* @todo TESTCASE - Click outside Editor holder should close Toolbar and Clear Focused blocks
* @todo TESTCASE - Click inside Editor holder should close Toolbar and Clear Focused blocks
@@ -33,11 +37,7 @@ interface ToolbarNodes {
content: HTMLElement;
actions: HTMLElement;
- // Content Zone
plusButton: HTMLElement;
-
- // Actions Zone
- blockActionsButtons: HTMLElement;
settingsToggler: HTMLElement;
}
/**
@@ -137,14 +137,10 @@ export default class Toolbar extends Module {
toolbarOpened: 'ce-toolbar--opened',
openedToolboxHolderModifier: 'codex-editor--toolbox-opened',
- // Content Zone
plusButton: 'ce-toolbar__plus',
plusButtonShortcut: 'ce-toolbar__plus-shortcut',
- plusButtonHidden: 'ce-toolbar__plus--hidden',
-
- // Actions Zone
- blockActionsButtons: 'ce-toolbar__actions-buttons',
settingsToggler: 'ce-toolbar__settings-btn',
+ settingsTogglerHidden: 'ce-toolbar__settings-btn--hidden',
};
}
@@ -157,23 +153,6 @@ export default class Toolbar extends Module {
return this.nodes.wrapper.classList.contains(this.CSS.toolbarOpened);
}
- /**
- * Plus Button public methods
- *
- * @returns {{hide: function(): void, show: function(): void}}
- */
- public get plusButton(): { hide: () => void; show: () => void } {
- return {
- hide: (): void => this.nodes.plusButton.classList.add(this.CSS.plusButtonHidden),
- show: (): void => {
- if (this.toolboxInstance.isEmpty) {
- return;
- }
- this.nodes.plusButton.classList.remove(this.CSS.plusButtonHidden);
- },
- };
- }
-
/**
* Public interface for accessing the Toolbox
*/
@@ -182,11 +161,14 @@ export default class Toolbar extends Module {
close: () => void;
open: () => void;
toggle: () => void;
- flipperHasFocus: boolean;
+ hasFocus: () => boolean;
} {
return {
opened: this.toolboxInstance.opened,
- close: (): void => this.toolboxInstance.close(),
+ close: (): void => {
+ this.toolboxInstance.close();
+ this.Editor.Caret.setToBlock(this.Editor.BlockManager.currentBlock);
+ },
open: (): void => {
/**
* Set current block to cover the case when the Toolbar showed near hovered Block but caret is set to another Block.
@@ -196,14 +178,12 @@ export default class Toolbar extends Module {
this.toolboxInstance.open();
},
toggle: (): void => this.toolboxInstance.toggle(),
- flipperHasFocus: this.toolboxInstance.flipperHasFocus,
+ hasFocus: (): boolean => this.toolboxInstance.hasFocus(),
};
}
/**
* Block actions appearance manipulations
- *
- * @returns {{hide: function(): void, show: function(): void}}
*/
private get blockActions(): { hide: () => void; show: () => void } {
return {
@@ -216,6 +196,16 @@ export default class Toolbar extends Module {
};
}
+ /**
+ * Methods for working with Block Tunes toggler
+ */
+ private get blockTunesToggler(): { hide: () => void; show: () => void } {
+ return {
+ hide: (): void => this.nodes.settingsToggler.classList.add(this.CSS.settingsTogglerHidden),
+ show: (): void => this.nodes.settingsToggler.classList.remove(this.CSS.settingsTogglerHidden),
+ };
+ }
+
/**
* Toggles read-only mode
*
@@ -227,7 +217,6 @@ export default class Toolbar extends Module {
this.enableModuleBindings();
} else {
this.destroy();
- this.toolboxInstance.destroy();
this.Editor.BlockSettings.destroy();
this.disableModuleBindings();
}
@@ -277,15 +266,15 @@ export default class Toolbar extends Module {
/**
* Move Toolbar to the Top coordinate of Block
*/
- this.nodes.wrapper.style.transform = `translate3D(0, ${Math.floor(toolbarY)}px, 0)`;
+ this.nodes.wrapper.style.top = `${Math.floor(toolbarY)}px`;
/**
- * Plus Button should be shown only for __empty__ __default__ block
+ * Do not show Block Tunes Toggler near single and empty block
*/
- if (block.tool.isDefault && block.isEmpty) {
- this.plusButton.show();
+ if (this.Editor.BlockManager.blocks.length === 1 && block.isEmpty) {
+ this.blockTunesToggler.hide();
} else {
- this.plusButton.hide();
+ this.blockTunesToggler.show();
}
this.open();
@@ -295,6 +284,10 @@ export default class Toolbar extends Module {
* Close the Toolbar
*/
public close(): void {
+ if (this.Editor.ReadOnly.isEnabled) {
+ return;
+ }
+
this.nodes.wrapper.classList.remove(this.CSS.toolbarOpened);
/** Close components */
@@ -378,13 +371,11 @@ export default class Toolbar extends Module {
* - Remove Block Button
* - Settings Panel
*/
- this.nodes.blockActionsButtons = $.make('div', this.CSS.blockActionsButtons);
this.nodes.settingsToggler = $.make('span', this.CSS.settingsToggler);
const settingsIcon = $.svg('dots', 16, 16);
$.append(this.nodes.settingsToggler, settingsIcon);
- $.append(this.nodes.blockActionsButtons, this.nodes.settingsToggler);
- $.append(this.nodes.actions, this.nodes.blockActionsButtons);
+ $.append(this.nodes.actions, this.nodes.settingsToggler);
this.tooltip.onHover(
this.nodes.settingsToggler,
@@ -397,7 +388,7 @@ export default class Toolbar extends Module {
/**
* Appending Toolbar components to itself
*/
- $.append(this.nodes.content, this.makeToolbox());
+ $.append(this.nodes.actions, this.makeToolbox());
$.append(this.nodes.actions, this.Editor.BlockSettings.nodes.wrapper);
/**
@@ -416,6 +407,10 @@ export default class Toolbar extends Module {
this.toolboxInstance = new Toolbox({
api: this.Editor.API.methods,
tools: this.Editor.Tools.blockTools,
+ i18nLabels: {
+ filter: I18n.ui(I18nInternalNS.ui.toolbar.toolbox, 'Filter'),
+ nothingFound: I18n.ui(I18nInternalNS.ui.toolbar.toolbox, 'Nothing found'),
+ },
});
this.toolboxInstance.on(ToolboxEvent.Opened, () => {
@@ -484,18 +479,25 @@ export default class Toolbar extends Module {
}, true);
/**
- * Subscribe to the 'block-hovered' event
+ * Subscribe to the 'block-hovered' event if currenct view is not mobile
+ *
+ * @see https://github.com/codex-team/editor.js/issues/1972
*/
- this.eventsDispatcher.on(this.Editor.UI.events.blockHovered, (data: {block: Block}) => {
+ if (!_.isMobileScreen()) {
/**
- * Do not move toolbar if Block Settings or Toolbox opened
+ * Subscribe to the 'block-hovered' event
*/
- if (this.Editor.BlockSettings.opened || this.toolboxInstance.opened) {
- return;
- }
+ this.eventsDispatcher.on(this.Editor.UI.events.blockHovered, (data: {block: Block}) => {
+ /**
+ * Do not move toolbar if Block Settings or Toolbox opened
+ */
+ if (this.Editor.BlockSettings.opened || this.toolboxInstance.opened) {
+ return;
+ }
- this.moveAndOpen(data.block);
- });
+ this.moveAndOpen(data.block);
+ });
+ }
}
/**
@@ -551,7 +553,9 @@ export default class Toolbar extends Module {
*/
private destroy(): void {
this.removeAllNodes();
- this.toolboxInstance.destroy();
+ if (this.toolboxInstance) {
+ this.toolboxInstance.destroy();
+ }
this.tooltip.destroy();
}
}
diff --git a/src/components/modules/toolbar/inline.ts b/src/components/modules/toolbar/inline.ts
index 91ded10d..799b60fd 100644
--- a/src/components/modules/toolbar/inline.ts
+++ b/src/components/modules/toolbar/inline.ts
@@ -698,7 +698,10 @@ export default class InlineToolbar extends Module {
private enableFlipper(): void {
this.flipper = new Flipper({
focusedItemClass: this.CSS.focusedButton,
- allowArrows: false,
+ allowedKeys: [
+ _.keyCodes.ENTER,
+ _.keyCodes.TAB,
+ ],
});
}
}
diff --git a/src/components/modules/ui.ts b/src/components/modules/ui.ts
index aecd6de5..75bfef90 100644
--- a/src/components/modules/ui.ts
+++ b/src/components/modules/ui.ts
@@ -231,7 +231,7 @@ export default class UI extends Module {
* Toolbar has internal module (Toolbox) that has own Flipper,
* so we check it manually
*/
- if (this.Editor.Toolbar.toolbox.flipperHasFocus) {
+ if (this.Editor.Toolbar.toolbox.hasFocus()) {
return true;
}
@@ -239,7 +239,7 @@ export default class UI extends Module {
return moduleClass.flipper instanceof Flipper;
})
.some(([moduleName, moduleClass]) => {
- return moduleClass.flipper.currentItem;
+ return moduleClass.flipper.hasFocus();
});
}
@@ -385,7 +385,7 @@ export default class UI extends Module {
*/
private watchBlockHoveredEvents(): void {
/**
- * Used to not to emit the same block multiple times to the 'block-hovered' event on every mousemove
+ * Used to not emit the same block multiple times to the 'block-hovered' event on every mousemove
*/
let blockHoveredEmitted;
diff --git a/src/components/polyfills.ts b/src/components/polyfills.ts
index cfa9af74..42b86a05 100644
--- a/src/components/polyfills.ts
+++ b/src/components/polyfills.ts
@@ -96,3 +96,46 @@ if (!Element.prototype.prepend) {
this.insertBefore(docFrag, this.firstChild);
};
}
+
+interface Element {
+ /**
+ * Scrolls the current element into the visible area of the browser window
+ *
+ * @param centerIfNeeded - true, if the element should be aligned so it is centered within the visible area of the scrollable ancestor.
+ */
+ scrollIntoViewIfNeeded(centerIfNeeded?: boolean): void;
+}
+
+/**
+ * ScrollIntoViewIfNeeded polyfill by KilianSSL (forked from hsablonniere)
+ *
+ * @see {@link https://gist.github.com/KilianSSL/774297b76378566588f02538631c3137}
+ * @param centerIfNeeded - true, if the element should be aligned so it is centered within the visible area of the scrollable ancestor.
+ */
+if (!Element.prototype.scrollIntoViewIfNeeded) {
+ Element.prototype.scrollIntoViewIfNeeded = function (centerIfNeeded): void {
+ centerIfNeeded = arguments.length === 0 ? true : !!centerIfNeeded;
+
+ const parent = this.parentNode,
+ parentComputedStyle = window.getComputedStyle(parent, null),
+ parentBorderTopWidth = parseInt(parentComputedStyle.getPropertyValue('border-top-width')),
+ parentBorderLeftWidth = parseInt(parentComputedStyle.getPropertyValue('border-left-width')),
+ overTop = this.offsetTop - parent.offsetTop < parent.scrollTop,
+ overBottom = (this.offsetTop - parent.offsetTop + this.clientHeight - parentBorderTopWidth) > (parent.scrollTop + parent.clientHeight),
+ overLeft = this.offsetLeft - parent.offsetLeft < parent.scrollLeft,
+ overRight = (this.offsetLeft - parent.offsetLeft + this.clientWidth - parentBorderLeftWidth) > (parent.scrollLeft + parent.clientWidth),
+ alignWithTop = overTop && !overBottom;
+
+ if ((overTop || overBottom) && centerIfNeeded) {
+ parent.scrollTop = this.offsetTop - parent.offsetTop - parent.clientHeight / 2 - parentBorderTopWidth + this.clientHeight / 2;
+ }
+
+ if ((overLeft || overRight) && centerIfNeeded) {
+ parent.scrollLeft = this.offsetLeft - parent.offsetLeft - parent.clientWidth / 2 - parentBorderLeftWidth + this.clientWidth / 2;
+ }
+
+ if ((overTop || overBottom || overLeft || overRight) && !centerIfNeeded) {
+ this.scrollIntoView(alignWithTop);
+ }
+ };
+}
diff --git a/src/components/ui/toolbox.ts b/src/components/ui/toolbox.ts
index 2c813caf..b781aa28 100644
--- a/src/components/ui/toolbox.ts
+++ b/src/components/ui/toolbox.ts
@@ -1,15 +1,17 @@
-import $ from '../dom';
import * as _ from '../utils';
-import Flipper from '../flipper';
import { BlockToolAPI } from '../block';
-import I18n from '../i18n';
-import { I18nInternalNS } from '../i18n/namespace-internal';
import Shortcuts from '../utils/shortcuts';
-import Tooltip from '../utils/tooltip';
import BlockTool from '../tools/block';
import ToolsCollection from '../tools/collection';
import { API } from '../../../types';
import EventsDispatcher from '../utils/events';
+import Popover, { PopoverEvent } from '../utils/popover';
+import I18n from '../i18n';
+import { I18nInternalNS } from '../i18n/namespace-internal';
+
+/**
+ * @todo the first Tab on the Block — focus Plus Button, the second — focus Block Tunes Toggler, the third — focus next Block
+ */
/**
* Event that can be triggered by the Toolbox
@@ -31,6 +33,11 @@ export enum ToolboxEvent {
BlockAdded = 'toolbox-block-added',
}
+/**
+ * Available i18n dict keys that should be passed to the constructor
+ */
+type toolboxTextLabelsKeys = 'filter' | 'nothingFound';
+
/**
* Toolbox
* This UI element contains list of Block Tools available to be inserted
@@ -45,7 +52,7 @@ export default class Toolbox extends EventsDispatcher {
* @returns {boolean}
*/
public get isEmpty(): boolean {
- return this.displayedToolsCount === 0;
+ return this.toolsToBeDisplayed.length === 0;
}
/**
@@ -60,21 +67,29 @@ export default class Toolbox extends EventsDispatcher {
*/
private api: API;
+ /**
+ * Popover instance. There is a util for vertical lists.
+ */
+ private popover: Popover;
+
/**
* List of Tools available. Some of them will be shown in the Toolbox
*/
private tools: ToolsCollection;
+ /**
+ * Text labels used in the Toolbox. Should be passed from the i18n module
+ */
+ private i18nLabels: Record;
+
/**
* Current module HTML Elements
*/
private nodes: {
toolbox: HTMLElement;
- buttons: HTMLElement[];
} = {
toolbox: null,
- buttons: [],
- }
+ };
/**
* CSS styles
@@ -84,34 +99,10 @@ export default class Toolbox extends EventsDispatcher {
private static get CSS(): { [name: string]: string } {
return {
toolbox: 'ce-toolbox',
- toolboxButton: 'ce-toolbox__button',
- toolboxButtonActive: 'ce-toolbox__button--active',
- toolboxOpened: 'ce-toolbox--opened',
-
- buttonTooltip: 'ce-toolbox-button-tooltip',
- buttonShortcut: 'ce-toolbox-button-tooltip__shortcut',
+ toolboxOpenedTop: 'ce-toolbox--opened-top',
};
}
- /**
- * How many tools displayed in Toolbox
- *
- * @type {number}
- */
- private displayedToolsCount = 0;
-
- /**
- * Instance of class that responses for leafing buttons by arrows/tab
- *
- * @type {Flipper|null}
- */
- private flipper: Flipper = null;
-
- /**
- * Tooltip utility Instance
- */
- private tooltip: Tooltip;
-
/**
* Id of listener added used to remove it on destroy()
*/
@@ -124,67 +115,78 @@ export default class Toolbox extends EventsDispatcher {
* @param options.api - Editor API methods
* @param options.tools - Tools available to check whether some of them should be displayed at the Toolbox or not
*/
- constructor({ api, tools }) {
+ constructor({ api, tools, i18nLabels }: {api: API; tools: ToolsCollection; i18nLabels: Record}) {
super();
this.api = api;
this.tools = tools;
-
- this.tooltip = new Tooltip();
- }
-
- /**
- * Returns true if the Toolbox has the Flipper activated and the Flipper has selected button
- */
- public get flipperHasFocus(): boolean {
- return this.flipper && this.flipper.currentItem !== null;
+ this.i18nLabels = i18nLabels;
}
/**
* Makes the Toolbox
*/
public make(): Element {
- this.nodes.toolbox = $.make('div', Toolbox.CSS.toolbox);
+ this.popover = new Popover({
+ className: Toolbox.CSS.toolbox,
+ searchable: true,
+ filterLabel: this.i18nLabels.filter,
+ nothingFoundLabel: this.i18nLabels.nothingFound,
+ items: this.toolsToBeDisplayed.map(tool => {
+ return {
+ icon: tool.toolbox.icon,
+ label: I18n.t(I18nInternalNS.toolNames, tool.toolbox.title || _.capitalize(tool.name)),
+ name: tool.name,
+ onClick: (item): void => {
+ this.toolButtonActivated(tool.name);
+ },
+ secondaryLabel: tool.shortcut ? _.beautifyShortcut(tool.shortcut) : '',
+ };
+ }),
+ });
- this.addTools();
- this.enableFlipper();
+ this.popover.on(PopoverEvent.OverlayClicked, this.onOverlayClicked);
+
+ /**
+ * Enable tools shortcuts
+ */
+ this.enableShortcuts();
+
+ this.nodes.toolbox = this.popover.getElement();
return this.nodes.toolbox;
}
+ /**
+ * Returns true if the Toolbox has the Flipper activated and the Flipper has selected button
+ */
+ public hasFocus(): boolean {
+ return this.popover.hasFocus();
+ }
+
/**
* Destroy Module
*/
public destroy(): void {
super.destroy();
- /**
- * Sometimes (in read-only mode) there is no Flipper
- */
- if (this.flipper) {
- this.flipper.deactivate();
- this.flipper = null;
- }
-
if (this.nodes && this.nodes.toolbox) {
this.nodes.toolbox.remove();
this.nodes.toolbox = null;
- this.nodes.buttons = [];
}
this.api.listeners.offById(this.clickListenerId);
this.removeAllShortcuts();
- this.tooltip.destroy();
+ this.popover.off(PopoverEvent.OverlayClicked, this.onOverlayClicked);
}
/**
* Toolbox Tool's button click handler
*
- * @param {MouseEvent|KeyboardEvent} event - event that activates toolbox button
- * @param {string} toolName - button to activate
+ * @param toolName - tool type to be activated
*/
- public toolButtonActivate(event: MouseEvent|KeyboardEvent, toolName: string): void {
+ public toolButtonActivated(toolName: string): void {
this.insertNewBlock(toolName);
}
@@ -196,24 +198,28 @@ export default class Toolbox extends EventsDispatcher {
return;
}
- this.emit(ToolboxEvent.Opened);
-
- this.nodes.toolbox.classList.add(Toolbox.CSS.toolboxOpened);
+ /**
+ * Open the popover above the button
+ * if there is not enough available space below it
+ */
+ if (!this.shouldOpenPopoverBottom) {
+ this.nodes.toolbox.style.setProperty('--popover-height', this.popover.calculateHeight() + 'px');
+ this.nodes.toolbox.classList.add(Toolbox.CSS.toolboxOpenedTop);
+ }
+ this.popover.show();
this.opened = true;
- this.flipper.activate();
+ this.emit(ToolboxEvent.Opened);
}
/**
* Close Toolbox
*/
public close(): void {
- this.emit(ToolboxEvent.Closed);
-
- this.nodes.toolbox.classList.remove(Toolbox.CSS.toolboxOpened);
-
+ this.popover.hide();
this.opened = false;
- this.flipper.deactivate();
+ this.nodes.toolbox.classList.remove(Toolbox.CSS.toolboxOpenedTop);
+ this.emit(ToolboxEvent.Closed);
}
/**
@@ -228,106 +234,65 @@ export default class Toolbox extends EventsDispatcher {
}
/**
- * Iterates available tools and appends them to the Toolbox
+ * Checks if there popover should be opened downwards.
+ * It happens in case there is enough space below or not enough space above
*/
- private addTools(): void {
- Array
+ private get shouldOpenPopoverBottom(): boolean {
+ const toolboxRect = this.nodes.toolbox.getBoundingClientRect();
+ const editorElementRect = this.api.ui.nodes.redactor.getBoundingClientRect();
+ const popoverHeight = this.popover.calculateHeight();
+ const popoverPotentialBottomEdge = toolboxRect.top + popoverHeight;
+ const popoverPotentialTopEdge = toolboxRect.top - popoverHeight;
+ const bottomEdgeForComparison = Math.min(window.innerHeight, editorElementRect.bottom);
+
+ return popoverPotentialTopEdge < editorElementRect.top || popoverPotentialBottomEdge <= bottomEdgeForComparison;
+ }
+
+ /**
+ * Handles overlay click
+ */
+ private onOverlayClicked = (): void => {
+ this.close();
+ }
+
+ /**
+ * Returns list of tools that enables the Toolbox (by specifying the 'toolbox' getter)
+ */
+ @_.cacheable
+ private get toolsToBeDisplayed(): BlockTool[] {
+ return Array
.from(this.tools.values())
- .forEach((tool) => this.addTool(tool));
+ .filter(tool => {
+ const toolToolboxSettings = tool.toolbox;
+
+ /**
+ * Skip tools that don't pass 'toolbox' property
+ */
+ if (!toolToolboxSettings) {
+ return false;
+ }
+
+ if (toolToolboxSettings && !toolToolboxSettings.icon) {
+ _.log('Toolbar icon is missed. Tool %o skipped', 'warn', tool.name);
+
+ return false;
+ }
+
+ return true;
+ });
}
/**
- * Append Tool to the Toolbox
- *
- * @param {BlockToolConstructable} tool - BlockTool object
+ * Iterate all tools and enable theirs shortcuts if specified
*/
- private addTool(tool: BlockTool): void {
- const toolToolboxSettings = tool.toolbox;
+ private enableShortcuts(): void {
+ this.toolsToBeDisplayed.forEach((tool: BlockTool) => {
+ const shortcut = tool.shortcut;
- /**
- * Skip tools that don't pass 'toolbox' property
- */
- if (!toolToolboxSettings) {
- return;
- }
-
- if (toolToolboxSettings && !toolToolboxSettings.icon) {
- _.log('Toolbar icon is missed. Tool %o skipped', 'warn', tool.name);
-
- return;
- }
-
- /**
- * @todo Add checkup for the render method
- */
- // if (typeof tool.render !== 'function') {
- // _.log('render method missed. Tool %o skipped', 'warn', tool);
- // return;
- // }
-
- const button = $.make('li', [ Toolbox.CSS.toolboxButton ]);
-
- button.dataset.tool = tool.name;
- button.innerHTML = toolToolboxSettings.icon;
-
- $.append(this.nodes.toolbox, button);
-
- this.nodes.toolbox.appendChild(button);
- this.nodes.buttons.push(button);
-
- /**
- * Add click listener
- */
- this.clickListenerId = this.api.listeners.on(button, 'click', (event: KeyboardEvent|MouseEvent) => {
- this.toolButtonActivate(event, tool.name);
+ if (shortcut) {
+ this.enableShortcutForTool(tool.name, shortcut);
+ }
});
-
- /**
- * Add listeners to show/hide toolbox tooltip
- */
- const tooltipContent = this.drawTooltip(tool);
-
- this.tooltip.onHover(button, tooltipContent, {
- placement: 'bottom',
- hidingDelay: 200,
- });
-
- const shortcut = tool.shortcut;
-
- if (shortcut) {
- this.enableShortcut(tool.name, shortcut);
- }
-
- /** Increment Tools count */
- this.displayedToolsCount++;
- }
-
- /**
- * Draw tooltip for toolbox tools
- *
- * @param tool - BlockTool object
- * @returns {HTMLElement}
- */
- private drawTooltip(tool: BlockTool): HTMLElement {
- const toolboxSettings = tool.toolbox || {};
- const name = I18n.t(I18nInternalNS.toolNames, toolboxSettings.title || tool.name);
-
- let shortcut = tool.shortcut;
-
- const tooltip = $.make('div', Toolbox.CSS.buttonTooltip);
- const hint = document.createTextNode(_.capitalize(name));
-
- tooltip.appendChild(hint);
-
- if (shortcut) {
- shortcut = _.beautifyShortcut(shortcut);
-
- tooltip.appendChild($.make('div', Toolbox.CSS.buttonShortcut, {
- textContent: shortcut,
- }));
- }
-
- return tooltip;
}
/**
@@ -336,7 +301,7 @@ export default class Toolbox extends EventsDispatcher {
* @param {string} toolName - Tool name
* @param {string} shortcut - shortcut according to the ShortcutData Module format
*/
- private enableShortcut(toolName: string, shortcut: string): void {
+ private enableShortcutForTool(toolName: string, shortcut: string): void {
Shortcuts.add({
name: shortcut,
on: this.api.ui.nodes.redactor,
@@ -352,26 +317,12 @@ export default class Toolbox extends EventsDispatcher {
* Fired when the Read-Only mode is activated
*/
private removeAllShortcuts(): void {
- Array
- .from(this.tools.values())
- .forEach((tool) => {
- const shortcut = tool.shortcut;
+ this.toolsToBeDisplayed.forEach((tool: BlockTool) => {
+ const shortcut = tool.shortcut;
- if (shortcut) {
- Shortcuts.remove(this.api.ui.nodes.redactor, shortcut);
- }
- });
- }
-
- /**
- * Creates Flipper instance to be able to leaf tools
- */
- private enableFlipper(): void {
- const tools = Array.from(this.nodes.toolbox.childNodes) as HTMLElement[];
-
- this.flipper = new Flipper({
- items: tools,
- focusedItemClass: Toolbox.CSS.toolboxButtonActive,
+ if (shortcut) {
+ Shortcuts.remove(this.api.ui.nodes.redactor, shortcut);
+ }
});
}
diff --git a/src/components/utils.ts b/src/components/utils.ts
index e1e756f6..2f1650bf 100644
--- a/src/components/utils.ts
+++ b/src/components/utils.ts
@@ -762,3 +762,20 @@ export function cacheable 1));
\ No newline at end of file
diff --git a/src/components/utils/popover.ts b/src/components/utils/popover.ts
new file mode 100644
index 00000000..0a2c30c7
--- /dev/null
+++ b/src/components/utils/popover.ts
@@ -0,0 +1,417 @@
+import Dom from '../dom';
+import Listeners from './listeners';
+import Flipper from '../flipper';
+import SearchInput from './search-input';
+import EventsDispatcher from './events';
+import { isMobileScreen, keyCodes, cacheable } from '../utils';
+import ScrollLocker from './scroll-locker';
+
+/**
+ * Describe parameters for rendering the single item of Popover
+ */
+export interface PopoverItem {
+ /**
+ * Item icon to be appeared near a title
+ */
+ icon: string;
+
+ /**
+ * Displayed text
+ */
+ label: string;
+
+ /**
+ * Item name
+ * Used in data attributes needed for cypress tests
+ */
+ name?: string;
+
+ /**
+ * Additional displayed text
+ */
+ secondaryLabel?: string;
+
+ /**
+ * Itm click handler
+ *
+ * @param item - clicked item
+ */
+ onClick: (item: PopoverItem) => void;
+}
+
+/**
+ * Event that can be triggered by the Popover
+ */
+export enum PopoverEvent {
+ /**
+ * When popover overlay is clicked
+ */
+ OverlayClicked = 'overlay-clicked',
+}
+
+/**
+ * Popover is the UI element for displaying vertical lists
+ */
+export default class Popover extends EventsDispatcher {
+ /**
+ * Items list to be displayed
+ */
+ private readonly items: PopoverItem[];
+
+ /**
+ * Stores the visibility state.
+ */
+ private isShown = false;
+
+ /**
+ * Created nodes
+ */
+ private nodes: {
+ wrapper: HTMLElement;
+ popover: HTMLElement;
+ items: HTMLElement;
+ nothingFound: HTMLElement;
+ overlay: HTMLElement;
+ } = {
+ wrapper: null,
+ popover: null,
+ items: null,
+ nothingFound: null,
+ overlay: null,
+ }
+
+ /**
+ * Additional wrapper's class name
+ */
+ private readonly className: string;
+
+ /**
+ * Listeners util instance
+ */
+ private listeners: Listeners;
+
+ /**
+ * Flipper - module for keyboard iteration between elements
+ */
+ private flipper: Flipper;
+
+ /**
+ * Pass true to enable local search field
+ */
+ private readonly searchable: boolean;
+
+ /**
+ * Instance of the Search Input
+ */
+ private search: SearchInput;
+
+ /**
+ * Label for the 'Filter' placeholder
+ */
+ private readonly filterLabel: string;
+
+ /**
+ * Label for the 'Nothing found' message
+ */
+ private readonly nothingFoundLabel: string;
+
+ /**
+ * Style classes
+ */
+ private static get CSS(): {
+ popover: string;
+ popoverOpened: string;
+ itemsWrapper: string;
+ item: string;
+ itemHidden: string;
+ itemFocused: string;
+ itemLabel: string;
+ itemIcon: string;
+ itemSecondaryLabel: string;
+ noFoundMessage: string;
+ noFoundMessageShown: string;
+ popoverOverlay: string;
+ popoverOverlayHidden: string;
+ } {
+ return {
+ popover: 'ce-popover',
+ popoverOpened: 'ce-popover--opened',
+ itemsWrapper: 'ce-popover__items',
+ item: 'ce-popover__item',
+ itemHidden: 'ce-popover__item--hidden',
+ itemFocused: 'ce-popover__item--focused',
+ itemLabel: 'ce-popover__item-label',
+ itemIcon: 'ce-popover__item-icon',
+ itemSecondaryLabel: 'ce-popover__item-secondary-label',
+ noFoundMessage: 'ce-popover__no-found',
+ noFoundMessageShown: 'ce-popover__no-found--shown',
+ popoverOverlay: 'ce-popover__overlay',
+ popoverOverlayHidden: 'ce-popover__overlay--hidden',
+ };
+ }
+
+ /**
+ * ScrollLocker instance
+ */
+ private scrollLocker = new ScrollLocker()
+
+ /**
+ * Creates the Popover
+ *
+ * @param options - config
+ * @param options.items - config for items to be displayed
+ * @param options.className - additional class name to be added to the popover wrapper
+ * @param options.filterLabel - label for the search Field
+ * @param options.nothingFoundLabel - label of the 'nothing found' message
+ */
+ constructor({ items, className, searchable, filterLabel, nothingFoundLabel }: {
+ items: PopoverItem[];
+ className?: string;
+ searchable?: boolean;
+ filterLabel: string;
+ nothingFoundLabel: string;
+ }) {
+ super();
+ this.items = items;
+ this.className = className || '';
+ this.searchable = searchable;
+ this.listeners = new Listeners();
+
+ this.filterLabel = filterLabel;
+ this.nothingFoundLabel = nothingFoundLabel;
+
+ this.render();
+ this.enableFlipper();
+ }
+
+ /**
+ * Returns rendered wrapper
+ */
+ public getElement(): HTMLElement {
+ return this.nodes.wrapper;
+ }
+
+ /**
+ * Shows the Popover
+ */
+ public show(): void {
+ /**
+ * Clear search and items scrolling
+ */
+ this.search.clear();
+ this.nodes.items.scrollTop = 0;
+
+ this.nodes.popover.classList.add(Popover.CSS.popoverOpened);
+ this.nodes.overlay.classList.remove(Popover.CSS.popoverOverlayHidden);
+ this.flipper.activate();
+
+ if (this.searchable) {
+ window.requestAnimationFrame(() => {
+ this.search.focus();
+ });
+ }
+
+ if (isMobileScreen()) {
+ this.scrollLocker.lock();
+ }
+
+ this.isShown = true;
+ }
+
+ /**
+ * Hides the Popover
+ */
+ public hide(): void {
+ /**
+ * If it's already hidden, do nothing
+ * to prevent extra DOM operations
+ */
+ if (!this.isShown) {
+ return;
+ }
+
+ this.nodes.popover.classList.remove(Popover.CSS.popoverOpened);
+ this.nodes.overlay.classList.add(Popover.CSS.popoverOverlayHidden);
+ this.flipper.deactivate();
+
+ if (isMobileScreen()) {
+ this.scrollLocker.unlock();
+ }
+
+ this.isShown = false;
+ }
+
+ /**
+ * Clears memory
+ */
+ public destroy(): void {
+ this.listeners.removeAll();
+ }
+
+ /**
+ * Returns true if some item is focused
+ */
+ public hasFocus(): boolean {
+ return this.flipper.hasFocus();
+ }
+
+ /**
+ * Helps to calculate height of popover while it is not displayed on screen.
+ * Renders invisible clone of popover to get actual height.
+ */
+ @cacheable
+ public calculateHeight(): number {
+ let height = 0;
+ 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;
+ }
+
+ /**
+ * Makes the UI
+ */
+ private render(): void {
+ this.nodes.wrapper = Dom.make('div', this.className);
+ this.nodes.popover = Dom.make('div', Popover.CSS.popover);
+ this.nodes.wrapper.appendChild(this.nodes.popover);
+
+ this.nodes.overlay = Dom.make('div', [Popover.CSS.popoverOverlay, Popover.CSS.popoverOverlayHidden]);
+ this.nodes.wrapper.appendChild(this.nodes.overlay);
+
+ if (this.searchable) {
+ this.addSearch(this.nodes.popover);
+ }
+
+ this.nodes.items = Dom.make('div', Popover.CSS.itemsWrapper);
+ this.items.forEach(item => {
+ this.nodes.items.appendChild(this.createItem(item));
+ });
+
+ this.nodes.popover.appendChild(this.nodes.items);
+ this.nodes.nothingFound = Dom.make('div', [ Popover.CSS.noFoundMessage ], {
+ textContent: this.nothingFoundLabel,
+ });
+
+ this.nodes.popover.appendChild(this.nodes.nothingFound);
+
+ this.listeners.on(this.nodes.popover, 'click', (event: KeyboardEvent|MouseEvent) => {
+ const clickedItem = (event.target as HTMLElement).closest(`.${Popover.CSS.item}`) as HTMLElement;
+
+ if (clickedItem) {
+ this.itemClicked(clickedItem);
+ }
+ });
+
+ this.listeners.on(this.nodes.overlay, 'click', () => {
+ this.emit(PopoverEvent.OverlayClicked);
+ });
+ }
+
+ /**
+ * Adds the s4arch field to passed element
+ *
+ * @param holder - where to append search input
+ */
+ private addSearch(holder: HTMLElement): void {
+ this.search = new SearchInput({
+ items: this.items,
+ placeholder: this.filterLabel,
+ onSearch: (filteredItems): void => {
+ const itemsVisible = [];
+
+ this.items.forEach((item, index) => {
+ const itemElement = this.nodes.items.children[index];
+
+ if (filteredItems.includes(item)) {
+ itemsVisible.push(itemElement);
+ itemElement.classList.remove(Popover.CSS.itemHidden);
+ } else {
+ itemElement.classList.add(Popover.CSS.itemHidden);
+ }
+ });
+
+ this.nodes.nothingFound.classList.toggle(Popover.CSS.noFoundMessageShown, itemsVisible.length === 0);
+
+ /**
+ * Update flipper items with only visible
+ */
+ this.flipper.deactivate();
+ this.flipper.activate(itemsVisible);
+ this.flipper.focusFirst();
+ },
+ });
+
+ const searchField = this.search.getElement();
+
+ holder.appendChild(searchField);
+ }
+
+ /**
+ * Renders the single item
+ *
+ * @param item - item data to be rendered
+ */
+ private createItem(item: PopoverItem): HTMLElement {
+ const el = Dom.make('div', Popover.CSS.item);
+
+ el.dataset.itemName = item.name;
+ const label = Dom.make('div', Popover.CSS.itemLabel, {
+ innerHTML: item.label,
+ });
+
+ if (item.icon) {
+ el.appendChild(Dom.make('div', Popover.CSS.itemIcon, {
+ innerHTML: item.icon,
+ }));
+ }
+
+ el.appendChild(label);
+
+ if (item.secondaryLabel) {
+ el.appendChild(Dom.make('div', Popover.CSS.itemSecondaryLabel, {
+ textContent: item.secondaryLabel,
+ }));
+ }
+
+ return el;
+ }
+
+ /**
+ * Item click handler
+ *
+ * @param itemEl - clicked item
+ */
+ private itemClicked(itemEl: HTMLElement): void {
+ const allItems = this.nodes.wrapper.querySelectorAll(`.${Popover.CSS.item}`);
+ const itemIndex = Array.from(allItems).indexOf(itemEl);
+ const clickedItem = this.items[itemIndex];
+
+ clickedItem.onClick(clickedItem);
+ }
+
+ /**
+ * Creates Flipper instance to be able to leaf tools
+ */
+ private enableFlipper(): void {
+ const tools = Array.from(this.nodes.wrapper.querySelectorAll(`.${Popover.CSS.item}`)) as HTMLElement[];
+
+ this.flipper = new Flipper({
+ items: tools,
+ focusedItemClass: Popover.CSS.itemFocused,
+ allowedKeys: [
+ keyCodes.TAB,
+ keyCodes.UP,
+ keyCodes.DOWN,
+ keyCodes.ENTER,
+ ],
+ });
+ }
+}
diff --git a/src/components/utils/scroll-locker.ts b/src/components/utils/scroll-locker.ts
new file mode 100644
index 00000000..394ddcec
--- /dev/null
+++ b/src/components/utils/scroll-locker.ts
@@ -0,0 +1,64 @@
+import { isIosDevice } from '../utils';
+
+/**
+ * Utility allowing to lock body scroll on demand
+ */
+export default class ScrollLocker {
+ /**
+ * Style classes
+ */
+ private static CSS = {
+ scrollLocked: 'ce-scroll-locked',
+ scrollLockedHard: 'ce-scroll-locked--hard',
+ }
+
+ /**
+ * Stores scroll position, used for hard scroll lock
+ */
+ private scrollPosition: null|number
+
+ /**
+ * Locks body element scroll
+ */
+ public lock(): void {
+ if (isIosDevice) {
+ this.lockHard();
+ } else {
+ document.body.classList.add(ScrollLocker.CSS.scrollLocked);
+ }
+ }
+
+ /**
+ * Unlocks body element scroll
+ */
+ public unlock(): void {
+ if (isIosDevice) {
+ this.unlockHard();
+ } else {
+ document.body.classList.remove(ScrollLocker.CSS.scrollLocked);
+ }
+ }
+
+ /**
+ * Locks scroll in a hard way (via setting fixed position to body element)
+ */
+ private lockHard(): void {
+ this.scrollPosition = window.pageYOffset;
+ document.documentElement.style.setProperty(
+ '--window-scroll-offset',
+ `${this.scrollPosition}px`
+ );
+ document.body.classList.add(ScrollLocker.CSS.scrollLockedHard);
+ }
+
+ /**
+ * Unlocks hard scroll lock
+ */
+ private unlockHard(): void {
+ document.body.classList.remove(ScrollLocker.CSS.scrollLockedHard);
+ if (this.scrollPosition !== null) {
+ window.scrollTo(0, this.scrollPosition);
+ }
+ this.scrollPosition = null;
+ }
+}
diff --git a/src/components/utils/search-input.ts b/src/components/utils/search-input.ts
new file mode 100644
index 00000000..a5b8e3ae
--- /dev/null
+++ b/src/components/utils/search-input.ts
@@ -0,0 +1,152 @@
+import Dom from '../dom';
+import Listeners from './listeners';
+
+/**
+ * Item that could be searched
+ */
+interface SearchableItem {
+ label: string;
+}
+
+/**
+ * Provides search input element and search logic
+ */
+export default class SearchInput {
+ /**
+ * Input wrapper element
+ */
+ private wrapper: HTMLElement;
+
+ /**
+ * Editable input itself
+ */
+ private input: HTMLInputElement;
+
+ /**
+ * The instance of the Listeners util
+ */
+ private listeners: Listeners;
+
+ /**
+ * Items for local search
+ */
+ private items: SearchableItem[];
+
+ /**
+ * Current search query
+ */
+ private searchQuery: string;
+
+ /**
+ * Externally passed callback for the search
+ */
+ private readonly onSearch: (items: SearchableItem[]) => void;
+
+ /**
+ * Styles
+ */
+ private static get CSS(): {
+ input: string;
+ icon: string;
+ wrapper: string;
+ } {
+ return {
+ wrapper: 'cdx-search-field',
+ icon: 'cdx-search-field__icon',
+ input: 'cdx-search-field__input',
+ };
+ }
+
+ /**
+ * @param options - available config
+ * @param options.items - searchable items list
+ * @param options.onSearch - search callback
+ * @param options.placeholder - input placeholder
+ */
+ constructor({ items, onSearch, placeholder }: {
+ items: SearchableItem[];
+ onSearch: (items: SearchableItem[]) => void;
+ placeholder: string;
+ }) {
+ this.listeners = new Listeners();
+ this.items = items;
+ this.onSearch = onSearch;
+
+ this.render(placeholder);
+ }
+
+ /**
+ * Returns search field element
+ */
+ public getElement(): HTMLElement {
+ return this.wrapper;
+ }
+
+ /**
+ * Sets focus to the input
+ */
+ public focus(): void {
+ this.input.focus();
+ }
+
+ /**
+ * Clears search query and results
+ */
+ public clear(): void {
+ this.input.value = '';
+ this.searchQuery = '';
+ this.onSearch(this.foundItems);
+ }
+
+ /**
+ * Clears memory
+ */
+ public destroy(): void {
+ 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);
+ const icon = Dom.svg('search', 16, 16);
+
+ this.input = Dom.make('input', SearchInput.CSS.input, {
+ placeholder,
+ }) as HTMLInputElement;
+
+ iconWrapper.appendChild(icon);
+ this.wrapper.appendChild(iconWrapper);
+ this.wrapper.appendChild(this.input);
+
+ this.listeners.on(this.input, 'input', () => {
+ this.searchQuery = this.input.value;
+
+ this.onSearch(this.foundItems);
+ });
+ }
+
+ /**
+ * Returns list of found items for the current search query
+ */
+ private get foundItems(): SearchableItem[] {
+ return this.items.filter(item => this.checkItem(item));
+ }
+
+ /**
+ * Contains logic for checking whether passed item conforms the search query
+ *
+ * @param item - item to be checked
+ */
+ private checkItem(item: SearchableItem): boolean {
+ const text = item.label.toLowerCase();
+ const query = this.searchQuery.toLowerCase();
+
+ return text.includes(query);
+ }
+}
diff --git a/src/styles/animations.css b/src/styles/animations.css
index fced4886..c8189902 100644
--- a/src/styles/animations.css
+++ b/src/styles/animations.css
@@ -117,3 +117,20 @@
transform: translateY(0);
}
}
+
+@keyframes panelShowingMobile {
+ from {
+ opacity: 0;
+ transform: translateY(14px) scale(0.98);
+ }
+
+ 70% {
+ opacity: 1;
+ transform: translateY(-4px);
+ }
+
+ to {
+
+ transform: translateY(0);
+ }
+}
diff --git a/src/styles/block.css b/src/styles/block.css
index 55bbc142..fb68133e 100644
--- a/src/styles/block.css
+++ b/src/styles/block.css
@@ -1,4 +1,17 @@
+@keyframes fade-in {
+ from {
+ opacity: 0;
+ }
+
+ to {
+ opacity: 1;
+ }
+}
+
.ce-block {
+ animation: fade-in 300ms ease;
+ animation-fill-mode: initial;
+
&:first-of-type {
margin-top: 0;
}
diff --git a/src/styles/input.css b/src/styles/input.css
new file mode 100644
index 00000000..1c94d8fe
--- /dev/null
+++ b/src/styles/input.css
@@ -0,0 +1,46 @@
+.cdx-search-field {
+ --icon-margin-right: 10px;
+
+ background: rgba(232,232,235,0.49);
+ border: 1px solid rgba(226,226,229,0.20);
+ border-radius: 6px;
+ padding: 2px;
+ display: grid;
+ grid-template-columns: auto auto 1fr;
+ grid-template-rows: auto;
+
+ &__icon {
+ width: var(--toolbox-buttons-size);
+ height: var(--toolbox-buttons-size);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ margin-right: var(--icon-margin-right);
+
+ .icon {
+ width: 14px;
+ height: 14px;
+ color: var(--grayText);
+ flex-shrink: 0;
+ }
+ }
+
+
+ &__input {
+ font-size: 14px;
+ outline: none;
+ font-weight: 500;
+ font-family: inherit;
+ border: 0;
+ background: transparent;
+ margin: 0;
+ padding: 0;
+ line-height: 22px;
+ min-width: calc(100% - var(--toolbox-buttons-size) - var(--icon-margin-right));
+
+ &::placeholder {
+ color: var(--grayText);
+ font-weight: 500;
+ }
+ }
+}
diff --git a/src/styles/main.css b/src/styles/main.css
index 94fa26bd..e1adc48d 100644
--- a/src/styles/main.css
+++ b/src/styles/main.css
@@ -10,3 +10,5 @@
@import './export.css';
@import './stub.css';
@import './rtl.css';
+@import './popover.css';
+@import './input.css';
diff --git a/src/styles/popover.css b/src/styles/popover.css
new file mode 100644
index 00000000..207907a6
--- /dev/null
+++ b/src/styles/popover.css
@@ -0,0 +1,142 @@
+.ce-popover {
+ position: absolute;
+ opacity: 0;
+ will-change: opacity, transform;
+ display: flex;
+ flex-direction: column;
+ padding: 6px;
+ min-width: 200px;
+ overflow: hidden;
+ box-sizing: border-box;
+ flex-shrink: 0;
+ max-height: 0;
+
+ @apply --overlay-pane;
+
+ z-index: 4;
+ flex-wrap: nowrap;
+
+ &--opened {
+ opacity: 1;
+ max-height: 270px;
+ animation: panelShowing 100ms ease;
+
+ @media (--mobile) {
+ animation: panelShowingMobile 250ms ease;
+ }
+ }
+
+ &::-webkit-scrollbar {
+ width: 7px;
+ }
+
+ &::-webkit-scrollbar-thumb {
+ box-sizing: border-box;
+ box-shadow: inset 0 0 2px 2px var(--bg-light);
+ border: 3px solid transparent;
+ border-left-width: 0px;
+ border-top-width: 4px;
+ border-bottom-width: 4px;
+ }
+
+ @media (--mobile) {
+ position: fixed;
+ max-width: none;
+ min-width: auto;
+ left: 5px;
+ right: 5px;
+ bottom: calc(5px + env(safe-area-inset-bottom));
+ top: auto;
+ border-radius: 10px;
+ }
+
+ &__items {
+ overflow-y: auto;
+ overscroll-behavior: contain;
+
+ @media (--not-mobile) {
+ margin-top: 5px;
+ }
+ }
+
+ &__item {
+ @apply --popover-button;
+
+ &--focused {
+ @apply --button-focused;
+ }
+
+ &--hidden {
+ display: none;
+ }
+
+ &-icon {
+ @apply --tool-icon;
+ }
+
+ &-label {
+ &::after {
+ content: '';
+ width: 25px;
+ display: inline-block;
+ }
+ }
+
+ &-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;
+ }
+ }
+ }
+
+ &__no-found {
+ @apply --popover-button;
+
+ color: var(--grayText);
+ display: none;
+ cursor: default;
+
+ &--shown {
+ display: block;
+ }
+
+ &:hover {
+ background-color: transparent;
+ }
+ }
+
+ @media (--mobile) {
+ &__overlay {
+ position: fixed;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ background: var(--color-dark);
+ opacity: 0.5;
+ z-index: 3;
+ transition: opacity 0.12s ease-in;
+ will-change: opacity;
+ visibility: visible;
+ }
+
+ .cdx-search-field {
+ display: none;
+ }
+ }
+
+ &__overlay--hidden {
+ z-index: 0;
+ opacity: 0;
+ visibility: hidden;
+ }
+}
diff --git a/src/styles/settings.css b/src/styles/settings.css
index c6693e85..78622a0b 100644
--- a/src/styles/settings.css
+++ b/src/styles/settings.css
@@ -1,7 +1,7 @@
.ce-settings {
@apply --overlay-pane;
- right: -1px;
- top: 30px;
+ top: var(--toolbar-buttons-size);
+ left: 0;
min-width: 114px;
box-sizing: content-box;
diff --git a/src/styles/toolbar.css b/src/styles/toolbar.css
index ceceacb7..f3fef0ef 100644
--- a/src/styles/toolbar.css
+++ b/src/styles/toolbar.css
@@ -4,34 +4,18 @@
right: 0;
top: 0;
transition: opacity 100ms ease;
- will-change: opacity, transform;
- display: none;
+ will-change: opacity, top;
- @media (--mobile) {
- @apply --overlay-pane;
- padding: 3px;
- margin-top: 5px;
- }
+ display: none;
&--opened {
display: block;
-
- @media (--mobile){
- display: flex;
- }
}
&__content {
max-width: var(--content-width);
margin: 0 auto;
position: relative;
-
- @media (--mobile){
- display: flex;
- align-content: center;
- margin: 0;
- max-width: 100%;
- }
}
&__plus {
@@ -44,14 +28,9 @@
margin-top: 5px;
}
- &--hidden {
- display: none;
- }
-
@media (--mobile){
- display: inline-flex !important;
+ @apply --overlay-pane;
position: static;
- transform: none !important;
}
}
@@ -64,37 +43,37 @@
right: 100%;
opacity: 0;
display: flex;
-
- @media (--mobile){
- position: absolute;
- right: auto;
- top: 50%;
- transform: translateY(-50%);
- display: flex;
- align-items: center;
- }
+ padding-right: 5px;
&--opened {
opacity: 1;
}
- &-buttons {
- text-align: right;
+ @media (--mobile){
+ right: auto;
}
}
&__settings-btn {
@apply --toolbox-button;
- width: 18px;
- margin: 0 5px;
+ margin-left: 5px;
cursor: pointer;
user-select: none;
- }
-}
-.codex-editor--toolbox-opened .ce-toolbar__actions {
- display: none;
+ @media (--not-mobile){
+ width: 18px;
+ }
+
+ &--hidden {
+ display: none;
+ }
+
+ @media (--mobile){
+ @apply --overlay-pane;
+ position: static;
+ }
+ }
}
/**
diff --git a/src/styles/toolbox.css b/src/styles/toolbox.css
index c02c5937..d8b60210 100644
--- a/src/styles/toolbox.css
+++ b/src/styles/toolbox.css
@@ -1,44 +1,24 @@
.ce-toolbox {
- position: absolute;
- visibility: hidden;
- transition: opacity 100ms ease;
- will-change: opacity;
- display: flex;
- flex-direction: row;
+ --gap: 8px;
- @media (--mobile){
- position: static;
- transform: none !important;
- align-items: center;
- overflow-x: auto;
- }
+ @media (--not-mobile){
+ position: absolute;
+ top: calc(var(--toolbox-buttons-size) + var(--gap));
+ left: 0;
- &--opened {
- opacity: 1;
- visibility: visible;
- }
-
- &__button {
- @apply --toolbox-button;
- flex-shrink: 0;
- margin-left: 5px;
+ &--opened-top {
+ top: calc(-1 * (var(--gap) + var(--popover-height)));
+ }
}
}
-.ce-toolbox-button-tooltip {
- &__shortcut {
- opacity: 0.6;
- word-spacing: -3px;
- margin-top: 3px;
- }
-}
-
-/**
- * Styles for Narrow mode
- */
.codex-editor--narrow .ce-toolbox {
- @media (--not-mobile) {
- background: #fff;
- z-index: 2;
+ @media (--not-mobile){
+ left: auto;
+ right: 0;
+
+ .ce-popover {
+ right: 0;
+ }
}
}
diff --git a/src/styles/ui.css b/src/styles/ui.css
index 4a240994..e594e071 100644
--- a/src/styles/ui.css
+++ b/src/styles/ui.css
@@ -127,3 +127,14 @@
transform: rotate(360deg);
}
}
+
+.ce-scroll-locked {
+ overflow: hidden;
+}
+
+.ce-scroll-locked--hard {
+ overflow: hidden;
+ top: calc(-1 * var(--window-scroll-offset));
+ position: fixed;
+ width: 100%;
+}
\ No newline at end of file
diff --git a/src/styles/variables.css b/src/styles/variables.css
index d8528ea6..ca9b14b3 100644
--- a/src/styles/variables.css
+++ b/src/styles/variables.css
@@ -1,5 +1,9 @@
+/**
+ * Updating values in media queries should also include changes in utils.ts@isMobile
+ */
@custom-media --mobile (width <= 650px);
@custom-media --not-mobile (width >= 651px);
+@custom-media --can-hover (hover: hover);
:root {
/**
@@ -21,7 +25,7 @@
/**
* Gray icons hover
*/
- --color-dark: #1D202B;
+ --color-dark: #1D202B;
/**
* Blue icons
@@ -95,6 +99,11 @@
}
};
+ --button-focused: {
+ box-shadow: inset 0 0 0px 1px rgba(7, 161, 227, 0.08);
+ background: rgba(34, 186, 255, 0.08) !important;
+ };
+
/**
* Styles for Toolbox Buttons and Plus Button
*/
@@ -103,22 +112,25 @@
cursor: pointer;
width: var(--toolbox-buttons-size);
height: var(--toolbox-buttons-size);
- border-radius: 3px;
+ border-radius: 7px;
display: inline-flex;
justify-content: center;
align-items: center;
+ user-select: none;
@media (--mobile){
width: var(--toolbox-buttons-size--mobile);
height: var(--toolbox-buttons-size--mobile);
}
- &:hover,
- &--active {
- background-color: var(--bg-light);
+ @media (--can-hover) {
+ &:hover {
+ background-color: var(--bg-light);
+ }
}
- &--active{
+ &--active {
+ background-color: var(--bg-light);
animation: bounceIn 0.75s 1;
animation-fill-mode: forwards;
}
@@ -132,9 +144,9 @@
display: inline-flex;
align-items: center;
justify-content: center;
- width: 34px;
- height: 34px;
- line-height: 34px;
+ width: var(--toolbar-buttons-size);
+ height: var(--toolbar-buttons-size);
+ line-height: var(--toolbar-buttons-size);
padding: 0 !important;
text-align: center;
border-radius: 3px;
@@ -155,8 +167,7 @@
}
&--focused {
- box-shadow: inset 0 0 0px 1px rgba(7, 161, 227, 0.08);
- background: rgba(34, 186, 255, 0.08) !important;
+ @apply --button-focused;
&-animated {
animation-name: buttonClicked;
@@ -164,5 +175,66 @@
}
}
};
+
+ /**
+ * Element of the Toolbox. Has icon and label
+ */
+ --popover-button: {
+ display: grid;
+ grid-template-columns: auto auto 1fr;
+ grid-template-rows: auto;
+ justify-content: start;
+ white-space: nowrap;
+ padding: 3px;
+ font-size: 14px;
+ line-height: 20px;
+ font-weight: 500;
+ cursor: pointer;
+ align-items: center;
+ border-radius: 6px;
+
+ &:not(:last-of-type){
+ margin-bottom: 1px;
+ }
+
+ @media (--can-hover) {
+ &:hover {
+ background-color: var(--bg-light);
+ }
+ }
+
+ @media (--mobile) {
+ font-size: 16px;
+ padding: 4px;
+ }
+ };
+
+ /**
+ * Tool icon with border
+ */
+ --tool-icon: {
+ display: inline-flex;
+ width: var(--toolbox-buttons-size);
+ height: var(--toolbox-buttons-size);
+ border: 1px solid var(--color-gray-border);
+ border-radius: 5px;
+ align-items: center;
+ justify-content: center;
+ background: #fff;
+ box-sizing: border-box;
+ flex-shrink: 0;
+ margin-right: 10px;
+
+ @media (--mobile) {
+ width: var(--toolbox-buttons-size--mobile);
+ height: var(--toolbox-buttons-size--mobile);
+ border-radius: 8px;
+ }
+
+ svg {
+ width: 12px;
+ height: 12px;
+ }
+ }
}
diff --git a/test/cypress/tests/block-ids.spec.ts b/test/cypress/tests/block-ids.spec.ts
index 3fc1e209..e207e58f 100644
--- a/test/cypress/tests/block-ids.spec.ts
+++ b/test/cypress/tests/block-ids.spec.ts
@@ -31,7 +31,7 @@ describe.only('Block ids', () => {
.click();
cy.get('[data-cy=editorjs]')
- .get('li.ce-toolbox__button[data-tool=header]')
+ .get('div.ce-popover__item[data-item-name=header]')
.click();
cy.get('[data-cy=editorjs]')
diff --git a/test/cypress/tests/i18n.spec.ts b/test/cypress/tests/i18n.spec.ts
new file mode 100644
index 00000000..2f31d48c
--- /dev/null
+++ b/test/cypress/tests/i18n.spec.ts
@@ -0,0 +1,84 @@
+import Header from '@editorjs/header';
+import { ToolboxConfig } from '../../../types';
+
+/**
+ * Tool class allowing to test case when capitalized tool name is used as translation key if toolbox title is missing
+ */
+class TestTool {
+ /**
+ * Returns toolbox config without title
+ */
+ public static get toolbox(): ToolboxConfig {
+ return {
+ title: '',
+ icon: '',
+ };
+ }
+}
+
+describe('Editor i18n', () => {
+ context('Toolbox', () => {
+ it('should translate tool title in a toolbox', () => {
+ if (this && this.editorInstance) {
+ this.editorInstance.destroy();
+ }
+ const toolNamesDictionary = {
+ Heading: 'Заголовок',
+ };
+
+ cy.createEditor({
+ tools: {
+ header: Header,
+ },
+ i18n: {
+ messages: {
+ toolNames: toolNamesDictionary,
+ },
+ },
+ }).as('editorInstance');
+
+ cy.get('[data-cy=editorjs]')
+ .get('div.ce-block')
+ .click();
+
+ cy.get('[data-cy=editorjs]')
+ .get('div.ce-toolbar__plus')
+ .click();
+
+ cy.get('[data-cy=editorjs]')
+ .get('div.ce-popover__item[data-item-name=header]')
+ .should('contain.text', toolNamesDictionary.Heading);
+ });
+
+ it('should use capitalized tool name as translation key if toolbox title is missing', () => {
+ if (this && this.editorInstance) {
+ this.editorInstance.destroy();
+ }
+ const toolNamesDictionary = {
+ TestTool: 'ТестТул',
+ };
+
+ cy.createEditor({
+ tools: {
+ testTool: TestTool,
+ },
+ i18n: {
+ messages: {
+ toolNames: toolNamesDictionary,
+ },
+ },
+ }).as('editorInstance');
+ cy.get('[data-cy=editorjs]')
+ .get('div.ce-block')
+ .click();
+
+ cy.get('[data-cy=editorjs]')
+ .get('div.ce-toolbar__plus')
+ .click();
+
+ cy.get('[data-cy=editorjs]')
+ .get('div.ce-popover__item[data-item-name=testTool]')
+ .should('contain.text', toolNamesDictionary.TestTool);
+ });
+ });
+});
\ No newline at end of file
diff --git a/test/cypress/tests/initialization.spec.ts b/test/cypress/tests/initialization.spec.ts
index ada89c62..f4992bdb 100644
--- a/test/cypress/tests/initialization.spec.ts
+++ b/test/cypress/tests/initialization.spec.ts
@@ -25,4 +25,26 @@ describe('Editor basic initialization', () => {
.should('be.visible');
});
});
+
+ describe('Configuration', () => {
+ describe('readOnly', () => {
+ beforeEach(() => {
+ if (this && this.editorInstance) {
+ this.editorInstance.destroy();
+ }
+ });
+
+ it('should create editor without editing ability when true passed', () => {
+ cy.createEditor({
+ readOnly: true,
+ }).as('editorInstance');
+
+ cy.get('[data-cy=editorjs]')
+ .get('div.codex-editor')
+ .get('div.ce-paragraph')
+ .invoke('attr', 'contenteditable')
+ .should('eq', 'false');
+ });
+ });
+ });
});
diff --git a/test/cypress/tests/onchange.spec.ts b/test/cypress/tests/onchange.spec.ts
index 8d410b96..f4740c30 100644
--- a/test/cypress/tests/onchange.spec.ts
+++ b/test/cypress/tests/onchange.spec.ts
@@ -1,4 +1,6 @@
import Header from '@editorjs/header';
+import Code from '@editorjs/code';
+import Delimiter from '@editorjs/delimiter';
import { BlockMutationType } from '../../../types/events/block/mutation-type';
/**
@@ -16,6 +18,7 @@ describe('onChange callback', () => {
const config = {
tools: {
header: Header,
+ code: Code,
},
onChange: (api, event): void => {
console.log('something changed', api, event);
@@ -30,6 +33,32 @@ describe('onChange callback', () => {
cy.createEditor(config).as('editorInstance');
}
+ /**
+ * Creates Editor instance with save inside the onChange event.
+ *
+ * @param blocks - list of blocks to prefill the editor
+ */
+ function createEditorWithSave(blocks = null): void {
+ const config = {
+ tools: {
+ header: Header,
+ code: Code,
+ delimiter: Delimiter,
+ },
+ onChange: (api, event): void => {
+ console.log('something changed', api, event);
+ api.saver.save();
+ },
+ data: blocks ? {
+ blocks,
+ } : null,
+ };
+
+ cy.spy(config, 'onChange').as('onChange');
+
+ cy.createEditor(config).as('editorInstance');
+ }
+
/**
* EditorJS API is passed as the first parameter of the onChange callback
*/
@@ -47,7 +76,7 @@ describe('onChange callback', () => {
type: BlockMutationType.Added,
detail: {
target: {
- name: 'paragraph'
+ name: 'paragraph',
},
index: 0,
},
@@ -67,7 +96,7 @@ describe('onChange callback', () => {
type: BlockMutationType.Added,
detail: {
target: {
- name: 'paragraph'
+ name: 'paragraph',
},
index: 1,
},
@@ -85,7 +114,54 @@ describe('onChange callback', () => {
cy.get('@onChange').should('be.calledWithMatch', EditorJSApiMock, Cypress.sinon.match({
type: BlockMutationType.Changed,
detail: {
- index: 0
+ index: 0,
+ },
+ }));
+ });
+
+ it('should fire onChange callback on block insertion with save inside onChange', () => {
+ createEditorWithSave();
+
+ cy.get('[data-cy=editorjs]')
+ .get('div.ce-block')
+ .click();
+
+ cy.get('[data-cy=editorjs]')
+ .get('div.ce-toolbar__plus')
+ .click();
+
+ cy.get('[data-cy=editorjs]')
+ .get('div.ce-popover__item[data-item-name=delimiter]')
+ .click();
+
+ cy.get('@onChange').should('be.calledThrice');
+ cy.get('@onChange').should('be.calledWithMatch', EditorJSApiMock, Cypress.sinon.match({
+ type: BlockMutationType.Removed,
+ detail: {
+ index: 0,
+ target: {
+ name: 'paragraph',
+ },
+ },
+ }));
+
+ cy.get('@onChange').should('be.calledWithMatch', EditorJSApiMock, Cypress.sinon.match({
+ type: BlockMutationType.Added,
+ detail: {
+ index: 0,
+ target: {
+ name: 'delimiter',
+ },
+ },
+ }));
+
+ cy.get('@onChange').should('be.calledWithMatch', EditorJSApiMock, Cypress.sinon.match({
+ type: BlockMutationType.Added,
+ detail: {
+ index: 1,
+ target: {
+ name: 'paragraph',
+ },
},
}));
});
@@ -102,7 +178,7 @@ describe('onChange callback', () => {
.click();
cy.get('[data-cy=editorjs]')
- .get('li.ce-toolbox__button[data-tool=header]')
+ .get('div.ce-popover__item[data-item-name=header]')
.click();
cy.get('@onChange').should('be.calledTwice');
@@ -169,6 +245,14 @@ describe('onChange callback', () => {
it('should fire onChange callback when block is removed', () => {
createEditor();
+ /**
+ * The only block does not have Tune menu, so need to create at least 2 blocks to test deleting
+ */
+ cy.get('[data-cy=editorjs]')
+ .get('div.ce-block')
+ .click()
+ .type('some text');
+
cy.get('[data-cy=editorjs]')
.get('div.ce-block')
.click();
@@ -185,7 +269,7 @@ describe('onChange callback', () => {
cy.get('@onChange').should('be.calledWithMatch', EditorJSApiMock, Cypress.sinon.match({
type: BlockMutationType.Removed,
detail: {
- index: 0
+ index: 0,
},
}));
});
@@ -219,4 +303,24 @@ describe('onChange callback', () => {
},
}));
});
+
+ it('should fire onChange if something changed inside native input', () => {
+ createEditor([ {
+ type: 'code',
+ data: {
+ code: '',
+ },
+ } ]);
+
+ cy.get('[data-cy=editorjs')
+ .get('textarea')
+ .type('Some input to the textarea');
+
+ cy.get('@onChange').should('be.calledWithMatch', EditorJSApiMock, Cypress.sinon.match({
+ type: BlockMutationType.Changed,
+ detail: {
+ index: 0,
+ },
+ }));
+ });
});
diff --git a/test/cypress/tests/readOnly.spec.ts b/test/cypress/tests/readOnly.spec.ts
new file mode 100644
index 00000000..18dec6d2
--- /dev/null
+++ b/test/cypress/tests/readOnly.spec.ts
@@ -0,0 +1,50 @@
+import EditorJS, { EditorConfig } from '../../../types';
+
+describe('ReadOnly API spec', () => {
+ function createEditor(config?: EditorConfig): void {
+ const editorConfig = Object.assign({}, config || {});
+
+ cy.createEditor(editorConfig).as('editorInstance');
+ }
+
+ it('should return correct value for readOnly.isEnabled when editor initialized in normal mode', () => {
+ createEditor();
+
+ cy
+ .get('@editorInstance')
+ .then(editor => {
+ expect(editor.readOnly.isEnabled).to.be.false;
+ });
+ });
+
+ it('should return correct value for readOnly.isEnabled when editor initialized in read-only mode', () => {
+ createEditor({
+ readOnly: true,
+ });
+
+ cy
+ .get('@editorInstance')
+ .then(editor => {
+ expect(editor.readOnly.isEnabled).to.be.true;
+ });
+ });
+
+ it('should return correct value for readOnly.isEnabled when read-only mode toggled', () => {
+ createEditor();
+
+ cy
+ .get('@editorInstance')
+ .then(async editor => {
+ expect(editor.readOnly.isEnabled).to.be.false;
+
+ editor.readOnly.toggle()
+ .then(() => {
+ expect(editor.readOnly.isEnabled).to.be.true;
+ })
+ .then(() => editor.readOnly.toggle())
+ .then(() => {
+ expect(editor.readOnly.isEnabled).to.be.false;
+ });
+ });
+ });
+});
diff --git a/yarn.lock b/yarn.lock
index 96bd0aeb..20477c1e 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1423,6 +1423,16 @@
debug "^3.1.0"
lodash.once "^4.1.1"
+"@editorjs/code@^2.7.0":
+ version "2.7.0"
+ resolved "https://registry.yarnpkg.com/@editorjs/code/-/code-2.7.0.tgz#0a21de9ac15e4533605ffcc80969513ab2142ac5"
+ integrity sha512-gXtTce915fHp3H9i4IqhTxEDbbkT2heFfYiW/bhFHsCmZDpyGzfZxi94kmrEqDmbxXjV49ZZ6GZbR26If13KJw==
+
+"@editorjs/delimiter@^1.2.0":
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/@editorjs/delimiter/-/delimiter-1.2.0.tgz#5075f1a3e68765cfb6aec8694b316d81e2b41607"
+ integrity sha512-GKsCFPk85vH5FuCuVQ48NTLc9hk0T3DsBH9zABaicTYIJayFcUa8N4/Y+L3i4tduzDqqyvoxkv+5n43GmC5gEA==
+
"@editorjs/header@^2.6.1":
version "2.6.1"
resolved "https://registry.yarnpkg.com/@editorjs/header/-/header-2.6.1.tgz#454a46e4dbb32ae3aa1db4d22b0ddf2cc36c3134"
@@ -2385,24 +2395,16 @@ browserify@^16.1.0:
vm-browserify "^1.0.0"
xtend "^4.0.0"
-browserslist@^4.0.0, browserslist@^4.11.1, browserslist@^4.6.4, browserslist@^4.8.5, browserslist@^4.9.1:
- version "4.12.0"
- resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.12.0.tgz#06c6d5715a1ede6c51fc39ff67fd647f740b656d"
+browserslist@^4.0.0, browserslist@^4.11.1, browserslist@^4.14.5, browserslist@^4.16.3, browserslist@^4.6.0, browserslist@^4.6.4, browserslist@^4.8.5, browserslist@^4.9.1:
+ version "4.20.3"
+ resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.20.3.tgz#eb7572f49ec430e054f56d52ff0ebe9be915f8bf"
+ integrity sha512-NBhymBQl1zM0Y5dQT/O+xiLP9/rzOIQdKM/eMJBAq7yBgaB6krIYLGejrwVYnSHZdqjscB1SPuAjHwxjvN6Wdg==
dependencies:
- caniuse-lite "^1.0.30001043"
- electron-to-chromium "^1.3.413"
- node-releases "^1.1.53"
- pkg-up "^2.0.0"
-
-browserslist@^4.14.5, browserslist@^4.16.3, browserslist@^4.6.0:
- version "4.16.3"
- resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.16.3.tgz#340aa46940d7db878748567c5dea24a48ddf3717"
- dependencies:
- caniuse-lite "^1.0.30001181"
- colorette "^1.2.1"
- electron-to-chromium "^1.3.649"
+ caniuse-lite "^1.0.30001332"
+ electron-to-chromium "^1.4.118"
escalade "^3.1.1"
- node-releases "^1.1.70"
+ node-releases "^2.0.3"
+ picocolors "^1.0.0"
buffer-crc32@~0.2.3:
version "0.2.13"
@@ -2504,8 +2506,9 @@ cache-base@^1.0.1:
unset-value "^1.0.0"
cached-path-relative@^1.0.0, cached-path-relative@^1.0.2:
- version "1.0.2"
- resolved "https://registry.yarnpkg.com/cached-path-relative/-/cached-path-relative-1.0.2.tgz#a13df4196d26776220cc3356eb147a52dba2c6db"
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/cached-path-relative/-/cached-path-relative-1.1.0.tgz#865576dfef39c0d6a7defde794d078f5308e3ef3"
+ integrity sha512-WF0LihfemtesFcJgO7xfOoOcnWzY/QHR4qeDqV44jPU3HTI54+LnfXK3SA27AVVGCdZFgjjFFaqUA9Jx7dMJZA==
cachedir@^2.3.0:
version "2.3.0"
@@ -2565,13 +2568,10 @@ caniuse-api@^3.0.0:
lodash.memoize "^4.1.2"
lodash.uniq "^4.5.0"
-caniuse-lite@^1.0.0, caniuse-lite@^1.0.30000981, caniuse-lite@^1.0.30001039, caniuse-lite@^1.0.30001043:
- version "1.0.30001048"
- resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001048.tgz#4bb4f1bc2eb304e5e1154da80b93dee3f1cf447e"
-
-caniuse-lite@^1.0.30001181:
- version "1.0.30001202"
- resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001202.tgz#4cb3bd5e8a808e8cd89e4e66c549989bc8137201"
+caniuse-lite@^1.0.0, caniuse-lite@^1.0.30000981, caniuse-lite@^1.0.30001039, caniuse-lite@^1.0.30001332:
+ version "1.0.30001332"
+ resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001332.tgz#39476d3aa8d83ea76359c70302eafdd4a1d727dd"
+ integrity sha512-10T30NYOEQtN6C11YGg411yebhvpnC6Z102+B95eAsN0oB6KUs01ivE8u+G6FMIRtIrVlYXhL+LUwQ3/hXwDWw==
caseless@~0.12.0:
version "0.12.0"
@@ -2808,9 +2808,10 @@ codex-notifier@^1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/codex-notifier/-/codex-notifier-1.1.2.tgz#a733079185f4c927fa296f1d71eb8753fe080895"
-codex-tooltip@^1.0.4:
- version "1.0.4"
- resolved "https://registry.yarnpkg.com/codex-tooltip/-/codex-tooltip-1.0.4.tgz#bb8c6e0fe7accc68ce79cdcb7c71bf7b4bf1317a"
+codex-tooltip@^1.0.5:
+ version "1.0.5"
+ resolved "https://registry.yarnpkg.com/codex-tooltip/-/codex-tooltip-1.0.5.tgz#ba25fd5b3a58ba2f73fd667c2b46987ffd1edef2"
+ integrity sha512-IuA8LeyLU5p1B+HyhOsqR6oxyFQ11k3i9e9aXw40CrHFTRO2Y1npNBVU3W1SvhKAbUU7R/YikUBdcYFP0RcJag==
coffeeify@3.0.1:
version "3.0.1"
@@ -2855,8 +2856,9 @@ color-name@^1.0.0, color-name@~1.1.4:
resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2"
color-string@^1.5.2:
- version "1.5.3"
- resolved "https://registry.yarnpkg.com/color-string/-/color-string-1.5.3.tgz#c9bbc5f01b58b5492f3d6857459cb6590ce204cc"
+ version "1.9.1"
+ resolved "https://registry.yarnpkg.com/color-string/-/color-string-1.9.1.tgz#4467f9146f036f855b764dfb5bf8582bf342c7a4"
+ integrity sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==
dependencies:
color-name "^1.0.0"
simple-swizzle "^0.2.2"
@@ -2868,10 +2870,6 @@ color@^3.0.0:
color-convert "^1.9.1"
color-string "^1.5.2"
-colorette@^1.2.1:
- version "1.2.2"
- resolved "https://registry.yarnpkg.com/colorette/-/colorette-1.2.2.tgz#cbcc79d5e99caea2dbf10eb3a26fd8b3e6acfa94"
-
colors@^1.1.2:
version "1.4.0"
resolved "https://registry.yarnpkg.com/colors/-/colors-1.4.0.tgz#c50491479d4c1bdaed2c9ced32cf7c7dc2360f78"
@@ -3558,13 +3556,10 @@ ecc-jsbn@~0.1.1:
jsbn "~0.1.0"
safer-buffer "^2.1.0"
-electron-to-chromium@^1.3.413:
- version "1.3.418"
- resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.418.tgz#840021191f466b803a873e154113620c9f53cec6"
-
-electron-to-chromium@^1.3.649:
- version "1.3.690"
- resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.690.tgz#54df63ec42fba6b8e9e05fe4be52caeeedb6e634"
+electron-to-chromium@^1.4.118:
+ version "1.4.122"
+ resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.122.tgz#56e518e8c4433876b01d4460eac0f653841ed510"
+ integrity sha512-VuLNxTIt8sBWIT2sd186xPd18Y8KcK8myLd9nMdSJOYZwFUxxbLVmX/T1VX+qqaytRlrYYQv39myxJdXtu7Ysw==
elegant-spinner@^1.0.1:
version "1.0.1"
@@ -3679,6 +3674,7 @@ es6-promise@^2.3.0:
escalade@^3.1.1:
version "3.1.1"
resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40"
+ integrity sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==
escape-string-regexp@4.0.0:
version "4.0.0"
@@ -4884,6 +4880,7 @@ is-arrayish@^0.2.1:
is-arrayish@^0.3.1:
version "0.3.2"
resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.3.2.tgz#4574a2ae56f7ab206896fb431eaeed066fdf8f03"
+ integrity sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==
is-binary-path@^1.0.0:
version "1.0.1"
@@ -5805,8 +5802,9 @@ minimist-options@^4.0.1:
is-plain-obj "^1.1.0"
minimist@^1.1.0, minimist@^1.1.1, minimist@^1.2.0, minimist@^1.2.5:
- version "1.2.5"
- resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602"
+ version "1.2.6"
+ resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44"
+ integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==
minipass-collect@^1.0.2:
version "1.0.2"
@@ -5919,8 +5917,9 @@ module-deps@^6.0.0, module-deps@^6.2.3:
xtend "^4.0.0"
moment@^2.29.1:
- version "2.29.1"
- resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.1.tgz#b2be769fa31940be9eeea6469c075e35006fa3d3"
+ version "2.29.2"
+ resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.2.tgz#00910c60b20843bcba52d37d58c628b47b1f20e4"
+ integrity sha512-UgzG4rvxYpN15jgCmVJwac49h9ly9NurikMWGPdVxm8GZD6XjkKPxDTjQQ43gtGgnV3X0cAyWDdP2Wexoquifg==
move-concurrently@^1.0.1:
version "1.0.1"
@@ -5958,8 +5957,9 @@ nanoid@3.1.20:
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.1.20.tgz#badc263c6b1dcf14b71efaa85f6ab4c1d6cfc788"
nanoid@^3.1.22:
- version "3.1.22"
- resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.1.22.tgz#b35f8fb7d151990a8aebd5aa5015c03cf726f844"
+ version "3.1.31"
+ resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.1.31.tgz#f5b58a1ce1b7604da5f0605757840598d8974dc6"
+ integrity sha512-ZivnJm0o9bb13p2Ot5CpgC2rQdzB9Uxm/mFZweqm5eMViqOJe3PV6LU2E30SiLgheesmcPrjquqraoolONSA0A==
nanomatch@^1.2.9:
version "1.2.13"
@@ -6027,13 +6027,10 @@ node-preload@^0.2.1:
dependencies:
process-on-spawn "^1.0.0"
-node-releases@^1.1.53:
- version "1.1.53"
- resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.53.tgz#2d821bfa499ed7c5dffc5e2f28c88e78a08ee3f4"
-
-node-releases@^1.1.70:
- version "1.1.71"
- resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.71.tgz#cb1334b179896b1c89ecfdd4b725fb7bbdfc7dbb"
+node-releases@^2.0.3:
+ version "2.0.3"
+ resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.3.tgz#225ee7488e4a5e636da8da52854844f9d716ca96"
+ integrity sha512-maHFz6OLqYxz+VQyCAtA3PTX4UP/53pa05fyDNc9CwjvJ0yEh6+xBwKsgCxMNhS8taUKBFYxfuiaD9U/55iFaw==
normalize-package-data@^2.3.2, normalize-package-data@^2.5.0:
version "2.5.0"
@@ -6469,6 +6466,11 @@ performance-now@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b"
+picocolors@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c"
+ integrity sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==
+
picomatch@^2.0.4, picomatch@^2.0.5, picomatch@^2.2.1:
version "2.2.2"
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.2.2.tgz#21f333e9b6b8eaff02468f5146ea406d345f4dad"
@@ -6505,12 +6507,6 @@ pkg-dir@^4.1.0:
dependencies:
find-up "^4.0.0"
-pkg-up@^2.0.0:
- version "2.0.0"
- resolved "https://registry.yarnpkg.com/pkg-up/-/pkg-up-2.0.0.tgz#c819ac728059a461cab1c3889a2be3c49a004d7f"
- dependencies:
- find-up "^2.1.0"
-
posix-character-classes@^0.1.0:
version "0.1.1"
resolved "https://registry.yarnpkg.com/posix-character-classes/-/posix-character-classes-0.1.1.tgz#01eac0fe3b5af71a2a6c02feabb8c1fef7e00eab"
@@ -7818,6 +7814,7 @@ simple-concat@^1.0.0:
simple-swizzle@^0.2.2:
version "0.2.2"
resolved "https://registry.yarnpkg.com/simple-swizzle/-/simple-swizzle-0.2.2.tgz#a4da6b635ffcccca33f70d17cb92592de95e557a"
+ integrity sha1-pNprY1/8zMoz9w0Xy5JZLeleVXo=
dependencies:
is-arrayish "^0.3.1"