suneditor
Version:
Vanilla JavaScript based WYSIWYG web editor
752 lines (649 loc) • 22 kB
JavaScript
import { _w } from '../env';
import { zeroWidthRegExp } from '../unicode';
import domUtils from './domUtils';
import domCheck from './domCheck';
/**
* @description Returns the index compared to other sibling nodes.
* @param {Node} node The Node to find index
* @returns {number}
*/
export function getPositionIndex(node) {
let idx = 0;
while ((node = node.previousSibling)) {
idx += 1;
}
return idx;
}
/**
* @description Returns the position of the `node` in the `parentNode` in a numerical array.
* - e.g.) <p><span>aa</span><span>bb</span></p> : getNodePath(node: "bb", parentNode: "<P>") -> [1, 0]
* @param {Node} node The Node to find position path
* @param {?Node} parentNode Parent node. If `null`, wysiwyg `div` area
* @param {?{s: number, e: number}} [_newOffsets] If you send an object of the form `{s: 0, e: 0}`, the text nodes that are attached together are merged into one, centered on the `node` argument.
* `_newOffsets.s` stores the length of the combined characters after `node` and `_newOffsets.e` stores the length of the combined characters before `node`.
* Do not use unless absolutely necessary.
* @returns {Array<number>}
*/
export function getNodePath(node, parentNode, _newOffsets) {
const path = [];
let finds = true;
getParentElement(node, (el) => {
if (el === parentNode) finds = false;
if (finds && !domCheck.isWysiwygFrame(el)) {
// merge text nodes
if (_newOffsets && el.nodeType === 3) {
let temp = null,
tempText = null;
_newOffsets.s = _newOffsets.e = 0;
let previous = el.previousSibling;
while (previous?.nodeType === 3) {
tempText = previous.textContent.replace(zeroWidthRegExp, '');
_newOffsets.s += tempText.length;
el.textContent = tempText + el.textContent;
temp = previous;
previous = previous.previousSibling;
domUtils.removeItem(temp);
}
let next = el.nextSibling;
while (next?.nodeType === 3) {
tempText = next.textContent.replace(zeroWidthRegExp, '');
_newOffsets.e += tempText.length;
el.textContent += tempText;
temp = next;
next = next.nextSibling;
domUtils.removeItem(temp);
}
}
// index push
path.push(el);
}
return false;
});
return path.map(getPositionIndex).reverse();
}
/**
* @template {Node} T
* @description Returns the node in the location of the path array obtained from `helper.dom.getNodePath`.
* @param {Array<number>} offsets Position array, array obtained from `helper.dom.getNodePath`
* @param {Node} parentNode Base parent element
* @returns {T}
* @example
* const node = dom.query.getNodeFromPath([0, 1, 0], wysiwygElement);
*/
export function getNodeFromPath(offsets, parentNode) {
let current = parentNode;
let nodes;
for (let i = 0, len = offsets.length; i < len; i++) {
nodes = current.childNodes;
if (nodes.length === 0) break;
if (nodes.length <= offsets[i]) {
current = nodes[nodes.length - 1];
} else {
current = nodes[offsets[i]];
}
}
return /** @type {T} */ (current);
}
/**
* @template {HTMLElement} T
* @description Get all `child node` of the argument value element
* @param {Node} element element to get child node
* @param {?(current: *) => boolean} validation Conditional function
* @returns {T|null}
*/
export function getChildNode(element, validation) {
let child = null;
if (!element) return child;
const el = /** @type {Element} */ (element);
if (!el.children || el.children.length === 0) return child;
validation ||= () => true;
(function recursionFunc(current) {
if (el !== current && validation(current)) {
child = current;
return true;
}
if (current.children) {
for (let i = 0, len = current.children.length; i < len; i++) {
recursionFunc(current.children[i]);
}
}
})(el);
return /** @type {T} */ (child);
}
/**
* @template {HTMLElement} T
* @description Get all `children` of the argument value element (Without text nodes)
* @param {Node} element element to get child node
* @param {?(current: *) => boolean} validation Conditional function
* @param {?number} depth Number of child levels to depth.
* @returns {Array<T>}
*/
export function getListChildren(element, validation, depth) {
/** @type {Array<T>} */
const children = [];
depth ??= Infinity;
if (!element || depth <= 0) return /** @type {Array<T>} */ (children);
const el = /** @type {Element} */ (element);
if (!el.children || el.children.length === 0) return children;
validation ||= () => true;
(function recursionFunc(current, level) {
if (level > depth) return;
if (level > 0 && el !== current && validation(current)) {
children.push(/** @type {T} */ (current));
}
if (level === depth) return;
if (current.children) {
for (let i = 0, len = current.children.length; i < len; i++) {
recursionFunc(current.children[i], level + 1);
}
}
})(el, 0);
return /** @type {Array<T>} */ (children);
}
/**
* @template {Node} T
* @description Get all `childNodes` of the argument value element (Include text nodes)
* @param {Node} element element to get child node
* @param {?(current: *) => boolean} validation Conditional function
* @param {?number} depth Number of child levels to depth.
* @returns {Array<T>}
* @example
* const allNodes = dom.query.getListChildNodes(container, (node) => node.nodeType === 3);
*/
export function getListChildNodes(element, validation, depth) {
const children = [];
depth ??= Infinity;
if (!element || depth <= 0 || element.childNodes.length === 0) return /** @type {Array<T>} */ (children);
validation ||= () => true;
(function recursionFunc(current, level) {
if (level > depth) return;
if (level > 0 && validation(current)) {
children.push(current);
}
if (level === depth) return;
const nodes = current.childNodes;
for (let i = 0, len = nodes.length; i < len; i++) {
recursionFunc(nodes[i], level + 1);
}
})(element, 0);
return /** @type {Array<T>} */ (children);
}
/**
* @description Returns the number of parents nodes.
* - `0` when the parent node is the WYSIWYG area.
* - `-1` when the element argument is the WYSIWYG area.
* @param {Node} node The element to check
* @returns {number}
*/
export function getNodeDepth(node) {
if (!node || domCheck.isWysiwygFrame(node)) return -1;
let depth = 0;
node = node.parentNode;
while (node && !domCheck.isWysiwygFrame(node)) {
depth += 1;
node = node.parentNode;
}
return depth;
}
/**
* @description Sort a node array by depth of element.
* @param {Array<Node>} array Node array
* @param {boolean} des `true`: descending order / `false`: ascending order
* @example
* const sorted = dom.query.sortNodeByDepth([nodeA, nodeB], true);
*/
export function sortNodeByDepth(array, des) {
const t = !des ? -1 : 1;
const f = t * -1;
array.sort(function (a, b) {
if (!domCheck.isListCell(a) || !domCheck.isListCell(b)) return 0;
const a_i = getNodeDepth(a);
const b_i = getNodeDepth(b);
return a_i > b_i ? t : a_i < b_i ? f : 0;
});
}
/**
* @description Compares two elements to find a common ancestor, and returns the order of the two elements.
* @param {Node} a Node to compare.
* @param {Node} b Node to compare.
* @returns {{ancestor: ?HTMLElement, a: Node, b: Node, result: number}} { ancesstor, a, b, result: (a > b ? 1 : a < b ? -1 : 0) };
* @example
* const result = dom.query.compareElements(nodeA, nodeB);
* // result: { ancestor: true, result: 0 } (same node), { ancestor: true, result: 1 } (a before b)
*/
export function compareElements(a, b) {
let aNode = a,
bNode = b;
// Equalize depth
const aDepth = getNodeDepth(a);
const bDepth = getNodeDepth(b);
if (aDepth > bDepth) {
let diff = aDepth - bDepth;
while (diff > 0 && aNode) {
aNode = aNode.parentElement;
diff--;
}
} else if (bDepth > aDepth) {
let diff = bDepth - aDepth;
while (diff > 0 && bNode) {
bNode = bNode.parentElement;
diff--;
}
}
while (aNode && bNode && aNode.parentElement !== bNode.parentElement) {
aNode = aNode.parentElement;
bNode = bNode.parentElement;
}
if (!aNode?.parentNode || !bNode?.parentNode)
return {
ancestor: null,
a: a,
b: b,
result: 0,
};
const children = aNode.parentNode.childNodes;
const aIndex = domUtils.getArrayIndex(children, aNode);
const bIndex = domUtils.getArrayIndex(children, bNode);
return {
ancestor: aNode.parentElement,
a: aNode,
b: bNode,
result: aIndex > bIndex ? 1 : aIndex < bIndex ? -1 : 0,
};
}
/**
* @template {HTMLElement} T
* @description Get the parent element of the argument value.
* - A tag that satisfies the query condition is imported.
* @example
* // Find by tag name
* const table = dom.query.getParentElement(cell, 'TABLE');
*
* // Find by CSS class
* const wrapper = dom.query.getParentElement(node, '.se-wrapper');
*
* // Find by validation function
* const line = dom.query.getParentElement(node, (el) => el.nodeType === 1);
* @param {Node} element Reference element
* @param {string|((current: *) => boolean)|Node} query Query String (`nodeName`, `.className`, `#ID`, `:name`) or validation function.
* - Not use it like jquery.
* - Only one condition can be entered at a time.
* @param {?number} [depth] Number of parent levels to depth.
* @returns {T|null} Not found: `null`
*/
export function getParentElement(element, query, depth) {
let valid;
if (typeof query === 'function') {
valid = query;
} else if (typeof query === 'object') {
/** @param {Node} current */
valid = (current) => current === query;
} else {
let attr;
if (/^\./.test(query)) {
attr = 'className';
query = '(\\s|^)' + query.split('.')[1] + '(\\s|$)';
} else if (/^#/.test(query)) {
attr = 'id';
query = '^' + query.split('#')[1] + '$';
} else if (/^:/.test(query)) {
attr = 'name';
query = '^' + query.split(':')[1] + '$';
} else {
attr = 'nodeName';
query = '^' + query + '$';
}
const regExp = new RegExp(query, 'i');
/** @param {Node} el */
valid = (el) => regExp.test(el[attr]);
}
depth ||= Infinity;
let index = 0;
while (element && !valid(element)) {
if (index >= depth || domCheck.isWysiwygFrame(element)) {
return null;
}
element = element.parentElement;
index++;
}
return /** @type {T} */ (element);
}
/**
* @template {HTMLElement} T
* @description Gets all ancestors of the argument value.
* - Get all tags that satisfy the query condition.
* @param {Node} element Reference element
* @param {string|((current: *) => boolean)|Node} query Query String (`nodeName`, `.className`, `#ID`, `:name`) or validation function.
* Not use it like jquery.
* Only one condition can be entered at a time.
* @param {?number} [depth] Number of parent levels to depth.
* @returns {Array<T>} Returned in an array in order.
*/
export function getParentElements(element, query, depth) {
let valid;
if (typeof query === 'function') {
valid = query;
} else if (typeof query === 'object') {
/** @param {Node} current */
valid = (current) => current === query;
} else {
let attr;
if (/^\./.test(query)) {
attr = 'className';
query = '(\\s|^)' + query.split('.')[1] + '(\\s|$)';
} else if (/^#/.test(query)) {
attr = 'id';
query = '^' + query.split('#')[1] + '$';
} else if (/^:/.test(query)) {
attr = 'name';
query = '^' + query.split(':')[1] + '$';
} else {
attr = 'nodeName';
query = '^' + query + '$';
}
const regExp = new RegExp(query, 'i');
/** @param {Node} el */
valid = (el) => regExp.test(el[attr]);
}
const elementList = [];
depth ||= Infinity;
let index = 0;
while (index <= depth && element && !domCheck.isWysiwygFrame(element)) {
if (valid(element)) {
elementList.push(element);
}
element = element.parentElement;
index++;
}
return /** @type {Array<T>} */ (elementList);
}
/**
* @template {HTMLElement} T
* @description Gets the element with `data-command` attribute among the parent elements.
* @param {Node} target Target element
* @returns {T|null}
*/
export function getCommandTarget(target) {
let n = /** @type {HTMLElement} */ (target);
while (n && !/^(UL)$/i.test(n.nodeName) && !domUtils.hasClass(n, 'sun-editor')) {
if (n.hasAttribute('data-command')) return /** @type {T} */ (n);
n = n.parentElement;
}
return null;
}
/**
* @template {HTMLElement} T
* @description Get the event.target element.
* @param {Event} event Event object
* @returns {T|null}
*/
export function getEventTarget(event) {
return /** @type {T} */ (event.target);
}
/**
* @template {Node} T
* @description Get the child element of the argument value.
* - A tag that satisfies the query condition is imported.
* @param {Node} node Reference element
* @param {string|((current: *) => boolean)|Node} query Query String (`nodeName`, `.className`, `#ID`, `:name`) or validation function.
* @param {boolean} last If `true` returns the last node among the found child nodes. (default: first node)
* Not use it like jquery.
* Only one condition can be entered at a time.
* @returns {T|null} Not found: `null`
* @example
* const firstLeaf = dom.query.getEdgeChild(container, (n) => n.nodeType === 3, false);
* const lastLeaf = dom.query.getEdgeChild(container, (n) => n.nodeType === 3, true);
*/
export function getEdgeChild(node, query, last) {
let valid;
if (typeof query === 'function') {
valid = query;
} else if (typeof query === 'object') {
valid = function (current) {
return current === query;
};
} else {
let attr;
if (/^\./.test(query)) {
attr = 'className';
query = '(\\s|^)' + query.split('.')[1] + '(\\s|$)';
} else if (/^#/.test(query)) {
attr = 'id';
query = '^' + query.split('#')[1] + '$';
} else if (/^:/.test(query)) {
attr = 'name';
query = '^' + query.split(':')[1] + '$';
} else {
attr = 'nodeName';
query = '^' + (query === 'text' ? '#' + query : query) + '$';
}
const regExp = new RegExp(query, 'i');
valid = function (el) {
return regExp.test(el[attr]);
};
}
const childList = getListChildNodes(node, (current) => valid(current), null);
return /** @type {T} */ (childList[last ? childList.length - 1 : 0]);
}
/**
* @description Get edge child nodes of the argument value.
* - 1. The first node of all the child nodes of the `first` element is returned.
* - 2. The last node of all the child nodes of the `last` element is returned.
* - 3. When there is no `last` element, the first and last nodes of all the children of the `first` element are returned.
* @param {Node} first First element
* @param {?Node} last Last element
* @returns {{sc: Node, ec: Node}} { sc: `first`, ec: `last` }
*/
export function getEdgeChildNodes(first, last) {
if (!first) return;
last ||= first;
while (first && first.nodeType === 1 && first.childNodes.length > 0 && !domCheck.isBreak(first)) first = first.firstChild;
while (last && last.nodeType === 1 && last.childNodes.length > 0 && !domCheck.isBreak(last)) last = last.lastChild;
return {
sc: first,
ec: last || first,
};
}
/**
* @template {Node} T
* @description Gets the previous sibling last child. If there is no sibling, then it'll take it from the closest ancestor with child
* @param {Node} node Reference element
* @param {?Node} [ceiling] Highest boundary allowed
* @returns {T|null} Not found: `null`
*/
export function getPreviousDeepestNode(node, ceiling) {
let previousNode = node.previousSibling;
if (!previousNode) {
for (let parentNode = node.parentNode; parentNode; parentNode = parentNode.parentNode) {
if (parentNode === ceiling) return null;
if (parentNode.previousSibling) {
previousNode = parentNode.previousSibling;
break;
}
}
if (!previousNode) return null;
}
if (domCheck.isNonEditable(previousNode)) return /** @type {T} */ (/** @type {unknown} */ (previousNode));
while (previousNode.lastChild) previousNode = previousNode.lastChild;
return /** @type {T} */ (/** @type {unknown} */ (previousNode));
}
/**
* @template {Node} T
* @description Gets the next sibling first child. If there is no sibling, then it'll take it from the closest ancestor with child
* @param {Node} node Reference element
* @param {?Node} [ceiling] Highest boundary allowed
* @returns {T|null} Not found: `null`
*/
export function getNextDeepestNode(node, ceiling) {
let nextNode = node.nextSibling;
if (!nextNode) {
for (let parentNode = node.parentNode; parentNode; parentNode = parentNode.parentNode) {
if (parentNode === ceiling) return null;
if (parentNode.nextSibling) {
nextNode = parentNode.nextSibling;
break;
}
}
if (!nextNode) return null;
}
if (domCheck.isNonEditable(nextNode)) return /** @type {T} */ (/** @type {unknown} */ (nextNode));
while (nextNode.firstChild) nextNode = nextNode.firstChild;
return /** @type {T} */ (/** @type {unknown} */ (nextNode));
}
/**
* @description Find the index of the text node in the `line` element.
* @param {Node} line `line` element (p, div, etc.)
* @param {Node} offsetContainer Base node to start searching
* @param {number} offset Base offset to start searching
* @param {?(current: *) => boolean} [validate] Validation function
* @returns {number}
*/
export function findTextIndexOnLine(line, offsetContainer, offset, validate) {
if (!line) return 0;
validate ||= () => true;
let index = 0;
let found = false;
(function recursionFunc(node) {
if (found || node.nodeType === 8) return;
if (validate(node)) return; // component.is
if (node.nodeType === 3) {
if (node === offsetContainer) {
index += offset;
found = true;
return;
}
index += node.textContent.length;
} else if (node.nodeType === 1) {
const childNodes = node.childNodes;
for (let i = 0, len = childNodes.length; i < len; i++) {
recursionFunc(childNodes[i]);
if (found) return;
}
}
})(line);
return index;
}
/**
* @description Find the end index of a sequence of at least minTabSize consecutive non-breaking spaces or spaces
* - which are interpreted as a tab key, occurring after a given base index in a text string.
* @param {Node} line `line` element (p, div, etc.)
* @param {number} baseIndex Base index to start searching
* @param {number} minTabSize Minimum number of consecutive spaces to consider as a tab
* @returns {number} The adjusted index within the line element accounting for non-space characters
*/
export function findTabEndIndex(line, baseIndex, minTabSize) {
if (!line) return 0;
const innerText = line.textContent;
const regex = new RegExp(`((\\u00A0|\\s){${minTabSize},})`, 'g');
let match;
regex.lastIndex = baseIndex;
while ((match = regex.exec(innerText)) !== null) {
if (match.index >= baseIndex) {
const spaceEndIndex = match.index + match[0].length - 1;
const precedingText = innerText.slice(0, spaceEndIndex + 1);
const nonSpaceCharCount = (precedingText.match(/[^\u00A0\s]/g) || []).length;
return spaceEndIndex + nonSpaceCharCount + minTabSize;
}
}
return 0;
}
/**
* @description Finds the table cell that appears visually at the bottom-right position,
* considering both `rowSpan` and `colSpan`, even if smaller cells are placed after large merged cells.
*
* @param {HTMLTableCellElement[]} cells
* @returns {HTMLTableCellElement|null}
*/
export function findVisualLastCell(cells) {
if (!cells || cells.length === 0) return null;
/**
* @description visibility col index
* @type {Object<number, boolean[]>}
*/
const occupied = {};
let target = null;
let maxRowEnd = -1;
let maxColEnd = -1;
for (const cell of cells) {
const row = /** @type {HTMLTableRowElement} */ (cell.parentElement);
const rowIndex = row.rowIndex;
const rowSpan = cell.rowSpan || 1;
const colSpan = cell.colSpan || 1;
// 현재 행에서 visual column index 찾기
occupied[rowIndex] ||= [];
let colIndex = 0;
const rowOcc = occupied[rowIndex];
while (rowOcc[colIndex]) colIndex++;
for (let i = 0; i < colSpan; i++) {
rowOcc[colIndex + i] = true;
}
for (let r = 1; r < rowSpan; r++) {
const nextRow = rowIndex + r;
occupied[nextRow] ||= [];
for (let i = 0; i < colSpan; i++) {
occupied[nextRow][colIndex + i] = true;
}
}
const visualRowEnd = rowIndex + rowSpan - 1;
const visualColEnd = colIndex + colSpan - 1;
// right-bottom
if (visualRowEnd > maxRowEnd || (visualRowEnd === maxRowEnd && visualColEnd > maxColEnd)) {
maxRowEnd = visualRowEnd;
maxColEnd = visualColEnd;
target = cell;
}
}
return target;
}
/**
* @description Finds and returns parent containers that are scrollable.
* @param {HTMLElement} element - Element to start with
* @returns {HTMLElement[]} - Array (in descending order)
*/
export function getScrollParents(element) {
const scrollable = [];
let parent = element?.parentElement;
while (parent && !/^(body|html)$/i.test(parent.nodeName)) {
const style = _w.getComputedStyle(parent);
const { overflow, overflowX, overflowY } = style;
const canScroll = [overflow, overflowX, overflowY].some((prop) => ['auto', 'scroll', 'overlay'].includes(prop));
if (canScroll) {
scrollable.push(parent);
}
parent = parent.parentElement;
}
return scrollable;
}
/**
* @description Get the argument iframe's document object if use the `iframe` or `fullPage` options
* @param {HTMLIFrameElement} iframe Iframe element (`this.frameContext.get('wysiwygFrame')`)
* @returns {Document}
*/
export function getIframeDocument(iframe) {
return iframe.contentWindow?.document || iframe.contentDocument;
}
const query = {
getPositionIndex,
getNodePath,
getNodeFromPath,
getChildNode,
getListChildren,
getListChildNodes,
getNodeDepth,
sortNodeByDepth,
compareElements,
getParentElement,
getParentElements,
getCommandTarget,
getEventTarget,
getEdgeChild,
getEdgeChildNodes,
getPreviousDeepestNode,
getNextDeepestNode,
findTextIndexOnLine,
findTabEndIndex,
findVisualLastCell,
getScrollParents,
getIframeDocument,
};
export default query;