UNPKG

@ckeditor/ckeditor5-widget

Version:
1,058 lines (1,054 loc) • 140 kB
/** * @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved. * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options */ import { Plugin } from '@ckeditor/ckeditor5-core/dist/index.js'; import { MouseObserver, PointerObserver, ModelTreeWalker } from '@ckeditor/ckeditor5-engine/dist/index.js'; import { Delete } from '@ckeditor/ckeditor5-typing/dist/index.js'; import { EmitterMixin, Rect, CKEditorError, toArray, isForwardArrowKeyCode, env, keyCodes, getLocalizedArrowKeyCodeDirection, compareArrays, getRangeFromMouseEvent, logWarning, ObservableMixin, global, DomEmitterMixin } from '@ckeditor/ckeditor5-utils/dist/index.js'; import { IconDragHandle, IconReturnArrow } from '@ckeditor/ckeditor5-icons/dist/index.js'; import { IconView, Template, ContextualBalloon, ToolbarView, BalloonPanelView, View } from '@ckeditor/ckeditor5-ui/dist/index.js'; import { Enter } from '@ckeditor/ckeditor5-enter/dist/index.js'; import { throttle } from 'es-toolkit/compat'; /** * Class used to handle the correct order of highlights on elements. * * When different highlights are applied to same element the correct order should be preserved: * * * highlight with highest priority should be applied, * * if two highlights have same priority - sort by CSS class provided in * {@link module:engine/conversion/downcasthelpers~DowncastHighlightDescriptor}. * * This way, highlight will be applied with the same rules it is applied on texts. */ class WidgetHighlightStack extends /* #__PURE__ */ EmitterMixin() { _stack = []; /** * Adds highlight descriptor to the stack. * * @fires change:top */ add(descriptor, writer) { const stack = this._stack; // Save top descriptor and insert new one. If top is changed - fire event. const oldTop = stack[0]; this._insertDescriptor(descriptor); const newTop = stack[0]; // When new object is at the top and stores different information. if (oldTop !== newTop && !compareDescriptors(oldTop, newTop)) { this.fire('change:top', { oldDescriptor: oldTop, newDescriptor: newTop, writer }); } } /** * Removes highlight descriptor from the stack. * * @fires change:top * @param id Id of the descriptor to remove. */ remove(id, writer) { const stack = this._stack; const oldTop = stack[0]; this._removeDescriptor(id); const newTop = stack[0]; // When new object is at the top and stores different information. if (oldTop !== newTop && !compareDescriptors(oldTop, newTop)) { this.fire('change:top', { oldDescriptor: oldTop, newDescriptor: newTop, writer }); } } /** * Inserts a given descriptor in correct place in the stack. It also takes care about updating information * when descriptor with same id is already present. */ _insertDescriptor(descriptor) { const stack = this._stack; const index = stack.findIndex((item)=>item.id === descriptor.id); // Inserting exact same descriptor - do nothing. if (compareDescriptors(descriptor, stack[index])) { return; } // If descriptor with same id but with different information is on the stack - remove it. if (index > -1) { stack.splice(index, 1); } // Find correct place to insert descriptor in the stack. // It has different information (for example priority) so it must be re-inserted in correct place. let i = 0; while(stack[i] && shouldABeBeforeB(stack[i], descriptor)){ i++; } stack.splice(i, 0, descriptor); } /** * Removes descriptor with given id from the stack. * * @param id Descriptor's id. */ _removeDescriptor(id) { const stack = this._stack; const index = stack.findIndex((item)=>item.id === id); // If descriptor with same id is on the list - remove it. if (index > -1) { stack.splice(index, 1); } } } /** * Compares two descriptors by checking their priority and class list. * * @returns Returns true if both descriptors are defined and have same priority and classes. */ function compareDescriptors(a, b) { return a && b && a.priority == b.priority && classesToString(a.classes) == classesToString(b.classes); } /** * Checks whenever first descriptor should be placed in the stack before second one. */ function shouldABeBeforeB(a, b) { if (a.priority > b.priority) { return true; } else if (a.priority < b.priority) { return false; } // When priorities are equal and names are different - use classes to compare. return classesToString(a.classes) > classesToString(b.classes); } /** * Converts CSS classes passed with {@link module:engine/conversion/downcasthelpers~DowncastHighlightDescriptor} to * sorted string. */ function classesToString(classes) { return Array.isArray(classes) ? classes.sort().join(',') : classes; } /** * CSS class added to each widget element. */ const WIDGET_CLASS_NAME = 'ck-widget'; /** * CSS class added to currently selected widget element. */ const WIDGET_SELECTED_CLASS_NAME = 'ck-widget_selected'; /** * Returns `true` if given {@link module:engine/view/node~ViewNode} is an {@link module:engine/view/element~ViewElement} and a widget. */ function isWidget(node) { if (!node.is('element')) { return false; } return !!node.getCustomProperty('widget'); } /** * Converts the given {@link module:engine/view/element~ViewElement} to a widget in the following way: * * * sets the `contenteditable` attribute to `"false"`, * * adds the `ck-widget` CSS class, * * adds a custom {@link module:engine/view/element~ViewElement#getFillerOffset `getFillerOffset()`} method returning `null`, * * adds a custom property allowing to recognize widget elements by using {@link ~isWidget `isWidget()`}, * * implements the {@link ~setHighlightHandling view highlight on widgets}. * * This function needs to be used in conjunction with * {@link module:engine/conversion/downcasthelpers~DowncastHelpers downcast conversion helpers} * like {@link module:engine/conversion/downcasthelpers~DowncastHelpers#elementToElement `elementToElement()`}. * Moreover, typically you will want to use `toWidget()` only for `editingDowncast`, while keeping the `dataDowncast` clean. * * For example, in order to convert a `<widget>` model element to `<div class="widget">` in the view, you can define * such converters: * * ```ts * editor.conversion.for( 'editingDowncast' ) * .elementToElement( { * model: 'widget', * view: ( modelItem, { writer } ) => { * const div = writer.createContainerElement( 'div', { class: 'widget' } ); * * return toWidget( div, writer, { label: 'some widget' } ); * } * } ); * * editor.conversion.for( 'dataDowncast' ) * .elementToElement( { * model: 'widget', * view: ( modelItem, { writer } ) => { * return writer.createContainerElement( 'div', { class: 'widget' } ); * } * } ); * ``` * * See the full source code of the widget (with a nested editable) schema definition and converters in * [this sample](https://github.com/ckeditor/ckeditor5-widget/blob/master/tests/manual/widget-with-nestededitable.js). * * @param options Additional options. * @param options.label Element's label provided to the {@link ~setLabel} function. It can be passed as * a plain string or a function returning a string. It represents the widget for assistive technologies (like screen readers). * @param options.hasSelectionHandle If `true`, the widget will have a selection handle added. * @returns Returns the same element. */ function toWidget(element, writer, options = {}) { if (!element.is('containerElement')) { /** * The element passed to `toWidget()` must be a {@link module:engine/view/containerelement~ViewContainerElement} * instance. * * @error widget-to-widget-wrong-element-type * @param {any} element The view element passed to `toWidget()`. */ throw new CKEditorError('widget-to-widget-wrong-element-type', null, { element }); } writer.setAttribute('contenteditable', 'false', element); writer.addClass(WIDGET_CLASS_NAME, element); writer.setCustomProperty('widget', true, element); element.getFillerOffset = getFillerOffset; writer.setCustomProperty('widgetLabel', [], element); if (options.label) { setLabel(element, options.label); } if (options.hasSelectionHandle) { addSelectionHandle(element, writer); } setHighlightHandling(element, writer); return element; } /** * Default handler for adding a highlight on a widget. * It adds CSS class and attributes basing on the given highlight descriptor. */ function addHighlight(element, descriptor, writer) { if (descriptor.classes) { writer.addClass(toArray(descriptor.classes), element); } if (descriptor.attributes) { for(const key in descriptor.attributes){ writer.setAttribute(key, descriptor.attributes[key], element); } } } /** * Default handler for removing a highlight from a widget. * It removes CSS class and attributes basing on the given highlight descriptor. */ function removeHighlight(element, descriptor, writer) { if (descriptor.classes) { writer.removeClass(toArray(descriptor.classes), element); } if (descriptor.attributes) { for(const key in descriptor.attributes){ writer.removeAttribute(key, element); } } } /** * Sets highlight handling methods. Uses {@link module:widget/highlightstack~WidgetHighlightStack} to * properly determine which highlight descriptor should be used at given time. */ function setHighlightHandling(element, writer, add = addHighlight, remove = removeHighlight) { const stack = new WidgetHighlightStack(); stack.on('change:top', (evt, data)=>{ if (data.oldDescriptor) { remove(element, data.oldDescriptor, data.writer); } if (data.newDescriptor) { add(element, data.newDescriptor, data.writer); } }); const addHighlightCallback = (element, descriptor, writer)=>stack.add(descriptor, writer); const removeHighlightCallback = (element, id, writer)=>stack.remove(id, writer); writer.setCustomProperty('addHighlight', addHighlightCallback, element); writer.setCustomProperty('removeHighlight', removeHighlightCallback, element); } /** * Sets label for given element. * It can be passed as a plain string or a function returning a string. Function will be called each time label is retrieved by * {@link ~getLabel `getLabel()`}. */ function setLabel(element, labelOrCreator) { const widgetLabel = element.getCustomProperty('widgetLabel'); widgetLabel.push(labelOrCreator); } /** * Returns the label of the provided element. */ function getLabel(element) { const widgetLabel = element.getCustomProperty('widgetLabel'); return widgetLabel.reduce((prev, current)=>{ if (typeof current === 'function') { return prev ? prev + '. ' + current() : current(); } else { return prev ? prev + '. ' + current : current; } }, ''); } /** * Adds functionality to the provided {@link module:engine/view/editableelement~ViewEditableElement} to act as a widget's editable: * * * sets the `contenteditable` attribute to `true` when * {@link module:engine/view/editableelement~ViewEditableElement#isReadOnly} is `false`, * otherwise sets it to `false`, * * adds the `ck-editor__editable` and `ck-editor__nested-editable` CSS classes, * * adds the `ck-editor__nested-editable_focused` CSS class when the editable is focused and removes it when it is blurred. * * implements the {@link ~setHighlightHandling view highlight on widget's editable}. * * sets the `role` attribute to `textbox` for accessibility purposes. * * Similarly to {@link ~toWidget `toWidget()`} this function should be used in `editingDowncast` only and it is usually * used together with {@link module:engine/conversion/downcasthelpers~DowncastHelpers#elementToElement `elementToElement()`}. * * For example, in order to convert a `<nested>` model element to `<div class="nested">` in the view, you can define * such converters: * * ```ts * editor.conversion.for( 'editingDowncast' ) * .elementToElement( { * model: 'nested', * view: ( modelItem, { writer } ) => { * const div = writer.createEditableElement( 'div', { class: 'nested' } ); * * return toWidgetEditable( nested, writer, { label: 'label for editable' } ); * } * } ); * * editor.conversion.for( 'dataDowncast' ) * .elementToElement( { * model: 'nested', * view: ( modelItem, { writer } ) => { * return writer.createContainerElement( 'div', { class: 'nested' } ); * } * } ); * ``` * * See the full source code of the widget (with nested editable) schema definition and converters in * [this sample](https://github.com/ckeditor/ckeditor5-widget/blob/master/tests/manual/widget-with-nestededitable.js). * * @param options Additional options. * @param options.label Editable's label used by assistive technologies (e.g. screen readers). * @param options.withAriaRole Whether to add the role="textbox" attribute on the editable. Defaults to `true`. * @returns Returns the same element that was provided in the `editable` parameter */ function toWidgetEditable(editable, writer, options = {}) { writer.addClass([ 'ck-editor__editable', 'ck-editor__nested-editable' ], editable); // Set role="textbox" only if explicitly requested (defaults to true for backward compatibility). if (options.withAriaRole !== false) { writer.setAttribute('role', 'textbox', editable); } // Setting tabindex=-1 on contenteditable=false makes it focusable. It propagates focus to the editable // element and makes it possible to highlight nested editables as focused. It's not what we want // for read-only editables though. // See more: https://github.com/ckeditor/ckeditor5/issues/18965 if (!editable.isReadOnly) { writer.setAttribute('tabindex', '-1', editable); } if (options.label) { writer.setAttribute('aria-label', options.label, editable); } // Set initial contenteditable value. writer.setAttribute('contenteditable', editable.isReadOnly ? 'false' : 'true', editable); // Bind the contenteditable property to element#isReadOnly. editable.on('change:isReadOnly', (evt, property, isReadonly)=>{ writer.setAttribute('contenteditable', isReadonly ? 'false' : 'true', editable); if (isReadonly) { writer.removeAttribute('tabindex', editable); } else { writer.setAttribute('tabindex', '-1', editable); } }); editable.on('change:isFocused', (evt, property, is)=>{ if (is) { writer.addClass('ck-editor__nested-editable_focused', editable); } else { writer.removeClass('ck-editor__nested-editable_focused', editable); } }); setHighlightHandling(editable, writer); return editable; } /** * Returns a model range which is optimal (in terms of UX) for inserting a widget block. * * For instance, if a selection is in the middle of a paragraph, the collapsed range before this paragraph * will be returned so that it is not split. If the selection is at the end of a paragraph, * the collapsed range after this paragraph will be returned. * * Note: If the selection is placed in an empty block, the range in that block will be returned. If that range * is then passed to {@link module:engine/model/model~Model#insertContent}, the block will be fully replaced * by the inserted widget block. * * @param selection The selection based on which the insertion position should be calculated. * @param model Model instance. * @returns The optimal range. */ function findOptimalInsertionRange(selection, model) { const selectedElement = selection.getSelectedElement(); if (selectedElement) { const typeAroundFakeCaretPosition = getTypeAroundFakeCaretPosition(selection); // If the WidgetTypeAround "fake caret" is displayed, use its position for the insertion // to provide the most predictable UX (https://github.com/ckeditor/ckeditor5/issues/7438). if (typeAroundFakeCaretPosition) { return model.createRange(model.createPositionAt(selectedElement, typeAroundFakeCaretPosition)); } } return model.schema.findOptimalInsertionRange(selection); } /** * A util to be used in order to map view positions to correct model positions when implementing a widget * which renders non-empty view element for an empty model element. * * For example: * * ``` * // Model: * <placeholder type="name"></placeholder> * * // View: * <span class="placeholder">name</span> * ``` * * In such case, view positions inside `<span>` cannot be correctly mapped to the model (because the model element is empty). * To handle mapping positions inside `<span class="placeholder">` to the model use this util as follows: * * ```ts * editor.editing.mapper.on( * 'viewToModelPosition', * viewToModelPositionOutsideModelElement( model, viewElement => viewElement.hasClass( 'placeholder' ) ) * ); * ``` * * The callback will try to map the view offset of selection to an expected model position. * * 1. When the position is at the end (or in the middle) of the inline widget: * * ``` * // View: * <p>foo <span class="placeholder">name|</span> bar</p> * * // Model: * <paragraph>foo <placeholder type="name"></placeholder>| bar</paragraph> * ``` * * 2. When the position is at the beginning of the inline widget: * * ``` * // View: * <p>foo <span class="placeholder">|name</span> bar</p> * * // Model: * <paragraph>foo |<placeholder type="name"></placeholder> bar</paragraph> * ``` * * @param model Model instance on which the callback operates. * @param viewElementMatcher Function that is passed a view element and should return `true` if the custom mapping * should be applied to the given view element. */ function viewToModelPositionOutsideModelElement(model, viewElementMatcher) { return (evt, data)=>{ const { mapper, viewPosition } = data; const viewParent = mapper.findMappedViewAncestor(viewPosition); if (!viewElementMatcher(viewParent)) { return; } const modelParent = mapper.toModelElement(viewParent); data.modelPosition = model.createPositionAt(modelParent, viewPosition.isAtStart ? 'before' : 'after'); }; } /** * Default filler offset function applied to all widget elements. */ function getFillerOffset() { return null; } /** * Adds a drag handle to the widget. */ function addSelectionHandle(widgetElement, writer) { const selectionHandle = writer.createUIElement('div', { class: 'ck ck-widget__selection-handle' }, function(domDocument) { const domElement = this.toDomElement(domDocument); // Use the IconView from the ui library. const icon = new IconView(); icon.set('content', IconDragHandle); // Render the icon view right away to append its #element to the selectionHandle DOM element. icon.render(); domElement.appendChild(icon.element); return domElement; }); // Append the selection handle into the widget wrapper. writer.insert(writer.createPositionAt(widgetElement, 0), selectionHandle); writer.addClass([ 'ck-widget_with-selection-handle' ], widgetElement); } /** * Starting from a DOM resize host element (an element that receives dimensions as a result of resizing), * this helper returns the width of the found ancestor element. * * * It searches up to 5 levels of ancestors only. * * @param domResizeHost Resize host DOM element that receives dimensions as a result of resizing. * @returns Width of ancestor element in pixels or 0 if no ancestor with a computed width has been found. */ function calculateResizeHostAncestorWidth(domResizeHost) { const getElementComputedWidth = (element)=>{ const { width, paddingLeft, paddingRight } = element.ownerDocument.defaultView.getComputedStyle(element); return parseFloat(width) - (parseFloat(paddingLeft) || 0) - (parseFloat(paddingRight) || 0); }; const domResizeHostParent = domResizeHost.parentElement; if (!domResizeHostParent) { return 0; } // Need to use computed style as it properly excludes parent's paddings from the returned value. let parentWidth = getElementComputedWidth(domResizeHostParent); // Sometimes parent width cannot be accessed. If that happens we should go up in the elements tree // and try to get width from next ancestor. // https://github.com/ckeditor/ckeditor5/issues/10776 const ancestorLevelLimit = 5; let currentLevel = 0; let checkedElement = domResizeHostParent; while(isNaN(parentWidth)){ checkedElement = checkedElement.parentElement; if (++currentLevel > ancestorLevelLimit) { return 0; } parentWidth = getElementComputedWidth(checkedElement); } return parentWidth; } /** * Calculates a relative width of a `domResizeHost` compared to its ancestor in percents. * * @param domResizeHost Resize host DOM element. * @returns Percentage value between 0 and 100. */ function calculateResizeHostPercentageWidth(domResizeHost, resizeHostRect = new Rect(domResizeHost)) { const parentWidth = calculateResizeHostAncestorWidth(domResizeHost); if (!parentWidth) { return 0; } return resizeHostRect.width / parentWidth * 100; } /** * The name of the type around model selection attribute responsible for * displaying a fake caret next to a selected widget. * * @internal */ const TYPE_AROUND_SELECTION_ATTRIBUTE = 'widget-type-around'; /** * Checks if an element is a widget that qualifies to get the widget type around UI. */ function isTypeAroundWidget(viewElement, modelElement, schema) { return !!viewElement && isWidget(viewElement) && !schema.isInline(modelElement); } /** * For the passed HTML element, this helper finds the closest widget type around button ancestor. * * @internal */ function getClosestTypeAroundDomButton(domElement) { return domElement.closest('.ck-widget__type-around__button'); } /** * For the passed widget type around button element, this helper determines at which position * the paragraph would be inserted into the content if, for instance, the button was * clicked by the user. * * @internal * @returns The position of the button. */ function getTypeAroundButtonPosition(domElement) { return domElement.classList.contains('ck-widget__type-around__button_before') ? 'before' : 'after'; } /** * For the passed HTML element, this helper returns the closest view widget ancestor. * * @internal */ function getClosestWidgetViewElement(domElement, domConverter) { const widgetDomElement = domElement.closest('.ck-widget'); return domConverter.mapDomToView(widgetDomElement); } /** * For the passed selection instance, it returns the position of the fake caret displayed next to a widget. * * **Note**: If the fake caret is not currently displayed, `null` is returned. * * @internal * @returns The position of the fake caret or `null` when none is present. */ function getTypeAroundFakeCaretPosition(selection) { return selection.getAttribute(TYPE_AROUND_SELECTION_ATTRIBUTE); } const POSSIBLE_INSERTION_POSITIONS = [ 'before', 'after' ]; // Do the SVG parsing once and then clone the result <svg> DOM element for each new button. const RETURN_ARROW_ICON_ELEMENT = new DOMParser().parseFromString(IconReturnArrow, 'image/svg+xml').firstChild; const PLUGIN_DISABLED_EDITING_ROOT_CLASS = 'ck-widget__type-around_disabled'; /** * A plugin that allows users to type around widgets where normally it is impossible to place the caret due * to limitations of web browsers. These "tight spots" occur, for instance, before (or after) a widget being * the first (or last) child of its parent or between two block widgets. * * This plugin extends the {@link module:widget/widget~Widget `Widget`} plugin and injects the user interface * with two buttons into each widget instance in the editor. Each of the buttons can be clicked by the * user if the widget is next to the "tight spot". Once clicked, a paragraph is created with the selection anchored * in it so that users can type (or insert content, paste, etc.) straight away. */ class WidgetTypeAround extends Plugin { /** * A reference to the model widget element that has the fake caret active * on either side of it. It is later used to remove CSS classes associated with the fake caret * when the widget no longer needs it. */ _currentFakeCaretModelElement = null; /** * @inheritDoc */ static get pluginName() { return 'WidgetTypeAround'; } /** * @inheritDoc */ static get isOfficialPlugin() { return true; } /** * @inheritDoc */ static get requires() { return [ Enter, Delete ]; } /** * @inheritDoc */ init() { const editor = this.editor; const editingView = editor.editing.view; // Set a CSS class on the view editing root when the plugin is disabled so all the buttons // and lines visually disappear. All the interactions are disabled in individual plugin methods. this.on('change:isEnabled', (evt, data, isEnabled)=>{ editingView.change((writer)=>{ for (const root of editingView.document.roots){ if (isEnabled) { writer.removeClass(PLUGIN_DISABLED_EDITING_ROOT_CLASS, root); } else { writer.addClass(PLUGIN_DISABLED_EDITING_ROOT_CLASS, root); } } }); if (!isEnabled) { editor.model.change((writer)=>{ writer.removeSelectionAttribute(TYPE_AROUND_SELECTION_ATTRIBUTE); }); } }); this._enableTypeAroundUIInjection(); this._enableInsertingParagraphsOnButtonClick(); this._enableInsertingParagraphsOnEnterKeypress(); this._enableInsertingParagraphsOnTypingKeystroke(); this._enableTypeAroundFakeCaretActivationUsingKeyboardArrows(); this._enableDeleteIntegration(); this._enableInsertContentIntegration(); this._enableInsertObjectIntegration(); this._enableDeleteContentIntegration(); } /** * @inheritDoc */ destroy() { super.destroy(); this._currentFakeCaretModelElement = null; } /** * Inserts a new paragraph next to a widget element with the selection anchored in it. * * **Note**: This method is heavily user-oriented and will both focus the editing view and scroll * the viewport to the selection in the inserted paragraph. * * @param widgetModelElement The model widget element next to which a paragraph is inserted. * @param position The position where the paragraph is inserted. Either `'before'` or `'after'` the widget. */ _insertParagraph(widgetModelElement, position) { const editor = this.editor; const editingView = editor.editing.view; const attributesToCopy = editor.model.schema.getAttributesWithProperty(widgetModelElement, 'copyOnReplace', true); editor.execute('insertParagraph', { position: editor.model.createPositionAt(widgetModelElement, position), attributes: attributesToCopy }); editingView.focus(); editingView.scrollToTheSelection(); } /** * A wrapper for the {@link module:utils/emittermixin~Emitter#listenTo} method that executes the callbacks only * when the plugin {@link #isEnabled is enabled}. * * @param emitter The object that fires the event. * @param event The name of the event. * @param callback The function to be called on event. * @param options Additional options. */ _listenToIfEnabled(emitter, event, callback, options) { this.listenTo(emitter, event, (...args)=>{ // Do not respond if the plugin is disabled. if (this.isEnabled) { callback(...args); } }, options); } /** * Similar to {@link #_insertParagraph}, this method inserts a paragraph except that it * does not expect a position. Instead, it performs the insertion next to a selected widget * according to the `widget-type-around` model selection attribute value (fake caret position). * * Because this method requires the `widget-type-around` attribute to be set, * the insertion can only happen when the widget's fake caret is active (e.g. activated * using the keyboard). * * @returns Returns `true` when the paragraph was inserted (the attribute was present) and `false` otherwise. */ _insertParagraphAccordingToFakeCaretPosition() { const editor = this.editor; const model = editor.model; const modelSelection = model.document.selection; const typeAroundFakeCaretPosition = getTypeAroundFakeCaretPosition(modelSelection); if (!typeAroundFakeCaretPosition) { return false; } // @if CK_DEBUG_TYPING // if ( ( window as any ).logCKETyping ) { // @if CK_DEBUG_TYPING // console.info( ..._buildLogMessage( this, 'WidgetTypeAround', // @if CK_DEBUG_TYPING // 'Fake caret -> insert paragraph', // @if CK_DEBUG_TYPING // ) ); // @if CK_DEBUG_TYPING // } const selectedModelElement = modelSelection.getSelectedElement(); this._insertParagraph(selectedModelElement, typeAroundFakeCaretPosition); return true; } /** * Creates a listener in the editing conversion pipeline that injects the widget type around * UI into every single widget instance created in the editor. * * The UI is delivered as a {@link module:engine/view/uielement~ViewUIElement} * wrapper which renders DOM buttons that users can use to insert paragraphs. */ _enableTypeAroundUIInjection() { const editor = this.editor; const schema = editor.model.schema; const t = editor.locale.t; const buttonTitles = { before: t('Insert paragraph before block'), after: t('Insert paragraph after block') }; editor.editing.downcastDispatcher.on('insert', (evt, data, conversionApi)=>{ const viewElement = conversionApi.mapper.toViewElement(data.item); if (!viewElement) { return; } // Filter out non-widgets and inline widgets. if (isTypeAroundWidget(viewElement, data.item, schema)) { injectUIIntoWidget(conversionApi.writer, buttonTitles, viewElement); const widgetLabel = viewElement.getCustomProperty('widgetLabel'); widgetLabel.push(()=>{ return this.isEnabled ? t('Press Enter to type after or press Shift + Enter to type before the widget') : ''; }); } }, { priority: 'low' }); } /** * Brings support for the fake caret that appears when either: * * * the selection moves to a widget from a position next to it using arrow keys, * * the arrow key is pressed when the widget is already selected. * * The fake caret lets the user know that they can start typing or just press * <kbd>Enter</kbd> to insert a paragraph at the position next to a widget as suggested by the fake caret. * * The fake caret disappears when the user changes the selection or the editor * gets blurred. * * The whole idea is as follows: * * 1. A user does one of the 2 scenarios described at the beginning. * 2. The "keydown" listener is executed and the decision is made whether to show or hide the fake caret. * 3. If it should show up, the `widget-type-around` model selection attribute is set indicating * on which side of the widget it should appear. * 4. The selection dispatcher reacts to the selection attribute and sets CSS classes responsible for the * fake caret on the view widget. * 5. If the fake caret should disappear, the selection attribute is removed and the dispatcher * does the CSS class clean-up in the view. * 6. Additionally, `change:range` and `FocusTracker#isFocused` listeners also remove the selection * attribute (the former also removes widget CSS classes). */ _enableTypeAroundFakeCaretActivationUsingKeyboardArrows() { const editor = this.editor; const model = editor.model; const modelSelection = model.document.selection; const schema = model.schema; const editingView = editor.editing.view; // This is the main listener responsible for the fake caret. // Note: The priority must precede the default Widget class keydown handler ("high"). this._listenToIfEnabled(editingView.document, 'arrowKey', (evt, domEventData)=>{ this._handleArrowKeyPress(evt, domEventData); }, { context: [ isWidget, '$text' ], priority: 'high' }); // This listener makes sure the widget type around selection attribute will be gone from the model // selection as soon as the model range changes. This attribute only makes sense when a widget is selected // (and the "fake horizontal caret" is visible) so whenever the range changes (e.g. selection moved somewhere else), // let's get rid of the attribute so that the selection downcast dispatcher isn't even bothered. this._listenToIfEnabled(modelSelection, 'change:range', (evt, data)=>{ // Do not reset the selection attribute when the change was indirect. if (!data.directChange) { return; } // Get rid of the widget type around attribute of the selection on every change:range. // If the range changes, it means for sure, the user is no longer in the active ("fake horizontal caret") mode. editor.model.change((writer)=>{ writer.removeSelectionAttribute(TYPE_AROUND_SELECTION_ATTRIBUTE); }); }); // Get rid of the widget type around attribute of the selection on every document change // that makes widget not selected any more (i.e. widget was removed). this._listenToIfEnabled(model.document, 'change:data', ()=>{ const selectedModelElement = modelSelection.getSelectedElement(); if (selectedModelElement) { const selectedViewElement = editor.editing.mapper.toViewElement(selectedModelElement); if (isTypeAroundWidget(selectedViewElement, selectedModelElement, schema)) { return; } } editor.model.change((writer)=>{ writer.removeSelectionAttribute(TYPE_AROUND_SELECTION_ATTRIBUTE); }); }); // React to changes of the model selection attribute made by the arrow keys listener. // If the block widget is selected and the attribute changes, downcast the attribute to special // CSS classes associated with the active ("fake horizontal caret") mode of the widget. this._listenToIfEnabled(editor.editing.downcastDispatcher, 'selection', (evt, data, conversionApi)=>{ const writer = conversionApi.writer; if (this._currentFakeCaretModelElement) { const selectedViewElement = conversionApi.mapper.toViewElement(this._currentFakeCaretModelElement); if (selectedViewElement) { // Get rid of CSS classes associated with the active ("fake horizontal caret") mode from the view widget. writer.removeClass(POSSIBLE_INSERTION_POSITIONS.map(positionToWidgetCssClass), selectedViewElement); this._currentFakeCaretModelElement = null; } } const selectedModelElement = data.selection.getSelectedElement(); if (!selectedModelElement) { return; } const selectedViewElement = conversionApi.mapper.toViewElement(selectedModelElement); if (!isTypeAroundWidget(selectedViewElement, selectedModelElement, schema)) { return; } const typeAroundFakeCaretPosition = getTypeAroundFakeCaretPosition(data.selection); if (!typeAroundFakeCaretPosition) { return; } writer.addClass(positionToWidgetCssClass(typeAroundFakeCaretPosition), selectedViewElement); // Remember the view widget that got the "fake-caret" CSS class. This class should be removed ASAP when the // selection changes this._currentFakeCaretModelElement = selectedModelElement; }); this._listenToIfEnabled(editor.ui.focusTracker, 'change:isFocused', (evt, name, isFocused)=>{ if (!isFocused) { editor.model.change((writer)=>{ writer.removeSelectionAttribute(TYPE_AROUND_SELECTION_ATTRIBUTE); }); } }); function positionToWidgetCssClass(position) { return `ck-widget_type-around_show-fake-caret_${position}`; } } /** * A listener executed on each "keydown" in the view document, a part of * {@link #_enableTypeAroundFakeCaretActivationUsingKeyboardArrows}. * * It decides whether the arrow keypress should activate the fake caret or not (also whether it should * be deactivated). * * The fake caret activation is done by setting the `widget-type-around` model selection attribute * in this listener, and stopping and preventing the event that would normally be handled by the widget * plugin that is responsible for the regular keyboard navigation near/across all widgets (that * includes inline widgets, which are ignored by the widget type around plugin). */ _handleArrowKeyPress(evt, domEventData) { const editor = this.editor; const model = editor.model; const modelSelection = model.document.selection; const schema = model.schema; const editingView = editor.editing.view; // Selection expanding/shrinking is handled without the fake caret by the widget plugin. if (domEventData.shiftKey) { return; } const keyCode = domEventData.keyCode; const isForward = isForwardArrowKeyCode(keyCode, editor.locale.contentLanguageDirection); const selectedViewElement = editingView.document.selection.getSelectedElement(); const selectedModelElement = editor.editing.mapper.toModelElement(selectedViewElement); let shouldStopAndPreventDefault; // Handle keyboard navigation when a type-around-compatible widget is currently selected. if (isTypeAroundWidget(selectedViewElement, selectedModelElement, schema)) { shouldStopAndPreventDefault = this._handleArrowKeyPressOnSelectedWidget(isForward); } else if (modelSelection.isCollapsed) { shouldStopAndPreventDefault = this._handleArrowKeyPressWhenSelectionNextToAWidget(isForward); } else if (!domEventData.shiftKey) { shouldStopAndPreventDefault = this._handleArrowKeyPressWhenNonCollapsedSelection(isForward); } if (shouldStopAndPreventDefault) { domEventData.preventDefault(); evt.stop(); } } /** * Handles the keyboard navigation on "keydown" when a widget is currently selected and activates or deactivates * the fake caret for that widget, depending on the current value of the `widget-type-around` model * selection attribute and the direction of the pressed arrow key. * * @param isForward `true` when the pressed arrow key was responsible for the forward model selection movement * as in {@link module:utils/keyboard~isForwardArrowKeyCode}. * @returns Returns `true` when the keypress was handled and no other keydown listener of the editor should * process the event any further. Returns `false` otherwise. */ _handleArrowKeyPressOnSelectedWidget(isForward) { const editor = this.editor; const model = editor.model; const modelSelection = model.document.selection; const typeAroundFakeCaretPosition = getTypeAroundFakeCaretPosition(modelSelection); return model.change((writer)=>{ // If the fake caret is displayed... if (typeAroundFakeCaretPosition) { const isLeavingWidget = typeAroundFakeCaretPosition === (isForward ? 'after' : 'before'); // If the keyboard arrow works against the value of the selection attribute... // then remove the selection attribute but prevent default DOM actions // and do not let the Widget plugin listener move the selection. This brings // the widget back to the state, for instance, like if was selected using the mouse. // // **Note**: If leaving the widget when the fake caret is active, then the default // Widget handler will change the selection and, in turn, this will automatically discard // the selection attribute. if (!isLeavingWidget) { writer.removeSelectionAttribute(TYPE_AROUND_SELECTION_ATTRIBUTE); return true; } } else { writer.setSelectionAttribute(TYPE_AROUND_SELECTION_ATTRIBUTE, isForward ? 'after' : 'before'); return true; } return false; }); } /** * Handles the keyboard navigation on "keydown" when **no** widget is selected but the selection is **directly** next * to one and upon the fake caret should become active for this widget upon arrow keypress * (AKA entering/selecting the widget). * * **Note**: This code mirrors the implementation from the widget plugin but also adds the selection attribute. * Unfortunately, there is no safe way to let the widget plugin do the selection part first and then just set the * selection attribute here in the widget type around plugin. This is why this code must duplicate some from the widget plugin. * * @param isForward `true` when the pressed arrow key was responsible for the forward model selection movement * as in {@link module:utils/keyboard~isForwardArrowKeyCode}. * @returns Returns `true` when the keypress was handled and no other keydown listener of the editor should * process the event any further. Returns `false` otherwise. */ _handleArrowKeyPressWhenSelectionNextToAWidget(isForward) { const editor = this.editor; const model = editor.model; const schema = model.schema; const widgetPlugin = editor.plugins.get('Widget'); // This is the widget the selection is about to be set on. const modelElementNextToSelection = widgetPlugin._getObjectElementNextToSelection(isForward); const viewElementNextToSelection = editor.editing.mapper.toViewElement(modelElementNextToSelection); if (isTypeAroundWidget(viewElementNextToSelection, modelElementNextToSelection, schema)) { model.change((writer)=>{ widgetPlugin._setSelectionOverElement(modelElementNextToSelection); writer.setSelectionAttribute(TYPE_AROUND_SELECTION_ATTRIBUTE, isForward ? 'before' : 'after'); }); // The change() block above does the same job as the Widget plugin. The event can // be safely canceled. return true; } return false; } /** * Handles the keyboard navigation on "keydown" when a widget is currently selected (together with some other content) * and the widget is the first or last element in the selection. It activates or deactivates the fake caret for that widget. * * @param isForward `true` when the pressed arrow key was responsible for the forward model selection movement * as in {@link module:utils/keyboard~isForwardArrowKeyCode}. * @returns Returns `true` when the keypress was handled and no other keydown listener of the editor should * process the event any further. Returns `false` otherwise. */ _handleArrowKeyPressWhenNonCollapsedSelection(isForward) { const editor = this.editor; const model = editor.model; const schema = model.schema; const mapper = editor.editing.mapper; const modelSelection = model.document.selection; const selectedModelNode = isForward ? modelSelection.getLastPosition().nodeBefore : modelSelection.getFirstPosition().nodeAfter; const selectedViewNode = mapper.toViewElement(selectedModelNode); // There is a widget at the collapse position so collapse the selection to the fake caret on it. if (isTypeAroundWidget(selectedViewNode, selectedModelNode, schema)) { model.change((writer)=>{ writer.setSelection(selectedModelNode, 'on'); writer.setSelectionAttribute(TYPE_AROUND_SELECTION_ATTRIBUTE, isForward ? 'after' : 'before'); }); return true; } return false; } /** * Registers a `mousedown` listener for the view document which intercepts events * coming from the widget type around UI, which happens when a user clicks one of the buttons * that insert a paragraph next to a widget. */ _enableInsertingParagraphsOnButtonClick() { const editor = this.editor; const editingView = editor.editing.view; this._listenToIfEnabled(editingView.document, 'mousedown', (evt, domEventData)=>{ const button = getClosestTypeAroundDomButton(domEventData.domTarget); if (!button) { return; } const buttonPosition = getTypeAroundButtonPosition(button); const widgetViewElement = getClosestWidgetViewElement(button, editingView.domConverter); const widgetModelElement = editor.editing.mapper.toModelElement(widgetViewElement); this._insertParagraph(widgetModelElement, buttonPosition); domEventData.preventDefault(); evt.stop(); }); } /** * Creates the <kbd>Enter</kbd> key listener on the view document that allows the user to insert a paragraph * near the widget when either: * * * The fake caret was first activated using the arrow keys, * * The entire widget is selected in the model. * * In the first case, the new paragraph is inserted according to the `widget-type-around` selection * attribute (see {@link #_handleArrowKeyPress}). * * In the second case, the new paragraph is inserted based on whether a soft (<kbd>Shift</kbd>+<kbd>Enter</kbd>) keystroke * was pressed or not. */ _enableInsertingParagraphsOnEnterKeypress() { const editor = this.editor; const selection = editor.model.document.selection; const editingView = editor.editing.view; this._listenToIfEnabled(editingView.document, 'enter', (evt, domEventData)=>{ // This event could be triggered from inside the widget but we are interested // only when the widget is selected itself. if (evt.eventPhase != 'atTarget') { return; } const selectedModelElement = selection.getSelectedElement(); const selectedViewElement = editor.editing.mapper.toViewElement(selectedModelElement); const schema = editor.model.schema; let wasHandled; // First check if the widget is selected and there's a type around selection attribute associated // with the fake caret that would tell where to insert a new paragraph. if (this._insertParagraphAccordingToFakeCaretPosition()) { wasHandled = true; } else if (isTypeAroundWidget(selectedViewElement, selectedModelElement, schema)) { this._insertParagraph(selectedModelElement, domEventData.isSoft ? 'before' : 'after'); wasHandled = true; } if (wasHandled) { domEventData.preventDefault(); evt.stop(); } }, { context: isWidget }); } /** * Similar to the {@link #_enableInsertingParagraphsOnEnterKeypress}, it allows the user * to insert a paragraph next to a widget when the fake caret was activated using arrow * keys but it responds to typing instead of <kbd>Enter</kbd>. * * Listener enabled by this method will insert a new paragraph according to the `widget-type-around` * model selection attribute as the user simply starts typing, which creates the impression that the fake caret * behaves like a real one rendered by the browser (AKA your text appears where the caret was). * * **Note**: At the moment this listener creates 2 undo steps: one for the `insertParagraph` command * and another one for actual typing. It is not a disaster but this may need to be fixed * sooner or later. */ _enableInsertingParagraphsOnTypingKeystroke() { const editor = this.editor; const viewDocument = editor.editing.view.document; // Note: The priority must precede the default Input plugin insertText handler. this._listenToIfEnab