jodit
Version:
Jodit is awesome and usefully wysiwyg editor with filebrowser
475 lines (394 loc) • 10.1 kB
text/typescript
/*!
* Jodit Editor (https://xdsoft.net/jodit/)
* Released under MIT see LICENSE.txt in the project root for license information.
* Copyright (c) 2013-2020 Valeriy Chupurnov. All rights reserved. https://xdsoft.net
*/
import autobind from 'autobind-decorator';
import { CanUndef, IJodit, markerInfo, Nullable } from '../../../types';
import { isPlainObject, isVoid } from '../../helpers/checker';
import { Dom } from '../../dom';
import {
attr,
css,
each,
normalizeCssValue,
normalizeNode,
trim
} from '../../helpers';
import { IStyle, Style } from './style';
enum mode {
UNWRAP = 'UNWRAP',
WRAP = 'WRAP'
}
/**
* @see [[Select.prototype.applyStyle]]
*/
export class ApplyStyle {
constructor(readonly jodit: IJodit, readonly style: Style) {}
/**
* Apply options to selection
*/
apply(): void {
const sel = this.jodit.selection;
let selInfo: markerInfo[] = [];
const isCollapsed = sel.isCollapsed();
if (isCollapsed) {
const font = this.jodit.createInside.element('font');
sel.insertNode(font, false, false);
sel.setCursorIn(font);
selInfo = sel.save();
this.applyToElement(font);
Dom.unwrap(font);
} else {
selInfo = sel.save();
normalizeNode(sel.area.firstChild); // FF fix for test "commandsTest - Exec command "bold"
// for some text that contains a few STRONG elements, should unwrap all of these"
sel.wrapInTag(this.applyToElement);
}
sel.restore(selInfo);
}
/**
* Mode WRAP or UNWRAP
*/
private mode: CanUndef<keyof typeof mode>;
/**
* Apply options to all selected fragment
* @param font
*/
private applyToElement(font: HTMLElement): void {
const { area } = this.jodit.selection;
if (
this.checkSuitableParent(font) ||
this.checkSuitableChild(font) ||
this.checkClosestWrapper(font) ||
this.unwrapChildren(font)
) {
return;
}
if (!this.mode) {
this.mode = mode.WRAP;
}
if (this.mode !== mode.WRAP) {
return;
}
let wrapper = font;
if (this.style.elementIsBlock) {
const ulReg = /^(ul|ol|li|td|th|tr|tbody|table)$/i;
const box = Dom.up(
font,
node => {
if (node && Dom.isBlock(node, this.jodit.s.win)) {
if (
ulReg.test(this.style.element) ||
!ulReg.test(node.nodeName)
) {
return true;
}
}
return false;
},
area
);
if (box) {
wrapper = box;
} else {
wrapper = this.wrapUnwrappedText(font);
}
}
const newWrapper = Dom.replace(
wrapper,
this.style.element,
this.jodit.createInside
);
if (this.style.elementIsBlock) {
this.postProcessListElement(newWrapper);
}
if (this.style.options.style && this.style.elementIsDefault) {
css(newWrapper, this.style.options.style);
}
}
private checkSuitableParent(font: HTMLElement): boolean {
const { parentNode } = font;
if (
parentNode &&
!Dom.next(font, this.isNormalNode, parentNode) &&
!Dom.prev(font, this.isNormalNode, parentNode) &&
this.isSuitableElement(parentNode, false) &&
parentNode !== this.jodit.s.area &&
(!Dom.isBlock(parentNode, this.jodit.ew) ||
this.style.elementIsBlock)
) {
this.toggleStyles(parentNode);
return true;
}
return false;
}
/**
* Check suitable first child
*
* @param font
* @example
* `<font><strong>selected</strong></font>`
*/
private checkSuitableChild(font: HTMLElement): boolean {
let { firstChild } = font;
if (firstChild && this.jodit.s.isMarker(firstChild as HTMLElement)) {
firstChild = firstChild.nextSibling;
}
if (
firstChild &&
!Dom.next(firstChild, this.isNormalNode, font) &&
!Dom.prev(firstChild, this.isNormalNode, font) &&
this.isSuitableElement(firstChild, false)
) {
this.toggleStyles(firstChild);
return true;
}
return false;
}
/**
* Check closest suitable wrapper element
*
* @param font
* @example
* `<strong><span>zxc<font>selected</font>dfdsf</span></strong>`
*/
private checkClosestWrapper(font: HTMLElement): boolean {
const wrapper = Dom.closest(
font,
this.isSuitableElement,
this.jodit.editor
);
if (wrapper) {
if (this.style.elementIsBlock) {
this.toggleStyles(wrapper);
return true;
}
const leftRange = this.jodit.s.createRange();
leftRange.setStartBefore(wrapper);
leftRange.setEndBefore(font);
const leftFragment = leftRange.extractContents();
if (
(!leftFragment.textContent ||
!trim(leftFragment.textContent).length) &&
leftFragment.firstChild
) {
Dom.unwrap(leftFragment.firstChild);
}
if (wrapper.parentNode) {
wrapper.parentNode.insertBefore(leftFragment, wrapper);
}
leftRange.setStartAfter(font);
leftRange.setEndAfter(wrapper);
const rightFragment = leftRange.extractContents();
// case then marker can be inside fragnment
if (
(!rightFragment.textContent ||
!trim(rightFragment.textContent).length) &&
rightFragment.firstChild
) {
Dom.unwrap(rightFragment.firstChild);
}
Dom.after(wrapper, rightFragment);
this.toggleStyles(wrapper);
return true;
}
return false;
}
/**
* Element has all rules
* @param elm
* @param rules
*/
private elementHasSameStyle(elm: Node, rules: CanUndef<IStyle>): boolean {
return Boolean(
isPlainObject(rules) &&
!Dom.isTag(elm, 'font') &&
Dom.isHTMLElement(elm, this.jodit.ew) &&
each(rules, (property, checkValue) => {
const value = css(elm, property, undefined, true);
return (
!isVoid(value) &&
value !== '' &&
!isVoid(checkValue) &&
normalizeCssValue(property, checkValue)
.toString()
.toLowerCase() === value.toString().toLowerCase()
);
})
);
}
/**
* This element is suitable for options
*
* @param elm
* @param strict
*/
isSuitableElement(
elm: Nullable<Node>,
strict: boolean = true
): elm is HTMLElement {
if (!elm) {
return false;
}
const { element, elementIsDefault, options } = this.style;
const elmHasSameStyle = this.elementHasSameStyle(elm, options.style);
const elmIsSame = elm.nodeName.toLowerCase() === element;
return (
((!elementIsDefault || !strict) && elmIsSame) ||
(elmHasSameStyle && this.isNormalNode(elm))
);
}
/**
* Is normal usual element
* @param elm
*/
private isNormalNode(elm: Nullable<Node>): boolean {
return Boolean(
elm !== null &&
!Dom.isEmptyTextNode(elm) &&
!this.jodit.s.isMarker(elm as HTMLElement)
);
}
/**
* Add or remove styles to element
* @param elm
*/
private toggleStyles(elm: HTMLElement): void {
const { style } = this.style.options;
// toggle CSS rules
if (style && elm.nodeName.toLowerCase() === this.style.defaultTag) {
Object.keys(style).forEach(rule => {
if (
this.mode === mode.UNWRAP ||
css(elm, rule) ===
normalizeCssValue(rule, style[rule] as string)
) {
css(elm, rule, '');
if (this.mode === undefined) {
this.mode = mode.UNWRAP;
}
} else {
css(elm, rule, style[rule]);
if (this.mode === undefined) {
this.mode = mode.WRAP;
}
}
});
}
const isBlock = Dom.isBlock(elm, this.jodit.ew);
const isSuitableInline =
!isBlock &&
(!attr(elm, 'style') ||
elm.nodeName.toLowerCase() !== this.style.defaultTag);
const isSuitableBlock =
!isSuitableInline &&
isBlock &&
elm.nodeName.toLowerCase() === this.style.element;
if (isSuitableInline || isSuitableBlock) {
// toggle `<strong>test</strong>` toWYSIWYG `test`, and
// `<span style="">test</span>` toWYSIWYG `test`
Dom.unwrap(elm);
if (this.mode === undefined) {
this.mode = mode.UNWRAP;
}
}
}
/**
* Unwrap all suit elements inside
* @param font
*/
private unwrapChildren(font: HTMLElement): boolean {
const needUnwrap: Node[] = [];
let firstElementSuit: boolean | undefined;
if (font.firstChild) {
Dom.find(
font.firstChild,
(elm: Node | null) => {
if (elm && this.isSuitableElement(elm as HTMLElement)) {
if (firstElementSuit === undefined) {
firstElementSuit = true;
}
needUnwrap.push(elm);
} else {
if (firstElementSuit === undefined) {
firstElementSuit = false;
}
}
return false;
},
font,
true
);
}
needUnwrap.forEach(Dom.unwrap);
return Boolean(firstElementSuit);
}
/**
* Wrap text or inline elements inside Block element
* @param elm
*/
private wrapUnwrappedText(elm: Node): HTMLElement {
const { area, win } = this.jodit.selection;
const edge = (n: Node, key: keyof Node = 'previousSibling') => {
let edgeNode: Node = n,
node: Nullable<Node> = n;
while (node) {
edgeNode = node;
if (node[key]) {
node = node[key] as Nullable<Node>;
} else {
node =
node.parentNode &&
!Dom.isBlock(node.parentNode, win) &&
node.parentNode !== area
? node.parentNode
: null;
}
if (Dom.isBlock(node, win)) {
break;
}
}
return edgeNode;
};
const start: Node = edge(elm),
end: Node = edge(elm, 'nextSibling');
const range = this.jodit.s.createRange();
range.setStartBefore(start);
range.setEndAfter(end);
const fragment = range.extractContents();
const wrapper = this.jodit.createInside.element(this.style.element);
wrapper.appendChild(fragment);
range.insertNode(wrapper);
if (this.style.elementIsBlock) {
this.postProcessListElement(wrapper);
if (
Dom.isEmpty(wrapper) &&
!Dom.isTag(wrapper.firstElementChild, 'br')
) {
wrapper.appendChild(this.jodit.createInside.element('br'));
}
}
return wrapper;
}
/**
* Post process UL or OL element
* @param wrapper
*/
private postProcessListElement(wrapper: HTMLElement): void {
// Add extra LI inside UL/OL
if (
/^(OL|UL)$/i.test(this.style.element) &&
!Dom.isTag(wrapper.firstElementChild, 'li')
) {
const li = Dom.replace(wrapper, 'li', this.jodit.createInside);
const ul = Dom.wrap(li, this.style.element, this.jodit);
if (ul) {
wrapper = ul;
}
}
}
}