Tunes improvements for inline actions (#1722)

* Add tunes improvements

* Allow to show Inline Toolbar at Block Tune Wrapper element

* Add fake cursor on block selection

* Fix lint

* Update types

* Fix bugs with selection

* Remove selection observer

* Update due to comments

* Fix tests

* Update docs/block-tunes.md

Co-authored-by: Peter Savchenko <specc.dev@gmail.com>

* Move fake cursor to selection utils

* Fix missing range for Safari

* Fix data attribute value

* Add comment

* Update z-index for inline-toolbar

* Add changelog

* Remove fake cursor visibility for the core

Co-authored-by: Peter Savchenko <specc.dev@gmail.com>
This commit is contained in:
George Berezhnoy 2021-07-21 21:33:09 +03:00 committed by GitHub
parent cf494a7a28
commit 6f36707f67
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 302 additions and 73 deletions

View file

@ -1,5 +1,12 @@
# Changelog
### 2.22.2
- `Improvement` — Inline Toolbar might be used for any contenteditable element inside Editor.js zone
- `Improvement` *Tunes API* - Tunes now can provide sanitize configuration
- `Fix` *Tunes API* - Tune config now passed to constructor under `config` property
- `Fix` *Types* - Add common type for internal and external Tools configuration
### 2.22.1
- `Fix` — I18n for internal Block Tunes [#1661](https://github.com/codex-team/editor.js/issues/1661)

View file

@ -22,7 +22,7 @@ At the constructor of Tune's class exemplar you will receive an object with foll
| Parameter | Description |
| --------- | ----------- |
| api | Editor's [API](api.md) obejct |
| settings | Configuration of Block Tool Tune is connected to (might be useful in some cases) |
| config | Configuration of Block Tool Tune is connected to (might be useful in some cases) |
| block | [Block API](api.md#block-api) methods for block Tune is connected to |
| data | Saved Tune data |
@ -145,7 +145,24 @@ No return value
---
#### Format
### static get sanitize()
If your Tune inserts any HTML markup into Block's content you need to provide sanitize configuration, so your HTML is not trimmed on save.
Please see more information at [sanitizer page](sanitizer.md).
```javascript
class Tune {
static get sanitize() {
return {
sup: true
}
}
}
```
## Format
Tunes data is saved to `tunes` property of output object:

View file

@ -114,7 +114,6 @@
* Tools list
*/
tools: {
/**
* Each Tool is a Plugin. Pass them via 'class' option with necessary settings {@link docs/tools.md}
*/
@ -205,10 +204,10 @@
}
},
{
type : 'paragraph',
id: "b6ji-DvaKb",
data : {
text : 'Hey. Meet the new Editor. On this page you can see it in action — try to edit this text. Source code of the page contains the example of connection and configuration.'
"id": "b6ji-DvaKb",
"type": "paragraph",
"data": {
"text": "Hey. Meet the new Editor. On this page you can see it in action — try to edit this text. Source code of the page contains the example of connection and configuration."
}
},
{

View file

@ -201,7 +201,19 @@ export default class Block extends EventsDispatcher<BlockEvents> {
/**
* Is fired when DOM mutation has been happened
*/
private didMutated = _.debounce((): void => {
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');
});
/**
* In case some mutation free elements are added or removed, do not trigger didMutated event
*/
if (!shouldFireUpdate) {
return;
}
/**
* Drop cache
*/
@ -448,8 +460,12 @@ export default class Block extends EventsDispatcher<BlockEvents> {
public set selected(state: boolean) {
if (state) {
this.holder.classList.add(Block.CSS.selected);
SelectionUtils.addFakeCursor(this.holder);
} else {
this.holder.classList.remove(Block.CSS.selected);
SelectionUtils.removeFakeCursor(this.holder);
}
}

View file

@ -202,7 +202,7 @@ export default class Dom {
public static get allInputsSelector(): string {
const allowedInputTypes = ['text', 'password', 'email', 'number', 'search', 'tel', 'url'];
return '[contenteditable], textarea, input:not([type]), ' +
return '[contenteditable=true], textarea, input:not([type]), ' +
allowedInputTypes.map((type) => `input[type="${type}"]`).join(', ');
}

View file

@ -12,6 +12,7 @@ export default class ReadOnlyAPI extends Module {
public get methods(): ReadOnly {
return {
toggle: (state): Promise<boolean> => this.toggle(state),
isEnabled: this.isEnabled,
};
}
@ -25,4 +26,11 @@ export default class ReadOnlyAPI extends Module {
public toggle(state?: boolean): Promise<boolean> {
return this.Editor.ReadOnly.toggle(state);
}
/**
* Returns current read-only state
*/
public get isEnabled(): boolean {
return this.Editor.ReadOnly.isEnabled;
}
}

View file

@ -211,7 +211,16 @@ export default class RectangleSelection extends Module {
if (mouseEvent.button !== this.MAIN_MOUSE_BUTTON) {
return;
}
this.startSelection(mouseEvent.pageX, mouseEvent.pageY);
/**
* Do not enable the Rectangle Selection when mouse dragging started some editable input
* Used to prevent Rectangle Selection on Block Tune wrappers' inputs that also can be inside the Block
*/
const startedFromContentEditable = (mouseEvent.target as Element).closest($.allInputsSelector) !== null;
if (!startedFromContentEditable) {
this.startSelection(mouseEvent.pageX, mouseEvent.pageY);
}
}
/**

View file

@ -5,8 +5,8 @@ import I18n from '../../i18n';
import { I18nInternalNS } from '../../i18n/namespace-internal';
import Tooltip from '../../utils/tooltip';
import { ModuleConfig } from '../../../types-internal/module-config';
import EventsDispatcher from '../../utils/events';
import { EditorConfig } from '../../../../types';
import SelectionUtils from '../../selection';
/**
* HTML Elements used for Toolbar UI
@ -348,10 +348,19 @@ export default class Toolbar extends Module<ToolbarNodes> {
private enableModuleBindings(): void {
/**
* Settings toggler
*
* mousedown is used because on click selection is lost in Safari and FF
*/
this.readOnlyMutableListeners.on(this.nodes.settingsToggler, 'click', () => {
this.readOnlyMutableListeners.on(this.nodes.settingsToggler, 'mousedown', (e) => {
/**
* Stop propagation to prevent block selection clearance
*
* @see UI.documentClicked
*/
e.stopPropagation();
this.settingsTogglerClicked();
});
}, true);
}
/**

View file

@ -134,10 +134,11 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
/**
* Shows Inline Toolbar if something is selected
*
* @param {boolean} [needToClose] - pass true to close toolbar if it is not allowed.
* @param [needToClose] - pass true to close toolbar if it is not allowed.
* Avoid to use it just for closing IT, better call .close() clearly.
* @param [needToShowConversionToolbar] - pass false to not to show Conversion Toolbar
*/
public tryToShow(needToClose = false): void {
public tryToShow(needToClose = false, needToShowConversionToolbar = true): void {
if (!this.allowedToShow()) {
if (needToClose) {
this.close();
@ -147,7 +148,7 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
}
this.move();
this.open();
this.open(needToShowConversionToolbar);
this.Editor.Toolbar.close();
}
@ -233,8 +234,10 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
/**
* Shows Inline Toolbar
*
* @param [needToShowConversionToolbar] - pass false to not to show Conversion Toolbar
*/
public open(): void {
public open(needToShowConversionToolbar = true): void {
if (this.opened) {
return;
}
@ -251,7 +254,7 @@ export default class InlineToolbar extends Module<InlineToolbarNodes> {
this.buttonsList = this.nodes.buttons.querySelectorAll(`.${this.CSS.inlineToolButton}`);
this.opened = true;
if (this.Editor.ConversionToolbar.hasTools()) {
if (needToShowConversionToolbar && this.Editor.ConversionToolbar.hasTools()) {
/**
* Change Conversion Dropdown content for current tool
*/

View file

@ -340,7 +340,7 @@ export default class UI extends Module<UINodes> {
this.documentKeydown(event);
}, true);
this.readOnlyMutableListeners.on(document, 'click', (event: MouseEvent) => {
this.readOnlyMutableListeners.on(document, 'mousedown', (event: MouseEvent) => {
this.documentClicked(event);
}, true);
@ -591,9 +591,7 @@ export default class UI extends Module<UINodes> {
/**
* Clear Selection if user clicked somewhere
*/
if (!this.Editor.CrossBlockSelection.isCrossBlockSelectionStarted) {
this.Editor.BlockSelection.clearSelection(event);
}
this.Editor.BlockSelection.clearSelection(event);
}
/**
@ -754,10 +752,28 @@ export default class UI extends Module<UINodes> {
}
/**
* Event can be fired on clicks at the Editor elements, for example, at the Inline Toolbar
* We need to skip such firings
* Usual clicks on some controls, for example, Block Tunes Toggler
*/
if (!focusedElement || !focusedElement.closest(`.${Block.CSS.content}`)) {
if (!focusedElement) {
/**
* If there is no selected range, close inline toolbar
*
* @todo Make this method more straightforward
*/
if (!Selection.range) {
this.Editor.InlineToolbar.close();
}
return;
}
/**
* Event can be fired on clicks at non-block-content elements,
* for example, at the Inline Toolbar or some Block Tune element
*/
const clickedOutsideBlockContent = focusedElement.closest(`.${Block.CSS.content}`) === null;
if (clickedOutsideBlockContent) {
/**
* If new selection is not on Inline Toolbar, we need to close it
*/
@ -765,7 +781,16 @@ export default class UI extends Module<UINodes> {
this.Editor.InlineToolbar.close();
}
return;
/**
* Case when we click on external tool elements,
* for example some Block Tune element.
* If this external content editable element has data-inline-toolbar="true"
*/
const inlineToolbarEnabledForExternalTool = (focusedElement as HTMLElement).dataset.inlineToolbar === 'true';
if (!inlineToolbarEnabledForExternalTool) {
return;
}
}
/**
@ -775,10 +800,12 @@ export default class UI extends Module<UINodes> {
this.Editor.BlockManager.setCurrentBlockByChildNode(focusedElement);
}
const isNeedToShowConversionToolbar = clickedOutsideBlockContent !== true;
/**
* @todo add debounce
*/
this.Editor.InlineToolbar.tryToShow(true);
this.Editor.InlineToolbar.tryToShow(true, isNeedToShowConversionToolbar);
}
/**

View file

@ -34,6 +34,34 @@ interface Document {
* @typedef {SelectionUtils} SelectionUtils
*/
export default class SelectionUtils {
/**
* Selection instances
*
* @todo Check if this is still relevant
*/
public instance: Selection = null;
public selection: Selection = null;
/**
* This property can store SelectionUtils's range for restoring later
*
* @type {Range|null}
*/
public savedSelectionRange: Range = null;
/**
* Fake background is active
*
* @returns {boolean}
*/
public isFakeBackgroundEnabled = false;
/**
* Native Document's commands for fake background
*/
private readonly commandBackground: string = 'backColor';
private readonly commandRemoveFormat: string = 'removeFormat';
/**
* Editor styles
*
@ -112,7 +140,18 @@ export default class SelectionUtils {
* @returns {boolean}
*/
public static get isAtEditor(): boolean {
const selection = SelectionUtils.get();
return this.isSelectionAtEditor(SelectionUtils.get());
}
/**
* Check if passed selection is at Editor's zone
*
* @param selection - Selectoin object to check
*/
public static isSelectionAtEditor(selection: Selection): boolean {
if (!selection) {
return false;
}
/**
* Something selected on document
@ -132,7 +171,35 @@ export default class SelectionUtils {
/**
* SelectionUtils is not out of Editor because Editor's wrapper was found
*/
return editorZone && editorZone.nodeType === Node.ELEMENT_NODE;
return editorZone ? editorZone.nodeType === Node.ELEMENT_NODE : false;
}
/**
* Check if passed range at Editor zone
*
* @param range - range to check
*/
public static isRangeAtEditor(range: Range): boolean {
if (!range) {
return;
}
let selectedNode = range.startContainer as HTMLElement;
if (selectedNode && selectedNode.nodeType === Node.TEXT_NODE) {
selectedNode = selectedNode.parentNode as HTMLElement;
}
let editorZone = null;
if (selectedNode && selectedNode instanceof Element) {
editorZone = selectedNode.closest(`.${SelectionUtils.CSS.editorZone}`);
}
/**
* SelectionUtils is not out of Editor because Editor's wrapper was found
*/
return editorZone ? editorZone.nodeType === Node.ELEMENT_NODE : false;
}
/**
@ -150,8 +217,15 @@ export default class SelectionUtils {
* @returns {Range|null}
*/
public static get range(): Range | null {
const selection = window.getSelection();
return this.getRangeFromSelection(this.get());
}
/**
* Returns range from passed Selection object
*
* @param selection - Selection object to get Range from
*/
public static getRangeFromSelection(selection: Selection): Range {
return selection && selection.rangeCount ? selection.getRangeAt(0) : null;
}
@ -237,34 +311,6 @@ export default class SelectionUtils {
return window.getSelection ? window.getSelection().toString() : '';
}
/**
* Selection instances
*
* @todo Check if this is still relevant
*/
public instance: Selection = null;
public selection: Selection = null;
/**
* This property can store SelectionUtils's range for restoring later
*
* @type {Range|null}
*/
public savedSelectionRange: Range = null;
/**
* Fake background is active
*
* @returns {boolean}
*/
public isFakeBackgroundEnabled = false;
/**
* Native Document's commands for fake background
*/
private readonly commandBackground: string = 'backColor';
private readonly commandRemoveFormat: string = 'removeFormat';
/**
* Returns window SelectionUtils
* {@link https://developer.mozilla.org/ru/docs/Web/API/Window/getSelection}
@ -308,6 +354,36 @@ export default class SelectionUtils {
return range.getBoundingClientRect();
}
/**
* Adds fake cursor to the current range
*
* @param [container] - if passed cursor will be added only if container contains current range
*/
public static addFakeCursor(container?: HTMLElement): void {
const range = SelectionUtils.range;
const fakeCursor = $.make('span', 'codex-editor__fake-cursor');
fakeCursor.dataset.mutationFree = 'true';
if (!range || (container && !container.contains(range.startContainer))) {
return;
}
range.collapse();
range.insertNode(fakeCursor);
}
/**
* Removes fake cursor from a container
*
* @param container - container to look for
*/
public static removeFakeCursor(container: HTMLElement = document.body): void {
const fakeCursor = $.find(container, `.codex-editor__fake-cursor`);
fakeCursor && fakeCursor.remove();
}
/**
* Removes fake background
*/

View file

@ -116,7 +116,7 @@ export default class BlockTool extends BaseTool<IBlockTool> {
}
/**
* Returns sanitize configuration for Block Tool including conifgs from Inline Tools
* Returns sanitize configuration for Block Tool including configs from related Inline Tools and Block Tunes
*/
@_.cacheable
public get sanitizeConfig(): SanitizerConfig {
@ -160,6 +160,10 @@ export default class BlockTool extends BaseTool<IBlockTool> {
.from(this.inlineTools.values())
.forEach(tool => Object.assign(baseConfig, tool.sanitizeConfig));
Array
.from(this.tunes.values())
.forEach(tune => Object.assign(baseConfig, tune.sanitizeConfig));
return baseConfig;
}
}

View file

@ -28,7 +28,7 @@ export default class BlockTune extends BaseTool<IBlockTune> {
// eslint-disable-next-line new-cap
return new this.constructable({
api: this.api.getMethodsForTool(this),
settings: this.settings,
config: this.settings,
block,
data,
});

View file

@ -429,14 +429,12 @@ export function isValidMimeType(type: string): boolean {
* @param {boolean} immediate - call now
* @returns {Function}
*/
export function debounce(func: () => void, wait?: number, immediate?: boolean): () => void {
export function debounce(func: (...args: unknown[]) => void, wait?: number, immediate?: boolean): () => void {
let timeout;
return (): void => {
return (...args: unknown[]): void => {
// eslint-disable-next-line @typescript-eslint/no-this-alias
const context = this,
// eslint-disable-next-line prefer-rest-params
args = arguments;
const context = this;
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
const later = () => {

View file

@ -7,6 +7,7 @@
will-change: transform, opacity;
top: 0;
left: 0;
z-index: 3;
&--showed {
opacity: 1;

View file

@ -0,0 +1,33 @@
import * as _ from '../../../src/components/utils';
describe('Blocks selection', () => {
beforeEach(() => {
if (this && this.editorInstance) {
this.editorInstance.destroy();
} else {
cy.createEditor({}).as('editorInstance');
}
});
it('should remove block selection on click', () => {
cy.get('[data-cy=editorjs]')
.find('div.ce-block')
.click()
.type('First block{enter}');
cy.get('[data-cy=editorjs')
.find('div.ce-block')
.next()
.type('Second block')
.type('{movetostart}')
.trigger('keydown', {
shiftKey: true,
keyCode: _.keyCodes.UP,
});
cy.get('[data-cy=editorjs')
.click()
.find('div.ce-block')
.should('not.have.class', '.ce-block--selected');
});
});

View file

@ -16,16 +16,16 @@ describe('BlockTune', () => {
public static prepare;
public api: object;
public settings: ToolSettings;
public config: ToolSettings;
public data: BlockTuneData;
public block: object;
/**
*
*/
constructor({ api, settings, block, data }) {
constructor({ api, config, block, data }) {
this.api = api;
this.settings = settings;
this.config = config;
this.block = block;
this.data = data;
}
@ -173,7 +173,7 @@ describe('BlockTune', () => {
it('should return Tool instance with passed settings', () => {
const instance = tool.create(data, blockAPI as any) as any;
expect(instance.settings).to.be.deep.eq(options.config.config);
expect(instance.config).to.be.deep.eq(options.config.config);
});
});
});

View file

@ -9,4 +9,9 @@ export interface ReadOnly {
* @returns {Promise<boolean>} current value
*/
toggle: (state?: boolean) => Promise<boolean>;
/**
* Contains current read-only state
*/
isEnabled: boolean;
}

View file

@ -1,4 +1,4 @@
import {API, BlockAPI, ToolConfig} from '../index';
import {API, BlockAPI, SanitizerConfig, ToolConfig} from '../index';
import { BlockTuneData } from './block-tune-data';
/**
@ -41,6 +41,11 @@ export interface BlockTuneConstructable {
*/
isTune: boolean;
/**
* Tune's sanitize configuration
*/
sanitize?: SanitizerConfig;
/**
* @constructor
*
@ -48,7 +53,7 @@ export interface BlockTuneConstructable {
*/
new(config: {
api: API,
settings?: ToolConfig,
config?: ToolConfig,
block: BlockAPI,
data: BlockTuneData,
}): BlockTune;

View file

@ -53,7 +53,9 @@ export interface EditorConfig {
/**
* Map of Tools to use
*/
tools?: {[toolName: string]: ToolConstructable|ToolSettings};
tools?: {
[toolName: string]: ToolConstructable|ToolSettings;
}
/**
* Data to render on Editor start

View file

@ -21,7 +21,7 @@ export interface ToolboxConfig {
*
* @template Config - the structure describing a config object supported by the tool
*/
export interface ToolSettings <Config extends object = any> {
export interface ExternalToolSettings<Config extends object = any> {
/**
* Tool's class
@ -56,3 +56,13 @@ export interface ToolSettings <Config extends object = any> {
*/
toolbox?: ToolboxConfig | false;
}
/**
* For internal Tools 'class' property is optional
*/
export type InternalToolSettings<Config extends object = any> = Omit<ExternalToolSettings<Config>, 'class'> & Partial<Pick<ExternalToolSettings<Config>, 'class'>>;
/**
* Union of external and internal Tools settings
*/
export type ToolSettings<Config extends object = any> = InternalToolSettings<Config> | ExternalToolSettings<Config>;