UNPKG

@ckeditor/ckeditor5-engine

Version:

The editing engine of CKEditor 5 – the best browser-based rich text editor.

286 lines (285 loc) 12.5 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 */ /** * @module engine/view/placeholder */ import '../../theme/placeholder.css'; import { logWarning } from '@ckeditor/ckeditor5-utils'; // Each document stores information about its placeholder elements and check functions. const documentPlaceholders = new WeakMap(); let hasDisplayedPlaceholderDeprecationWarning = false; /** * A helper that enables a placeholder on the provided view element (also updates its visibility). * The placeholder is a CSS pseudo–element (with a text content) attached to the element. * * To change the placeholder text, change value of the `placeholder` property in the provided `element`. * * To disable the placeholder, use {@link module:engine/view/placeholder~disableViewPlaceholder `disableViewPlaceholder()`} helper. * * @param options Configuration options of the placeholder. * @param options.view Editing view instance. * @param options.element Element that will gain a placeholder. See `options.isDirectHost` to learn more. * @param options.isDirectHost If set `false`, the placeholder will not be enabled directly * in the passed `element` but in one of its children (selected automatically, i.e. a first empty child element). * Useful when attaching placeholders to elements that can host other elements (not just text), for instance, * editable root elements. * @param options.text Placeholder text. It's **deprecated** and will be removed soon. Use * {@link module:engine/view/placeholder~PlaceholderableViewElement#placeholder `options.element.placeholder`} instead. * @param options.keepOnFocus If set `true`, the placeholder stay visible when the host element is focused. */ export function enableViewPlaceholder({ view, element, text, isDirectHost = true, keepOnFocus = false }) { const doc = view.document; // Use a single post fixer per—document to update all placeholders. if (!documentPlaceholders.has(doc)) { documentPlaceholders.set(doc, new Map()); // If a post-fixer callback makes a change, it should return `true` so other post–fixers // can re–evaluate the document again. doc.registerPostFixer(writer => updateDocumentPlaceholders(documentPlaceholders.get(doc), writer)); // Update placeholders on isComposing state change since rendering is disabled while in composition mode. doc.on('change:isComposing', () => { view.change(writer => updateDocumentPlaceholders(documentPlaceholders.get(doc), writer)); }, { priority: 'high' }); } if (element.is('editableElement')) { element.on('change:placeholder', (evtInfo, evt, text) => setPlaceholder(text)); } if (element.placeholder) { setPlaceholder(element.placeholder); } else if (text) { setPlaceholder(text); } if (text) { showViewPlaceholderTextDeprecationWarning(); } function setPlaceholder(text) { const config = { text, isDirectHost, keepOnFocus, hostElement: isDirectHost ? element : null }; // Store information about the element placeholder under its document. documentPlaceholders.get(doc).set(element, config); // Update the placeholders right away. view.change(writer => updateDocumentPlaceholders([[element, config]], writer)); } } /** * Disables the placeholder functionality from a given element. * * See {@link module:engine/view/placeholder~enableViewPlaceholder `enableViewPlaceholder()`} to learn more. */ export function disableViewPlaceholder(view, element) { const doc = element.document; if (!documentPlaceholders.has(doc)) { return; } view.change(writer => { const placeholders = documentPlaceholders.get(doc); const config = placeholders.get(element); writer.removeAttribute('data-placeholder', config.hostElement); hideViewPlaceholder(writer, config.hostElement); placeholders.delete(element); }); } /** * Shows a placeholder in the provided element by changing related attributes and CSS classes. * * **Note**: This helper will not update the placeholder visibility nor manage the * it in any way in the future. What it does is a one–time state change of an element. Use * {@link module:engine/view/placeholder~enableViewPlaceholder `enableViewPlaceholder()`} and * {@link module:engine/view/placeholder~disableViewPlaceholder `disableViewPlaceholder()`} for full * placeholder functionality. * * **Note**: This helper will blindly show the placeholder directly in the root editable element if * one is passed, which could result in a visual clash if the editable element has some children * (for instance, an empty paragraph). Use {@link module:engine/view/placeholder~enableViewPlaceholder `enableViewPlaceholder()`} * in that case or make sure the correct element is passed to the helper. * * @returns `true`, if any changes were made to the `element`. */ export function showViewPlaceholder(writer, element) { if (!element.hasClass('ck-placeholder')) { writer.addClass('ck-placeholder', element); return true; } return false; } /** * Hides a placeholder in the element by changing related attributes and CSS classes. * * **Note**: This helper will not update the placeholder visibility nor manage the * it in any way in the future. What it does is a one–time state change of an element. Use * {@link module:engine/view/placeholder~enableViewPlaceholder `enableViewPlaceholder()`} and * {@link module:engine/view/placeholder~disableViewPlaceholder `disableViewPlaceholder()`} for full * placeholder functionality. * * @returns `true`, if any changes were made to the `element`. */ export function hideViewPlaceholder(writer, element) { if (element.hasClass('ck-placeholder')) { writer.removeClass('ck-placeholder', element); return true; } return false; } /** * Checks if a placeholder should be displayed in the element. * * **Note**: This helper will blindly check the possibility of showing a placeholder directly in the * root editable element if one is passed, which may not be the expected result. If an element can * host other elements (not just text), most likely one of its children should be checked instead * because it will be the final host for the placeholder. Use * {@link module:engine/view/placeholder~enableViewPlaceholder `enableViewPlaceholder()`} in that case or make * sure the correct element is passed to the helper. * * @param element Element that holds the placeholder. * @param keepOnFocus Focusing the element will keep the placeholder visible. */ export function needsViewPlaceholder(element, keepOnFocus) { if (!element.isAttached()) { return false; } if (hasContent(element)) { return false; } const doc = element.document; const viewSelection = doc.selection; const selectionAnchor = viewSelection.anchor; if (doc.isComposing && selectionAnchor && selectionAnchor.parent === element) { return false; } // Skip the focus check and make the placeholder visible already regardless of document focus state. if (keepOnFocus) { return true; } // If the document is blurred. if (!doc.isFocused) { return true; } // If document is focused and the element is empty but the selection is not anchored inside it. return !!selectionAnchor && selectionAnchor.parent !== element; } /** * Anything but uiElement(s) counts as content. */ function hasContent(element) { for (const child of element.getChildren()) { if (!child.is('uiElement')) { return true; } } return false; } /** * Updates all placeholders associated with a document in a post–fixer callback. * * @returns True if any changes were made to the view document. */ function updateDocumentPlaceholders(placeholders, writer) { const directHostElements = []; let wasViewModified = false; // First set placeholders on the direct hosts. for (const [element, config] of placeholders) { if (config.isDirectHost) { directHostElements.push(element); if (updatePlaceholder(writer, element, config)) { wasViewModified = true; } } } // Then set placeholders on the indirect hosts but only on those that does not already have an direct host placeholder. for (const [element, config] of placeholders) { if (config.isDirectHost) { continue; } const hostElement = getChildPlaceholderHostSubstitute(element); // If host element changed, remove the placeholder from the previous one. // This can happen when user replaces the first child element of the parent element // with new one, but the previous one is still in the view tree. // See: // https://github.com/ckeditor/ckeditor5/issues/14354 // https://github.com/ckeditor/ckeditor5/issues/18149 if (hostElement !== config.hostElement && config.hostElement) { writer.removeAttribute('data-placeholder', config.hostElement); hideViewPlaceholder(writer, config.hostElement); config.hostElement = null; wasViewModified = true; } // When not a direct host, it could happen that there is no child element // capable of displaying a placeholder. if (!hostElement) { continue; } // Don't override placeholder if the host element already has some direct placeholder. if (directHostElements.includes(hostElement)) { continue; } // Update the host element (used for setting and removing the placeholder). config.hostElement = hostElement; if (updatePlaceholder(writer, element, config)) { wasViewModified = true; } } return wasViewModified; } /** * Updates a single placeholder in a post–fixer callback. * * @returns True if any changes were made to the view document. */ function updatePlaceholder(writer, element, config) { const { text, isDirectHost, hostElement } = config; let wasViewModified = false; // This may be necessary when updating the placeholder text to something else. if (hostElement.getAttribute('data-placeholder') !== text) { writer.setAttribute('data-placeholder', text, hostElement); wasViewModified = true; } // If the host element is not a direct host then placeholder is needed only when there is only one element. const isOnlyChild = isDirectHost || element.childCount == 1; if (isOnlyChild && needsViewPlaceholder(hostElement, config.keepOnFocus)) { if (showViewPlaceholder(writer, hostElement)) { wasViewModified = true; } } else if (hideViewPlaceholder(writer, hostElement)) { wasViewModified = true; } return wasViewModified; } /** * Gets a child element capable of displaying a placeholder if a parent element can host more * than just text (for instance, when it is a root editable element). The child element * can then be used in other placeholder helpers as a substitute of its parent. */ function getChildPlaceholderHostSubstitute(parent) { if (parent.childCount) { const firstChild = parent.getChild(0); if (firstChild.is('element') && !firstChild.is('uiElement') && !firstChild.is('attributeElement')) { return firstChild; } } return null; } /** * Displays a deprecation warning message in the console, but only once per page load. */ function showViewPlaceholderTextDeprecationWarning() { if (!hasDisplayedPlaceholderDeprecationWarning) { /** * The "text" option in the {@link module:engine/view/placeholder~enableViewPlaceholder `enableViewPlaceholder()`} * function is deprecated and will be removed soon. * * See the {@glink updating/guides/update-to-39#view-element-placeholder Migration to v39} guide for * more information on how to apply this change. * * @error enableViewPlaceholder-deprecated-text-option */ logWarning('enableViewPlaceholder-deprecated-text-option'); } hasDisplayedPlaceholderDeprecationWarning = true; }