suneditor
Version:
Vanilla JavaScript based WYSIWYG web editor
1,098 lines (948 loc) • 38.2 kB
JavaScript
import { dom, unicode, numbers } from '../../../helper';
/**
* @description Classes related to editor formats such as `line` and `block`.
*/
class Format {
#$;
#store;
#options;
#frameContext;
#formatLineCheck;
#formatBrLineCheck;
#formatBlockCheck;
#formatClosureBlockCheck;
#formatClosureBrLineCheck;
#textStyleTagsCheck;
#brLineBreak = null;
/**
* @constructor
* @param {SunEditor.Kernel} kernel
*/
constructor(kernel) {
this.#$ = kernel.$;
this.#store = kernel.store;
this.#options = this.#$.options;
this.#frameContext = this.#$.frameContext;
// members
this.#formatLineCheck = this.#options.get('formatLine').reg;
this.#formatBrLineCheck = this.#options.get('formatBrLine').reg;
this.#formatBlockCheck = this.#options.get('formatBlock').reg;
this.#formatClosureBlockCheck = this.#options.get('formatClosureBlock').reg;
this.#formatClosureBrLineCheck = this.#options.get('formatClosureBrLine').reg;
this.#textStyleTagsCheck = new RegExp('^(' + this.#options.get('textStyleTags') + ')$', 'i');
this.__resetBrLineBreak(this.#options.get('defaultLineBreakFormat'));
}
/**
* @description Replace the line tag of the current selection.
* @param {Node} element Line element (P, DIV..)
* @example
* // Replace the format tag of selected lines with H1
* const h1 = document.createElement('h1');
* editor.format.setLine(h1);
*
* // Replace the format tag of selected lines with DIV
* const div = document.createElement('div');
* editor.format.setLine(div);
*/
setLine(element) {
if (!this.isLine(element)) {
throw new Error('[SUNEDITOR.format.setLine.fail] The "element" must satisfy "format.isLine()".');
}
const info = this.#lineWork();
const lines = info.lines;
const className = element.className;
const value = element.nodeName;
let first = info.firstNode;
let last = info.lastNode;
for (let i = 0, len = lines.length, node, newFormat; i < len; i++) {
node = lines[i];
if ((node.nodeName !== value || (node.className.match(/(\s|^)__se__format__[^\s]+/) || [''])[0].trim() !== className) && !this.#$.component.is(node)) {
newFormat = /** @type {HTMLElement} */ (element.cloneNode(false));
dom.utils.copyFormatAttributes(newFormat, node);
newFormat.innerHTML = node.innerHTML;
node.parentNode.replaceChild(newFormat, node);
}
if (i === 0) first = newFormat || node;
if (i === len - 1) last = newFormat || node;
newFormat = null;
}
this.#$.selection.setRange(dom.query.getNodeFromPath(info.firstPath, first), info.startOffset, dom.query.getNodeFromPath(info.lastPath, last), info.endOffset);
this.#$.history.push(false);
// document type
if (this.#frameContext.has('documentType_use_header')) {
this.#frameContext.get('documentType').reHeader();
}
}
/**
* @description If a parent node that contains an argument node finds a format node (`format.isLine`), it returns that node.
* @param {Node} node Reference node.
* @param {?(current: Node) => boolean} [validation] Additional validation function.
* @returns {HTMLElement|null}
* @example
* const line = editor.$.format.getLine(editor.$.selection.getNode());
* const lineWithFilter = editor.$.format.getLine(node, (el) => el.nodeName !== 'LI');
*/
getLine(node, validation) {
if (!node) return null;
validation ||= () => true;
while (node) {
if (dom.check.isWysiwygFrame(node)) return null;
if (this.isBlock(node)) {
if (this.isLine(node.firstElementChild)) {
return /** @type {HTMLElement} */ (node.firstElementChild);
}
if (this.isLine(node)) {
return /** @type {HTMLElement} */ (node);
}
}
if (this.isLine(node) && validation(node)) {
return /** @type {HTMLElement} */ (node);
}
node = node.parentNode;
}
return null;
}
/**
* @description Replace the br-line tag of the current selection.
* @param {Node} element BR-Line element (PRE..)
* @example
* editor.$.format.setBrLine(document.createElement('pre'));
*/
setBrLine(element) {
if (!this.isBrLine(element)) {
throw new Error('[SUNEDITOR.format.setBrLine.fail] The "element" must satisfy "format.isBrLine()".');
}
const lines = this.#lineWork().lines;
const len = lines.length - 1;
let parentNode = lines[len].parentNode;
let freeElement = /** @type {HTMLElement} */ (element.cloneNode(false));
const focusElement = freeElement;
for (let i = len, f, html, before, next, inner, isComp, first = true; i >= 0; i--) {
f = lines[i];
if (f === (!lines[i + 1] ? null : lines[i + 1].parentNode)) continue;
isComp = this.#$.component.is(f);
html = isComp ? '' : f.innerHTML.replace(/(?!>)\s+(?=<)|\n/g, ' ');
before = dom.query.getParentElement(f, (current) => current.parentNode === parentNode);
if (parentNode !== f.parentNode || isComp) {
if (this.isLine(parentNode)) {
parentNode.parentNode.insertBefore(freeElement, parentNode.nextSibling);
parentNode = parentNode.parentNode;
} else {
parentNode.insertBefore(freeElement, before ? before.nextSibling : null);
parentNode = f.parentNode;
}
next = /** @type {HTMLElement} */ (freeElement.nextSibling);
if (next && freeElement.nodeName === next.nodeName && dom.check.isSameAttributes(freeElement, next)) {
freeElement.innerHTML += '<BR>' + next.innerHTML;
dom.utils.removeItem(next);
}
freeElement = /** @type {HTMLElement} */ (element.cloneNode(false));
first = true;
}
inner = freeElement.innerHTML;
freeElement.innerHTML = (first || !html || !inner || /<br>$/i.test(html) ? html : html + '<BR>') + inner;
if (i === 0) {
parentNode.insertBefore(freeElement, f);
next = /** @type {HTMLElement} */ (f.nextSibling);
if (next && freeElement.nodeName === next.nodeName && dom.check.isSameAttributes(freeElement, next)) {
freeElement.innerHTML += '<BR>' + next.innerHTML;
dom.utils.removeItem(next);
}
const prev = /** @type {HTMLElement} */ (freeElement.previousSibling);
if (prev && freeElement.nodeName === prev.nodeName && dom.check.isSameAttributes(freeElement, prev)) {
prev.innerHTML += '<BR>' + freeElement.innerHTML;
dom.utils.removeItem(freeElement);
}
}
if (!isComp) dom.utils.removeItem(f);
if (html) first = false;
}
this.#$.selection.setRange(focusElement, 0, focusElement, 0);
this.#$.history.push(false);
}
/**
* @description If a parent node that contains an argument node finds a `brLine` (`format.isBrLine`), it returns that node.
* @param {Node} element Reference node.
* @param {?(current: Node) => boolean} [validation] Additional validation function.
* @returns {HTMLBRElement|null}
* @example
* const brLine = editor.$.format.getBrLine(editor.$.selection.getNode());
*/
getBrLine(element, validation) {
if (!element) return null;
validation ||= () => true;
while (element) {
if (dom.check.isWysiwygFrame(element)) return null;
if (this.isBrLine(element) && validation(element)) return /** @type {HTMLBRElement} */ (element);
element = element.parentNode;
}
return null;
}
/**
* @description Append `line` element to sibling node of argument element.
* - If the `lineNode` argument value is present, the tag of that argument value is inserted,
* - If not, the currently selected format tag is inserted.
* @param {Node} element Insert as siblings of that element
* @param {?(string|Node)} [lineNode] Node name or node obejct to be inserted
* @returns {HTMLElement}
*/
addLine(element, lineNode) {
if (!element || !element.parentNode) return null;
const currentFormatEl = this.getLine(this.#$.selection.getNode(), null);
let oFormat = null;
if (!this.isBrLine(element) && this.isBrLine(currentFormatEl || element.parentNode) && !this.#$.component.is(element)) {
oFormat = dom.utils.createElement('BR');
} else {
const oFormatName = lineNode ? (typeof lineNode === 'string' ? lineNode : lineNode.nodeName) : this.isNormalLine(currentFormatEl) ? currentFormatEl.nodeName : this.#options.get('defaultLine');
oFormat = dom.utils.createElement(oFormatName, null, '<br>');
if ((lineNode && typeof lineNode !== 'string') || (!lineNode && this.isLine(currentFormatEl))) {
dom.utils.copyTagAttributes(oFormat, /** @type {Node} */ (lineNode || currentFormatEl), ['id']);
}
}
if (dom.check.isTableCell(element)) element.insertBefore(oFormat, element.nextElementSibling);
else element.parentNode.insertBefore(oFormat, /** @type {HTMLElement} */ (element).nextElementSibling);
return oFormat;
}
/**
* @description If a parent node that contains an argument node finds a format node (`format.isBlock`), it returns that node.
* @param {Node} element Reference node.
* @param {?(current: Node) => boolean} [validation] Additional validation function.
* @returns {HTMLElement|null}
* @example
* const block = editor.$.format.getBlock(editor.$.selection.getNode());
*/
getBlock(element, validation) {
if (!element) return null;
validation ||= () => true;
while (element) {
if (dom.check.isWysiwygFrame(element)) return null;
if (this.isBlock(element) && !/^(THEAD|TBODY|TR)$/i.test(element.nodeName) && validation(element)) return element;
element = element.parentNode;
}
return null;
}
/**
* @description Appended all selected `line` element to the argument element(`block`) and insert
* @param {Node} blockElement Element of wrap the arguments (BLOCKQUOTE...)
* @example
* // Wrap selected lines in a blockquote
* const blockquote = document.createElement('blockquote');
* editor.format.applyBlock(blockquote);
*/
applyBlock(blockElement) {
this.#$.selection.getRangeAndAddLine(this.#$.selection.getRange(), null);
const rangeLines = /** @type {Element[]} */ (this.getLinesAndComponents(false));
if (!rangeLines || rangeLines.length === 0) return;
linesLoop: for (let i = 0, len = rangeLines.length, line, nested, fEl, lEl, f, l; i < len; i++) {
line = rangeLines[i];
if (!dom.check.isListCell(line)) continue;
nested = line.lastElementChild;
if (nested && dom.check.isListCell(line.nextElementSibling) && rangeLines.includes(line.nextElementSibling)) {
lEl = nested.lastElementChild;
if (rangeLines.includes(lEl)) {
let list = null;
while ((list = lEl.lastElementChild)) {
if (dom.check.isList(list)) {
if (rangeLines.includes(list.lastElementChild)) {
lEl = list.lastElementChild;
} else {
continue linesLoop;
}
}
}
fEl = nested.firstElementChild;
f = rangeLines.indexOf(fEl);
l = rangeLines.indexOf(lEl);
rangeLines.splice(f, l - f + 1);
len = rangeLines.length;
continue;
}
}
}
const last = rangeLines.at(-1);
let standTag, beforeTag, pElement;
if (this.isBlock(last) || this.isLine(last)) {
standTag = last;
} else {
standTag = this.getBlock(last, null) || this.getLine(last, null);
}
if (dom.check.isTableCell(standTag)) {
beforeTag = null;
pElement = standTag;
} else {
beforeTag = standTag.nextSibling;
pElement = standTag.parentNode;
}
const block = /** @type {HTMLElement} */ (blockElement.cloneNode(false));
let parentDepth = dom.query.getNodeDepth(standTag);
let listParent = null;
const lineArr = [];
const removeItems = (parent, origin, before) => {
let cc = null;
if (parent !== origin && !dom.check.isTableElements(origin)) {
if (origin && dom.query.getNodeDepth(parent) === dom.query.getNodeDepth(origin)) return before;
cc = this.#$.nodeTransform.removeAllParents(origin, null, parent);
}
return cc ? cc.ec : before;
};
for (let i = 0, len = rangeLines.length, line, originParent, depth, before, nextLine, nextList, nested; i < len; i++) {
line = rangeLines[i];
originParent = line.parentNode;
if (!originParent || block.contains(originParent)) continue;
depth = dom.query.getNodeDepth(line);
if (dom.check.isList(originParent)) {
if (listParent === null) {
if (nextList) {
listParent = nextList;
nested = true;
nextList = null;
} else {
listParent = originParent.cloneNode(false);
}
}
lineArr.push(line);
nextLine = rangeLines[i + 1];
if (i === len - 1 || nextLine?.parentNode !== originParent) {
// nested list
if (line.contains(nextLine?.parentNode)) {
nextList = nextLine.parentNode.cloneNode(false);
}
let list = originParent.parentNode,
p;
while (dom.check.isList(list)) {
p = dom.utils.createElement(list.nodeName);
p.appendChild(listParent);
listParent = p;
list = list.parentNode;
}
const edge = this.removeBlock(originParent, { selectedFormats: lineArr, newBlockElement: null, shouldDelete: true, skipHistory: true });
if (parentDepth >= depth) {
parentDepth = depth;
pElement = edge.cc;
beforeTag = removeItems(pElement, originParent, edge.ec);
if (beforeTag) pElement = beforeTag.parentNode;
} else if (pElement === edge.cc) {
beforeTag = edge.ec;
}
if (pElement !== edge.cc) {
before = removeItems(pElement, edge.cc, before);
if (before !== undefined) beforeTag = before;
else beforeTag = edge.cc;
}
for (let c = 0, cLen = edge.removeArray.length; c < cLen; c++) {
listParent.appendChild(edge.removeArray[c]);
}
if (!nested) block.appendChild(listParent);
if (nextList) edge.removeArray.at(-1).appendChild(nextList);
listParent = null;
nested = false;
}
} else {
if (parentDepth >= depth) {
parentDepth = depth;
pElement = originParent;
beforeTag = line.nextSibling;
}
block.appendChild(line);
if (pElement !== originParent) {
before = removeItems(pElement, originParent);
if (before !== undefined) beforeTag = before;
}
}
}
this.#store.set('_lastSelectionNode', null);
this.#$.nodeTransform.mergeSameTags(block, null, false);
this.#$.nodeTransform.mergeNestedTags(block, (current) => dom.check.isList(current));
// Nested list
if (beforeTag && dom.query.getNodeDepth(beforeTag) > 0 && (dom.check.isList(beforeTag.parentNode) || dom.check.isList(beforeTag.parentNode.parentNode))) {
const depthFormat = dom.query.getParentElement(beforeTag, (current) => this.isBlock(current) && !dom.check.isList(current));
const splitRange = this.#$.nodeTransform.split(beforeTag, null, !depthFormat ? 0 : dom.query.getNodeDepth(depthFormat) + 1);
splitRange.parentNode.insertBefore(block, splitRange);
} else {
// basic
pElement.insertBefore(block, beforeTag);
removeItems(block, beforeTag);
}
const edge = dom.query.getEdgeChildNodes(block.firstElementChild, block.lastElementChild);
if (rangeLines.length > 1) {
this.#$.selection.setRange(edge.sc, 0, edge.ec, edge.ec.textContent.length);
} else {
this.#$.selection.setRange(edge.ec, edge.ec.textContent.length, edge.ec, edge.ec.textContent.length);
}
this.#$.history.push(false);
}
/**
* @description The elements of the `selectedFormats` array are detached from the `blockElement` element. (`LI` tags are converted to `P` tags)
* - When `selectedFormats` is `null`, all elements are detached and return {cc: parentNode, sc: nextSibling, ec: previousSibling, removeArray: [Array of removed elements]}.
* @param {Node} blockElement `block` element (PRE, BLOCKQUOTE, OL, UL...)
* @param {Object} [options] Options
* @param {Array<Node>} [options.selectedFormats=null] Array of `line` elements (P, DIV, LI...) to remove.
* - If `null`, Applies to all elements and return {cc: parentNode, sc: nextSibling, ec: previousSibling}
* @param {Node} [options.newBlockElement=null] The node(`blockElement`) to replace the currently wrapped node.
* @param {boolean} [options.shouldDelete=false] If `true`, deleted without detached.
* @param {boolean} [options.skipHistory=false] When `true`, it does not update the history stack and the selection object and return `EdgeNodes` (dom-query-GetEdgeChildNodes)
* @returns {{cc: Node, sc: Node, so: number, ec: Node, eo: number, removeArray: ?Array<Node>}} Node information after deletion
* - cc: Common parent container node
* - sc: Start container node
* - so: Start offset
* - ec: End container node
* - eo: End offset
* - removeArray: Array of removed elements
* @example
* // Remove all list items from a list
* const listElement = editor.selection.getNode().closest('ul');
* editor.format.removeBlock(listElement);
*
* // Remove specific list items only
* const selectedItems = [liElement1, liElement2];
* editor.format.removeBlock(listElement, { selectedFormats: selectedItems });
*
* // Replace blockquote with div
* const blockquote = editor.selection.getNode().closest('blockquote');
* const newDiv = document.createElement('div');
* editor.format.removeBlock(blockquote, { newBlockElement: newDiv });
*/
removeBlock(blockElement, { selectedFormats, newBlockElement, shouldDelete, skipHistory } = {}) {
const range = this.#$.selection.getRange();
let so = range.startOffset;
let eo = range.endOffset;
let children = dom.query.getListChildNodes(blockElement, null, 1);
let parent = blockElement.parentNode;
let firstNode = null;
let lastNode = null;
let rangeEl = /** @type {HTMLElement} */ (blockElement.cloneNode(false));
const removeArray = [];
const newList = dom.check.isList(newBlockElement);
let insertedNew = false;
let reset = false;
let moveComplete = false;
const appendNode = (parentEl, insNode, sibling, originNode) => {
if (insNode.childNodes.length === 1 && dom.check.isZeroWidth(insNode)) {
insNode.innerHTML = unicode.zeroWidthSpace;
so = eo = 1;
}
if (insNode.nodeType === 3) {
parentEl.insertBefore(insNode, sibling);
return insNode;
}
const insChildren = (moveComplete ? insNode : originNode).childNodes;
let format = insNode.cloneNode(false);
let first = null;
let c = null;
while (insChildren[0]) {
c = insChildren[0];
if (this._isNotTextNode(c) && !dom.check.isBreak(c) && !dom.check.isListCell(format)) {
if (format.childNodes.length > 0) {
first ||= format;
parentEl.insertBefore(format, sibling);
format = insNode.cloneNode(false);
}
parentEl.insertBefore(c, sibling);
first ||= c;
} else {
format.appendChild(c);
}
}
if (format.childNodes.length > 0) {
if (dom.check.isListCell(parentEl) && dom.check.isListCell(format) && dom.check.isList(sibling)) {
if (newList) {
first = sibling;
while (sibling) {
format.appendChild(sibling);
sibling = sibling.nextSibling;
}
parentEl.parentNode.insertBefore(format, parentEl.nextElementSibling);
} else {
const originNext = originNode.nextElementSibling;
const detachRange = this.#$.listFormat.removeNested(originNode, false);
if (blockElement !== detachRange || originNext !== originNode.nextElementSibling) {
const fChildren = format.childNodes;
while (fChildren[0]) {
originNode.appendChild(fChildren[0]);
}
blockElement = detachRange;
reset = true;
}
}
} else {
parentEl.insertBefore(format, sibling);
}
first ||= format;
}
return first;
};
// detach loop
for (let i = 0, len = children.length, insNode, lineIndex, next; i < len; i++) {
insNode = children[i];
if (insNode.nodeType === 3 && dom.check.isList(rangeEl)) continue;
moveComplete = false;
if (shouldDelete && i === 0) {
if (!selectedFormats || selectedFormats.length === len || selectedFormats[0] === insNode) {
firstNode = blockElement.previousSibling;
} else {
firstNode = rangeEl;
}
}
if (selectedFormats) lineIndex = selectedFormats.indexOf(insNode);
if (selectedFormats && lineIndex === -1) {
rangeEl ||= /** @type {HTMLElement} */ (blockElement.cloneNode(false));
rangeEl.appendChild(insNode);
} else {
if (selectedFormats) next = selectedFormats[lineIndex + 1];
if (rangeEl && rangeEl.children.length > 0) {
parent.insertBefore(rangeEl, blockElement);
rangeEl = null;
}
if (!newList && dom.check.isListCell(insNode)) {
if (next && dom.query.getNodeDepth(insNode) !== dom.query.getNodeDepth(next) && (dom.check.isListCell(parent) || dom.utils.arrayFind(insNode.children, dom.check.isList))) {
const insNext = insNode.nextElementSibling;
const detachRange = this.#$.listFormat.removeNested(insNode, false);
if (blockElement !== detachRange || insNext !== insNode.nextElementSibling) {
blockElement = detachRange;
reset = true;
}
} else {
const inner = insNode;
insNode = dom.utils.createElement(
shouldDelete
? inner.nodeName
: dom.check.isList(blockElement.parentNode) || dom.check.isListCell(blockElement.parentNode)
? 'LI'
: dom.check.isTableCell(blockElement.parentNode)
? 'DIV'
: this.#options.get('defaultLine'),
);
const isCell = dom.check.isListCell(insNode);
const innerChildren = inner.childNodes;
while (innerChildren[0]) {
if (dom.check.isList(innerChildren[0]) && !isCell) break;
insNode.appendChild(innerChildren[0]);
}
dom.utils.copyFormatAttributes(insNode, inner);
moveComplete = true;
}
} else {
insNode = insNode.cloneNode(false);
}
if (!reset) {
if (!shouldDelete) {
if (newBlockElement) {
if (!insertedNew) {
parent.insertBefore(newBlockElement, blockElement);
insertedNew = true;
}
insNode = appendNode(newBlockElement, insNode, null, children[i]);
} else {
insNode = appendNode(parent, insNode, blockElement, children[i]);
}
if (!reset) {
if (selectedFormats) {
lastNode = insNode;
firstNode ||= insNode;
} else if (!firstNode) {
firstNode = lastNode = insNode;
}
}
} else {
removeArray.push(insNode);
dom.utils.removeItem(children[i]);
}
if (reset) {
reset = moveComplete = false;
children = dom.query.getListChildNodes(blockElement, null, 1);
rangeEl = /** @type {HTMLElement} */ (blockElement.cloneNode(false));
parent = blockElement.parentNode;
i = -1;
len = children.length;
continue;
}
}
}
}
const rangeParent = blockElement.parentNode;
let rangeRight = blockElement.nextSibling;
if (rangeEl?.children.length > 0) {
rangeParent.insertBefore(rangeEl, rangeRight);
}
if (newBlockElement) firstNode = newBlockElement.previousSibling;
else firstNode ||= blockElement.previousSibling;
rangeRight = blockElement.nextSibling !== rangeEl ? blockElement.nextSibling : rangeEl ? rangeEl.nextSibling : null;
if (/** @type {HTMLElement} */ (blockElement).children.length === 0 || blockElement.textContent.length === 0) {
dom.utils.removeItem(blockElement);
} else {
this.#$.nodeTransform.removeEmptyNode(blockElement, null, false);
}
let edge = null;
this.#store.set('_lastSelectionNode', null);
if (shouldDelete) {
edge = {
cc: rangeParent,
sc: firstNode,
so: so,
ec: rangeRight,
eo: eo,
removeArray: removeArray,
};
} else {
firstNode ||= lastNode;
lastNode ||= firstNode;
const childEdge = dom.query.getEdgeChildNodes(firstNode, lastNode?.parentNode ? firstNode : lastNode);
if (!childEdge) {
this.#$.focusManager.focus();
} else {
edge = {
cc: (childEdge.sc || childEdge.ec).parentNode,
sc: childEdge.sc,
so: so,
ec: childEdge.ec,
eo: eo,
removeArray: null,
};
}
}
if (skipHistory) return edge;
if (!shouldDelete && edge) {
if (!selectedFormats) {
this.#$.selection.setRange(edge.sc, 0, edge.sc, 0);
} else {
this.#$.selection.setRange(edge.sc, so, edge.ec, eo);
}
}
this.#$.history.push(false);
}
/**
* @description Indent more the selected lines.
* - margin size : `store.get('indentSize')`
*/
indent() {
const range = this.#$.selection.getRange();
const sc = range.startContainer;
const ec = range.endContainer;
const so = range.startOffset;
const eo = range.endOffset;
const lines = this.getLines(null);
const cells = SetLineMargin(lines, this.#store.get('indentSize'), this.#options.get('_rtl') ? 'marginRight' : 'marginLeft');
// list cells
if (cells.length > 0) {
this.#$.listFormat.applyNested(cells, false);
}
this.#store.set('_lastSelectionNode', null);
this.#$.selection.setRange(sc, so, ec, eo);
this.#$.history.push(false);
}
/**
* @description Indent less the selected lines.
* - margin size - `store.get('indentSize')`
*/
outdent() {
const range = this.#$.selection.getRange();
const sc = range.startContainer;
const ec = range.endContainer;
const so = range.startOffset;
const eo = range.endOffset;
const lines = this.getLines(null);
const cells = SetLineMargin(lines, this.#store.get('indentSize') * -1, this.#options.get('_rtl') ? 'marginRight' : 'marginLeft');
// list cells
if (cells.length > 0) {
this.#$.listFormat.applyNested(cells, true);
}
this.#store.set('_lastSelectionNode', null);
this.#$.selection.setRange(sc, so, ec, eo);
this.#$.history.push(false);
}
/**
* @description Check if the container and offset values are the edges of the `line`
* @param {Node} node The node of the selection object. (range.startContainer..)
* @param {number} offset The offset of the selection object. (selection.getRange().startOffset...)
* @param {"front"|"end"} dir Select check point - `front`: Front edge, `end`: End edge, `undefined`: Both edge.
* @returns {node is HTMLElement}
*/
isEdgeLine(node, offset, dir) {
if (!dom.check.isEdgePoint(node, offset, dir)) return false;
let result = false;
const siblingType = dir === 'front' ? 'previousSibling' : 'nextSibling';
while (node && !this.isLine(node) && !dom.check.isWysiwygFrame(node)) {
if (!node[siblingType] || (dom.check.isBreak(node[siblingType]) && !node[siblingType][siblingType])) {
result = true;
node = node.parentNode;
} else {
return false;
}
}
return result;
}
/**
* @description It is judged whether it is a node related to the text style.
* @param {Node|string} element The node to check
* @returns {element is HTMLElement}
* @example
* editor.$.format.isTextStyleNode('STRONG'); // true
* editor.$.format.isTextStyleNode('P'); // false
*/
isTextStyleNode(element) {
return typeof element === 'string' ? this.#textStyleTagsCheck.test(element) : element?.nodeType === 1 && this.#textStyleTagsCheck.test(element.nodeName);
}
/**
* @description It is judged whether it is the `line` element.
* - (P, DIV, H[1-6], PRE, LI | class=`__se__format__line_xxx`)
* - `line` element also contain `brLine` element
* @param {Node|string} element The node to check
* @returns {element is HTMLElement}
* @example
* editor.$.format.isLine(document.createElement('p')); // true
* editor.$.format.isLine('SPAN'); // false
*/
isLine(element) {
return typeof element === 'string'
? this.#formatLineCheck.test(element)
: element?.nodeType === 1 && (this.#formatLineCheck.test(element.nodeName) || dom.utils.hasClass(element, '__se__format__line_.+|__se__format__br_line_.+')) && !this.#nonFormat(element);
}
/**
* @description It is judged whether it is the only `line` element.
* @param {Node|string} element The node to check
* @returns {element is HTMLElement}
*/
isNormalLine(element) {
return this.isLine(element) && (this.#brLineBreak || !this.isBrLine(element)) && !this.isBlock(element);
}
/**
* @description It is judged whether it is the `brLine` element.
* - (PRE | class=`__se__format__br_line_xxx`)
* - `brLine` elements is included in the `line` element.
* - `brLine` elements's line break is `BR` tag.
* ※ Entering the Enter key in the space on the last line ends `brLine` and appends `line`.
* @param {Node|string} element The node to check
* @returns {element is HTMLElement}
* @example
* editor.$.format.isBrLine(document.createElement('pre')); // true
*/
isBrLine(element) {
return (
(this.#brLineBreak && this.isLine(element)) ||
(typeof element === 'string'
? this.#formatBrLineCheck.test(element)
: element?.nodeType === 1 && (this.#formatBrLineCheck.test(element.nodeName) || dom.utils.hasClass(element, '__se__format__br_line_.+')) && !this.#nonFormat(element))
);
}
/**
* @description It is judged whether it is the `block` element.
* - (BLOCKQUOTE, OL, UL, FIGCAPTION, TABLE, THEAD, TBODY, TR, TH, TD | class=`__se__format__block_xxx`)
* - `block` is wrap the `line` and `component`
* @param {Node|string} element The node to check
* @returns {element is HTMLElement}
* @example
* editor.$.format.isBlock(document.createElement('blockquote')); // true
* editor.$.format.isBlock(document.createElement('ul')); // true
*/
isBlock(element) {
return typeof element === 'string'
? this.#formatBlockCheck.test(element)
: element?.nodeType === 1 && (this.#formatBlockCheck.test(element.nodeName) || dom.utils.hasClass(element, '__se__format__block_.+')) && !this.#nonFormat(element);
}
/**
* @description It is judged whether it is the `closureBlock` element.
* - (TH, TD | class=`__se__format__block_closure_xxx`)
* - `closureBlock` elements is included in the `block`.
* - `closureBlock` element is wrap the `line` and `component`
* - ※ You cannot exit this format with the Enter key or Backspace key.
* - ※ Use it only in special cases. ([ex] format of table cells)
* @param {Node|string} element The node to check
* @returns {element is HTMLElement}
*/
isClosureBlock(element) {
return typeof element === 'string'
? this.#formatClosureBlockCheck.test(element)
: element?.nodeType === 1 && (this.#formatClosureBlockCheck.test(element.nodeName) || dom.utils.hasClass(element, '__se__format__block_closure_.+')) && !this.#nonFormat(element);
}
/**
* @description It is judged whether it is the `closureBrLine` element.
* - (class=`__se__format__br_line__closure_xxx`)
* - `closureBrLine` elements is included in the `brLine`.
* - `closureBrLine` elements's line break is `BR` tag.
* - ※ You cannot exit this format with the Enter key or Backspace key.
* - ※ Use it only in special cases. ([ex] format of table cells)
* @param {Node|string} element The node to check
* @returns {element is HTMLElement}
*/
isClosureBrLine(element) {
return typeof element === 'string'
? this.#formatClosureBrLineCheck.test(element)
: element?.nodeType === 1 && (this.#formatClosureBrLineCheck.test(element.nodeName) || dom.utils.hasClass(element, '__se__format__br_line__closure_.+')) && !this.#nonFormat(element);
}
/**
* @description Returns a `line` array from selected range.
* @param {?(current: Node) => boolean} [validation] The validation function. (Replaces the default validation `format.isLine(current)`)
* @returns {Array<HTMLElement>}
* @example
* const selectedLines = editor.$.format.getLines();
*/
getLines(validation) {
if (!this.#$.selection.resetRangeToTextNode()) return [];
let range = this.#$.selection.getRange();
if (dom.check.isWysiwygFrame(range.startContainer)) {
const children = this.#frameContext.get('wysiwyg').children;
const childrenLen = children.length;
if (childrenLen === 0) return [];
this.#$.selection.setRange(children[0], 0, children[childrenLen - 1], children[childrenLen - 1].textContent.trim().length);
range = this.#$.selection.getRange();
}
const startCon = range.startContainer;
const endCon = range.endContainer;
const commonCon = range.commonAncestorContainer;
// get line nodes
validation ||= this.isLine.bind(this);
const lineNodes = dom.query.getListChildren(commonCon, (current) => validation(current), null);
if (commonCon.nodeType === 3 || (!dom.check.isWysiwygFrame(commonCon) && !this.isBlock(commonCon))) lineNodes.unshift(this.getLine(commonCon, null));
if (startCon === endCon || lineNodes.length === 1) return lineNodes;
const startLine = this.getLine(startCon, null);
const endLine = this.getLine(endCon, null);
let startIdx = null;
let endIdx = null;
const onlyTable = function (current) {
return dom.check.isTableElements(current) ? /^TABLE$/i.test(current.nodeName) : true;
};
let startRangeEl = this.getBlock(startLine, onlyTable);
let endRangeEl = this.getBlock(endLine, onlyTable);
if (dom.check.isTableElements(startRangeEl) && dom.check.isListCell(startRangeEl.parentNode)) startRangeEl = startRangeEl.parentNode;
if (dom.check.isTableElements(endRangeEl) && dom.check.isListCell(endRangeEl.parentNode)) endRangeEl = endRangeEl.parentNode;
const sameRange = startRangeEl === endRangeEl;
for (let i = 0, len = lineNodes.length, line; i < len; i++) {
line = lineNodes[i];
if (startLine === line || (!sameRange && line === startRangeEl)) {
startIdx = i;
continue;
}
if (endLine === line || (!sameRange && line === endRangeEl)) {
endIdx = i;
break;
}
}
if (startIdx === null) startIdx = 0;
if (endIdx === null) endIdx = lineNodes.length - 1;
return lineNodes.slice(startIdx, endIdx + 1);
}
/**
* @description Get lines and components from the selected range. (P, DIV, H[1-6], OL, UL, TABLE..)
* - If some of the component are included in the selection, get the entire that component.
* @param {boolean} removeDuplicate If `true`, if there is a parent and child tag among the selected elements, the child tag is excluded.
* @returns {Array<HTMLElement>}
*/
getLinesAndComponents(removeDuplicate) {
const commonCon = this.#$.selection.getRange().commonAncestorContainer;
const myComponent = dom.query.getParentElement(commonCon, this.#$.component.is.bind(this.#$.component));
const selectedLines = dom.check.isTableElements(commonCon)
? this.getLines(null)
: this.getLines((current) => {
const component = dom.query.getParentElement(current, this.#$.component.is.bind(this.#$.component));
return (this.isLine(current) && (!component || component === myComponent)) || (dom.check.isComponentContainer(current) && !this.getLine(current));
});
if (removeDuplicate) {
for (let i = 0, len = selectedLines.length; i < len; i++) {
for (let j = i - 1; j >= 0; j--) {
if (selectedLines[j].contains(selectedLines[i])) {
selectedLines.splice(i, 1);
i--;
len--;
break;
}
}
}
}
return selectedLines;
}
/**
* @internal
* @description Nodes without text
* @param {Node|string} element Element to check
* @returns {boolean}
*/
_isNotTextNode(element) {
if (!element) return false;
const checkRegExp = /^(br|input|select|canvas|img|iframe|audio|video)$/i;
if (typeof element === 'string') return checkRegExp.test(element);
return element.nodeType === 1 && (this.#$.component.is(element) || checkRegExp.test(element.nodeName));
}
/**
* @internal
* @description A function that distinguishes areas where `selection` should not be placed
* @param {Node} element Element
* @returns {boolean}
*/
_isExcludeSelectionElement(element) {
return !/FIGCAPTION/i.test(element.nodeName) && (this.#$.component.is(element) || /FIGURE/i.test(element.nodeName));
}
/**
* @description A function that distinguishes non-formatting HTML elements or tags from formatting ones.
* @param {Node} element Element
* @returns {boolean}
*/
#nonFormat(element) {
return dom.check.isExcludeFormat(element) || this.#$.component.is(element) || dom.check.isWysiwygFrame(element);
}
/**
* @description Get current selected lines and selected node info.
* @returns {{lines: Array<HTMLElement>, firstNode: Node, lastNode: Node, firstPath: Array<number>, lastPath: Array<number>, startOffset: number, endOffset: number}}
*/
#lineWork() {
let range = this.#$.selection.getRange();
let selectedFormsts = this.getLinesAndComponents(false);
if (selectedFormsts.length === 0) {
range = this.#$.selection.getRangeAndAddLine(range, null);
selectedFormsts = this.getLinesAndComponents(false);
if (selectedFormsts.length === 0) return;
}
const startOffset = range.startOffset;
const endOffset = range.endOffset;
let first = /** @type {Node} */ (selectedFormsts[0]);
let last = /** @type {Node} */ (selectedFormsts.at(-1));
const firstPath = dom.query.getNodePath(range.startContainer, first, null);
const lastPath = dom.query.getNodePath(range.endContainer, last, null);
// remove selected list
const rlist = this.#$.listFormat.remove(selectedFormsts, false);
if (rlist.sc) first = rlist.sc;
if (rlist.ec) last = rlist.ec;
// change format tag
this.#$.selection.setRange(dom.query.getNodeFromPath(firstPath, first), startOffset, dom.query.getNodeFromPath(lastPath, last), endOffset);
return {
lines: this.getLinesAndComponents(false),
firstNode: first,
lastNode: last,
firstPath: firstPath,
lastPath: lastPath,
startOffset: startOffset,
endOffset: endOffset,
};
}
/**
* @internal
* @description Reset the line break format.
* @param {"line"|"br"} breakFormat `options.get('defaultLineBreakFormat')`
* @returns {boolean}
*/
__resetBrLineBreak(breakFormat) {
return (this.#brLineBreak = breakFormat === 'br');
}
}
/**
* @param {Array<HTMLElement>} lines - Line elements
* @param {number} size - Margin size
* @param {string} dir - Direction
* @returns
*/
function SetLineMargin(lines, size, dir) {
const cells = [];
for (let i = 0, len = lines.length, f, margin; i < len; i++) {
f = lines[i];
if (!dom.check.isListCell(f)) {
margin = /\d+/.test(f.style[dir]) ? numbers.get(f.style[dir], 0) : 0;
margin += size;
dom.utils.setStyle(f, dir, margin <= 0 ? '' : margin + 'px');
} else {
if (size < 0 || f.previousElementSibling) {
cells.push(f);
}
}
}
return cells;
}
export default Format;