UNPKG

suneditor

Version:

Vanilla JavaScript based WYSIWYG web editor

650 lines (578 loc) 19.2 kB
import { _d, _w } from '../env'; import { isImportantDisabled } from './domCheck'; import { htmlToEntity } from '../converter'; // ----- iframe-safe type check [START] ----- /** * @description iframe-safe : Node type [`HTMLCollection`, `NodeList`, `Array`] check. * @param {*} element * @returns {element is HTMLCollection|NodeList|Array} */ function IsElementArray(element) { const type = Object.prototype.toString.call(element); return type === '[object HTMLCollection]' || type === '[object NodeList]' || type === '[object Array]'; } /** * @description iframe-safe: check if element is an `HTMLImageElement` * @param {*} element * @returns {element is HTMLImageElement} */ function IsHTMLImageElement(element) { const type = Object.prototype.toString.call(element); return type === '[object HTMLImageElement]'; } /** * @description iframe-safe: check if element is an `HTMLMediaElement` (video or audio) * @param {*} element * @returns {element is HTMLMediaElement} */ function IsHTMLMediaElement(element) { const type = Object.prototype.toString.call(element); return type === '[object HTMLVideoElement]' || type === '[object HTMLAudioElement]'; } /** * @description iframe-safe: check if element is an `HTMLIFrameElement` * @param {*} element * @returns {element is HTMLIFrameElement} */ function IsHTMLIFrameElement(element) { const type = Object.prototype.toString.call(element); return type === '[object HTMLIFrameElement]'; } // ----- iframe-safe type check [END] ----- /** * @template {Node} T * @description Clones a node while preserving its type. * @param {T} node - The node to clone. * @param {boolean} [deep=false] - Whether to perform a deep clone. * @returns {T} - The cloned node. */ export function clone(node, deep = false) { return /** @type {T} */ (node.cloneNode(deep)); } /** * @template {HTMLElement} T * @description Create Element node * @example * // Create with attributes * const span = dom.utils.createElement('SPAN', { style: 'color:red;', class: 'highlight' }); * * // Create with HTML string content * const div = dom.utils.createElement('DIV', null, '<p>Hello</p>'); * * // Create with a child node * const br = dom.utils.createElement('BR'); * const p = dom.utils.createElement('P', null, br); * @param {string} elementName Element name * @param {?Object<string, string>} [attributes] The attributes of the tag. {style: 'font-size:12px;..', class: 'el_class',..} * @param {?string|Node} [inner] A innerHTML string or inner node. * @returns {T} */ export function createElement(elementName, attributes, inner) { const el = _d.createElement(elementName); if (attributes) { for (const key in attributes) { if (attributes[key] !== undefined && attributes[key] !== null) el.setAttribute(key, attributes[key]); } } if (inner) { if (typeof inner === 'string') { el.innerHTML = inner; } else if (typeof inner === 'object') { el.appendChild(inner); } } return /** @type {T} */ (el); } /** * @description Create text node * @param {string} text text content * @returns {Text} */ export function createTextNode(text) { return _d.createTextNode(text || ''); } /** * @description Get attributes of argument element to string ('class="---" name="---" ') * @param {Node} element Element object * @param {?Array<string>} exceptAttrs Array of attribute names to exclude from the result * @returns {string} * @example * const attrs = dom.utils.getAttributesToString(element, ['id', 'class']); */ export function getAttributesToString(element, exceptAttrs) { const attrs = /** @type {HTMLElement} */ (element).attributes; if (!attrs) return ''; let attrString = ''; for (let i = 0, len = attrs.length; i < len; i++) { if (exceptAttrs?.includes(attrs[i].name)) continue; attrString += attrs[i].name + '="' + htmlToEntity(attrs[i].value) + '" '; } return attrString; } /** * @description Get the items array from the array that matches the condition. * @param {SunEditor.NodeCollection} array Array to get item * @param {?(current: *) => boolean} validation Conditional function * @returns {Array<Node>|null} */ export function arrayFilter(array, validation) { if (!array || array.length === 0) return null; validation ||= () => true; const arr = []; for (let i = 0, len = array.length, a; i < len; i++) { a = array[i]; if (validation(a)) { arr.push(a); } } return arr; } /** * @description Get the item from the array that matches the condition. * @param {SunEditor.NodeCollection} array Array to get item * @param {?(current: *) => boolean} validation Conditional function * @returns {Node|null} */ export function arrayFind(array, validation) { if (!array || array.length === 0) return null; validation ||= () => true; for (let i = 0, len = array.length, a; i < len; i++) { a = array[i]; if (validation(a)) { return a; } } return null; } /** * @description Check if an array contains an element * @param {SunEditor.NodeCollection} array element array * @param {Node} node The node to check for * @returns {boolean} */ export function arrayIncludes(array, node) { for (let i = 0; i < array.length; i++) { if (array[i] === node) { return true; } } return false; } /** * @description Get the index of the argument value in the element array * @param {SunEditor.NodeCollection} array element array * @param {Node} node The element to find index * @returns {number} */ export function getArrayIndex(array, node) { let idx = -1; for (let i = 0, len = array.length; i < len; i++) { if (array[i] === node) { idx = i; break; } } return idx; } /** * @description Get the next index of the argument value in the element array * @param {SunEditor.NodeCollection} array element array * @param {Node} item The element to find index * @returns {number} */ export function nextIndex(array, item) { const idx = getArrayIndex(array, item); if (idx === -1) return -1; return idx + 1; } /** * @description Get the previous index of the argument value in the element array * @param {SunEditor.NodeCollection} array Element array * @param {Node} item The element to find index * @returns {number} */ export function prevIndex(array, item) { const idx = getArrayIndex(array, item); if (idx === -1) return -1; return idx - 1; } /** * @description Add style and className of copyEl to originEl * @param {Node} originEl Origin element * @param {Node} copyEl Element to copy * @param {?Array<string>} [blacklist] Blacklist array(LowerCase) * @example * dom.utils.copyTagAttributes(newElement, originalElement, ['contenteditable']); */ export function copyTagAttributes(originEl, copyEl, blacklist) { const o = /** @type {HTMLElement} */ (originEl); const c = /** @type {HTMLElement} */ (copyEl); if (c.style.length > 0) { const copyStyles = c.style; for (let i = 0, len = copyStyles.length; i < len; i++) { o.style[copyStyles[i]] = copyStyles[copyStyles[i]]; } } const attrs = c.attributes; for (let i = 0, len = attrs.length, name; i < len; i++) { name = attrs[i].name.toLowerCase(); if (blacklist?.includes(name) || !attrs[i].value) o.removeAttribute(name); else if (name !== 'style') o.setAttribute(attrs[i].name, attrs[i].value); } } /** * @description Copy and apply attributes of format tag that should be maintained. (style, class) Ignore `__se__format__` class * @param {Node} originEl Origin element * @param {Node} copyEl Element to copy */ export function copyFormatAttributes(originEl, copyEl) { const c = /** @type {HTMLElement} */ (copyEl.cloneNode(false)); c.className = c.className.replace(/(\s|^)__se__format__[^\s]+/g, ''); copyTagAttributes(originEl, c); } /** * @description Delete argumenu value element * @param {Node} item Node to be remove */ export function removeItem(item) { if (!item) return; if ('remove' in item && typeof item.remove === 'function') item.remove(); else if (item.parentNode) item.parentNode.removeChild(item); } /** * @description Replace element * @param {Node} element Target element * @param {string|Node} newElement String or element of the new element to apply * @example * dom.utils.changeElement(oldSpan, 'STRONG'); * dom.utils.changeElement(oldElement, newElement); */ export function changeElement(element, newElement) { if (!element) return; if (typeof newElement === 'string') { if ('outerHTML' in element) { element.outerHTML = newElement; } else { const doc = createElement('DIV'); doc.innerHTML = newElement; element.parentNode.replaceChild(doc.firstChild, element); } } else if (newElement?.nodeType === 1) { element.parentNode.replaceChild(newElement, element); } } /** * @description Set the text content value of the argument value element * @param {Node} node Element to replace text content * @param {string} txt Text to be applied */ export function changeTxt(node, txt) { if (!node || !txt) return; node.textContent = txt; } /** * @description Set style, if all styles are deleted, the style properties are deleted. * @param {Node|Node[]} elements Element to set style * @param {string} styleName Style attribute name (`marginLeft`, `textAlign`...) * @param {string|number} value Style value * @example * dom.utils.setStyle(element, 'color', 'red'); * dom.utils.setStyle([el1, el2], 'display', 'none'); */ export function setStyle(elements, styleName, value) { elements = Array.isArray(elements) ? elements : [elements]; for (let i = 0, len = elements.length, e; i < len; i++) { e = /** @type {HTMLElement} */ (elements[i]); e.style[styleName] = value; if (e.style.length === 0) { e.removeAttribute('style'); } } } /** * @description Gets the style value of the element. If the elements is an array, the style of the first element is returned. * @param {Node} element Element to get style from. * @param {string} styleName Style attribute name (e.g., `marginLeft`, `textAlign`). * @returns {string | undefined} The value of the style attribute, or `undefined` if the element does not exist. */ export function getStyle(element, styleName) { if (element?.nodeType !== 1) { return undefined; } return /** @type {HTMLElement} */ (element).style[styleName]; } /** * @description In the predefined code view mode, the buttons except the executable button are changed to the `disabled` state. * @param {SunEditor.NodeCollection} buttonList (Button | Input) Element array * @param {boolean} disabled Disabled value * @param {boolean} [important=false] If priveleged mode should be used (Necessary to switch importantDisabled buttons) */ export function setDisabled(buttonList, disabled, important) { for (let i = 0, len = buttonList.length; i < len; i++) { const button = /** @type {HTMLButtonElement|HTMLInputElement} */ (buttonList[i]); if (important || !isImportantDisabled(button)) button.disabled = disabled; if (important) { if (disabled) { button.setAttribute('data-important-disabled', ''); } else { button.removeAttribute('data-important-disabled'); } } } } /** * @description Determine whether any of the matched elements are assigned the given class * @param {?Node} element Elements to search class name * @param {string} className Class name to search for * @returns {boolean} */ export function hasClass(element, className) { if (!element || element.nodeType !== 1) return; return className.split('|').some((cls) => /** @type {HTMLElement} */ (element).classList.contains(cls)); } /** * @description Append the className value of the argument value element * @param {Node|SunEditor.NodeCollection} element Elements to add class name * @param {string} className Class name to be add * @example * dom.utils.addClass(element, 'active'); * dom.utils.addClass(element.children, 'highlight'); */ export function addClass(element, className) { if (!element) return; const elements = IsElementArray(element) ? element : [element]; const classNames = className.split('|'); for (let i = 0, len = elements.length; i < len; i++) { const e = elements[i]; if (!e || e.nodeType !== 1) continue; for (const c of classNames) { if (c) /** @type {HTMLElement} */ (e).classList.add(c); } } } /** * @description Delete the className value of the argument value element * @param {Node|SunEditor.NodeCollection} element Elements to remove class name * @param {string} className Class name to be remove * @example * dom.utils.removeClass(element, 'active'); */ export function removeClass(element, className) { if (!element) return; const elements = IsElementArray(element) ? element : [element]; const classNames = className.split('|'); for (let i = 0, len = elements.length; i < len; i++) { const e = elements[i]; if (!e || e.nodeType !== 1) continue; for (const c of classNames) { if (c) /** @type {HTMLElement} */ (e).classList.remove(c); } } } /** * @description Argument value If there is no class name, insert it and delete the class name if it exists * @param {Node} element Element to replace class name * @param {string} className Class name to be change * @param {boolean} [force] If true, adds the class; if false, removes it. */ export function toggleClass(element, className, force) { if (!element || element.nodeType !== 1) return; const el = /** @type {HTMLElement} */ (element); el.classList.toggle(className, force); if (!el.className.trim()) el.removeAttribute('class'); } /** * @description Flash the class name of the argument value element for a certain time * @param {Node} element Element to flash class name * @param {string} className class name * @param {number} [duration=120] duration milliseconds * @example * dom.utils.flashClass(element, 'blink', 500); */ export function flashClass(element, className, duration = 120) { addClass(element, className); _w.setTimeout(() => { removeClass(element, className); }, duration); } /** * @description Gets the size of the documentElement client size. * @param {Document} doc Document object * @returns {{w: number, h: number}} documentElement.clientWidth, documentElement.clientHeight */ export function getClientSize(doc = _d) { return { w: doc.documentElement.clientWidth, h: doc.documentElement.clientHeight, }; } /** * @description Gets the size of the window visualViewport size * @returns {{top: number, left: number, scale: number}} */ export function getViewportSize() { if ('visualViewport' in _w) { return { top: _w.visualViewport.pageTop, left: _w.visualViewport.pageLeft, scale: _w.visualViewport.scale, }; } return { top: 0, left: 0, scale: 1, }; } /** * @description Copies the `wwTarget` element and returns it with inline all styles applied. * @param {Node} wwTarget Target element to copy(`.sun-editor.sun-editor-editable`) * @param {boolean} includeWW Include the `wwTarget` element in the copy * @param {Iterable<string>} styles Style list - kamel case * @returns * @example * dom.utils.applyInlineStylesAll(wysiwygElement, true, ['font-family', 'font-size']); */ export function applyInlineStylesAll(wwTarget, includeWW, styles) { if (!wwTarget) { console.warn('"parentTarget" is not exist'); return null; } let ww = /** @type {HTMLElement} */ (wwTarget); const tempTarget = _d.createElement('DIV'); tempTarget.style.display = 'none'; if (/body/i.test(ww.nodeName)) { const wwDiv = _d.createElement('DIV'); const attrs = ww.attributes; for (let i = 0, len = attrs.length; i < len; i++) { wwDiv.setAttribute(attrs[i].name, attrs[i].value); } wwDiv.innerHTML = ww.innerHTML; ww = wwDiv; } else { ww = /** @type {HTMLElement} */ (ww.cloneNode(true)); } tempTarget.appendChild(ww); _d.body.appendChild(tempTarget); /** @type {HTMLElement[]} */ const allElements = Array.from(ww.querySelectorAll('*')); const elements = includeWW ? [ww].concat(allElements) : allElements; for (let i = 0, el; (el = elements[i]); i++) { if (el.nodeType !== 1) continue; const computedStyle = _w.getComputedStyle(el); const els = el.style; for (const props of styles) { els.setProperty(props, computedStyle.getPropertyValue(props) || ''); } } _d.body.removeChild(tempTarget); return ww; } /** * @description Wait for media elements to load * @param {Node} target Target element * @param {number} timeout Timeout milliseconds * @returns {Promise<void>} * @example * await dom.utils.waitForMediaLoad(imgElement, 5000); */ export function waitForMediaLoad(target, timeout = 5000) { const doc = /** @type {HTMLElement|Document} */ (target || _d); return new Promise((resolveAll) => { const selectors = ['img', 'video', 'audio', 'iframe']; const mediaElements = selectors.flatMap((selector) => Array.from(doc.querySelectorAll(selector))); if (mediaElements.length === 0) { resolveAll(); return; } const mediaPromises = mediaElements.map((element) => { // image if (IsHTMLImageElement(element)) { if (element.complete) { return Promise.resolve(); } } // video, audio else if (IsHTMLMediaElement(element)) { if (element.readyState >= 2) { return Promise.resolve(); } } // iframe else if (IsHTMLIFrameElement(element)) { try { if (element.contentDocument?.readyState === 'complete') { return Promise.resolve(); } } catch (e) { console.warn(['[SUNEDITOR] Iframe load error', e]); } } // load event return new Promise((resolve) => { element.addEventListener('load', resolve, { once: true }); element.addEventListener('error', resolve, { once: true }); }); }); Promise.race([Promise.all(mediaPromises), new Promise((resolve) => _w.setTimeout(resolve, timeout))]).then(() => { resolveAll(); }); }); } /** * @description Gets a CSS variable on the root element of the editor. * @param {string} name - The CSS variable name (e.g. `--se-color-primary`) * @return {string} The value of the CSS variable */ export function getRootCssVar(name) { return _d.documentElement.style.getPropertyValue(name); } /** * @description Sets a CSS variable on the root element of the editor. * @param {string} name - The CSS variable name (e.g. `--se-color-primary`) * @param {string} value - The CSS variable value */ export function setRootCssVar(name, value) { _d.documentElement.style.setProperty(name, value); } /** * @description Create tooltip HTML * @param {string} text Tooltip text * @returns {string} Tooltip HTML */ export function createTooltipInner(text) { return `<span class="se-tooltip-inner"><span class="se-tooltip-text">${text}</span></span>`; } const utils = { clone, createElement, createTextNode, getAttributesToString, arrayFilter, arrayFind, arrayIncludes, getArrayIndex, nextIndex, prevIndex, copyTagAttributes, copyFormatAttributes, removeItem, changeElement, changeTxt, setStyle, getStyle, setDisabled, hasClass, addClass, removeClass, toggleClass, flashClass, getClientSize, getViewportSize, applyInlineStylesAll, waitForMediaLoad, getRootCssVar, setRootCssVar, createTooltipInner, }; export default utils;