diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index d1a26467..0623fb5f 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -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) diff --git a/docs/block-tunes.md b/docs/block-tunes.md index 1e8c2ce6..6544bb95 100644 --- a/docs/block-tunes.md +++ b/docs/block-tunes.md @@ -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: diff --git a/example/example-dev.html b/example/example-dev.html index 4801722f..4700e8e9 100644 --- a/example/example-dev.html +++ b/example/example-dev.html @@ -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." } }, { diff --git a/src/components/block/index.ts b/src/components/block/index.ts index fcb8c412..85771882 100644 --- a/src/components/block/index.ts +++ b/src/components/block/index.ts @@ -201,7 +201,19 @@ export default class Block extends EventsDispatcher { /** * 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 { 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); } } diff --git a/src/components/dom.ts b/src/components/dom.ts index b9d694ac..7e45cd6e 100644 --- a/src/components/dom.ts +++ b/src/components/dom.ts @@ -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(', '); } diff --git a/src/components/modules/api/readonly.ts b/src/components/modules/api/readonly.ts index 7b268c83..19a8c2d9 100644 --- a/src/components/modules/api/readonly.ts +++ b/src/components/modules/api/readonly.ts @@ -12,6 +12,7 @@ export default class ReadOnlyAPI extends Module { public get methods(): ReadOnly { return { toggle: (state): Promise => this.toggle(state), + isEnabled: this.isEnabled, }; } @@ -25,4 +26,11 @@ export default class ReadOnlyAPI extends Module { public toggle(state?: boolean): Promise { return this.Editor.ReadOnly.toggle(state); } + + /** + * Returns current read-only state + */ + public get isEnabled(): boolean { + return this.Editor.ReadOnly.isEnabled; + } } diff --git a/src/components/modules/rectangleSelection.ts b/src/components/modules/rectangleSelection.ts index 8b2ea151..70657897 100644 --- a/src/components/modules/rectangleSelection.ts +++ b/src/components/modules/rectangleSelection.ts @@ -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); + } } /** diff --git a/src/components/modules/toolbar/index.ts b/src/components/modules/toolbar/index.ts index 56953d91..7f0e319d 100644 --- a/src/components/modules/toolbar/index.ts +++ b/src/components/modules/toolbar/index.ts @@ -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 { 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); } /** diff --git a/src/components/modules/toolbar/inline.ts b/src/components/modules/toolbar/inline.ts index 9bc788ee..41ca2ddf 100644 --- a/src/components/modules/toolbar/inline.ts +++ b/src/components/modules/toolbar/inline.ts @@ -134,10 +134,11 @@ export default class InlineToolbar extends Module { /** * 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 { } this.move(); - this.open(); + this.open(needToShowConversionToolbar); this.Editor.Toolbar.close(); } @@ -233,8 +234,10 @@ export default class InlineToolbar extends Module { /** * 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 { 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 */ diff --git a/src/components/modules/ui.ts b/src/components/modules/ui.ts index 8719d6a9..c6b7e245 100644 --- a/src/components/modules/ui.ts +++ b/src/components/modules/ui.ts @@ -340,7 +340,7 @@ export default class UI extends Module { 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 { /** * 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 { } /** - * 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 { 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 { this.Editor.BlockManager.setCurrentBlockByChildNode(focusedElement); } + const isNeedToShowConversionToolbar = clickedOutsideBlockContent !== true; + /** * @todo add debounce */ - this.Editor.InlineToolbar.tryToShow(true); + this.Editor.InlineToolbar.tryToShow(true, isNeedToShowConversionToolbar); } /** diff --git a/src/components/selection.ts b/src/components/selection.ts index 6a6ea6a3..d4bbcbf9 100644 --- a/src/components/selection.ts +++ b/src/components/selection.ts @@ -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 */ diff --git a/src/components/tools/block.ts b/src/components/tools/block.ts index 7da7315c..e410851d 100644 --- a/src/components/tools/block.ts +++ b/src/components/tools/block.ts @@ -116,7 +116,7 @@ export default class BlockTool extends BaseTool { } /** - * 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 { .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; } } diff --git a/src/components/tools/tune.ts b/src/components/tools/tune.ts index d52f32a6..52230cbd 100644 --- a/src/components/tools/tune.ts +++ b/src/components/tools/tune.ts @@ -28,7 +28,7 @@ export default class BlockTune extends BaseTool { // eslint-disable-next-line new-cap return new this.constructable({ api: this.api.getMethodsForTool(this), - settings: this.settings, + config: this.settings, block, data, }); diff --git a/src/components/utils.ts b/src/components/utils.ts index 091bd54a..90c3c32f 100644 --- a/src/components/utils.ts +++ b/src/components/utils.ts @@ -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 = () => { diff --git a/src/styles/inline-toolbar.css b/src/styles/inline-toolbar.css index 2a0837a7..b118df87 100644 --- a/src/styles/inline-toolbar.css +++ b/src/styles/inline-toolbar.css @@ -7,6 +7,7 @@ will-change: transform, opacity; top: 0; left: 0; + z-index: 3; &--showed { opacity: 1; diff --git a/test/cypress/tests/selection.spec.ts b/test/cypress/tests/selection.spec.ts new file mode 100644 index 00000000..a721e675 --- /dev/null +++ b/test/cypress/tests/selection.spec.ts @@ -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'); + }); +}); diff --git a/test/cypress/tests/tools/BlockTune.spec.ts b/test/cypress/tests/tools/BlockTune.spec.ts index 9704a5dc..89b47d85 100644 --- a/test/cypress/tests/tools/BlockTune.spec.ts +++ b/test/cypress/tests/tools/BlockTune.spec.ts @@ -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); }); }); }); diff --git a/types/api/readonly.d.ts b/types/api/readonly.d.ts index a0bf4ca2..3766bf72 100644 --- a/types/api/readonly.d.ts +++ b/types/api/readonly.d.ts @@ -9,4 +9,9 @@ export interface ReadOnly { * @returns {Promise} current value */ toggle: (state?: boolean) => Promise; + + /** + * Contains current read-only state + */ + isEnabled: boolean; } diff --git a/types/block-tunes/block-tune.d.ts b/types/block-tunes/block-tune.d.ts index c4ec7589..f323fd82 100644 --- a/types/block-tunes/block-tune.d.ts +++ b/types/block-tunes/block-tune.d.ts @@ -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; diff --git a/types/configs/editor-config.d.ts b/types/configs/editor-config.d.ts index 787ed2e5..4e93f6ce 100644 --- a/types/configs/editor-config.d.ts +++ b/types/configs/editor-config.d.ts @@ -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 diff --git a/types/tools/tool-settings.d.ts b/types/tools/tool-settings.d.ts index dd5af253..f093d969 100644 --- a/types/tools/tool-settings.d.ts +++ b/types/tools/tool-settings.d.ts @@ -21,7 +21,7 @@ export interface ToolboxConfig { * * @template Config - the structure describing a config object supported by the tool */ -export interface ToolSettings { +export interface ExternalToolSettings { /** * Tool's class @@ -56,3 +56,13 @@ export interface ToolSettings { */ toolbox?: ToolboxConfig | false; } + +/** + * For internal Tools 'class' property is optional + */ +export type InternalToolSettings = Omit, 'class'> & Partial, 'class'>>; + +/** + * Union of external and internal Tools settings + */ +export type ToolSettings = InternalToolSettings | ExternalToolSettings;