tinymce-editor/copy_this/modules/hdi/hdi-tinymce/tinymce/classes/Formatter.js

2391 lines
69 KiB
JavaScript

/**
* Formatter.js
*
* Copyright, Moxiecode Systems AB
* Released under LGPL License.
*
* License: http://www.tinymce.com/license
* Contributing: http://www.tinymce.com/contributing
*/
/**
* Text formatter engine class. This class is used to apply formats like bold, italic, font size
* etc to the current selection or specific nodes. This engine was build to replace the browsers
* default formatting logic for execCommand due to it's inconsistant and buggy behavior.
*
* @class tinymce.Formatter
* @example
* tinymce.activeEditor.formatter.register('mycustomformat', {
* inline: 'span',
* styles: {color: '#ff0000'}
* });
*
* tinymce.activeEditor.formatter.apply('mycustomformat');
*/
define("tinymce/Formatter", [
"tinymce/dom/TreeWalker",
"tinymce/dom/RangeUtils",
"tinymce/util/Tools"
], function(TreeWalker, RangeUtils, Tools) {
/**
* Constructs a new formatter instance.
*
* @constructor Formatter
* @param {tinymce.Editor} ed Editor instance to construct the formatter engine to.
*/
return function(ed) {
var formats = {},
dom = ed.dom,
selection = ed.selection,
rangeUtils = new RangeUtils(dom),
isValid = ed.schema.isValidChild,
isBlock = dom.isBlock,
forcedRootBlock = ed.settings.forced_root_block,
nodeIndex = dom.nodeIndex,
INVISIBLE_CHAR = '\uFEFF',
MCE_ATTR_RE = /^(src|href|style)$/,
FALSE = false,
TRUE = true,
formatChangeData,
undef,
getContentEditable = dom.getContentEditable,
disableCaretContainer,
markCaretContainersBogus;
var each = Tools.each,
grep = Tools.grep,
walk = Tools.walk,
extend = Tools.extend;
function isTextBlock(name) {
if (name.nodeType) {
name = name.nodeName;
}
return !!ed.schema.getTextBlockElements()[name.toLowerCase()];
}
function getParents(node, selector) {
return dom.getParents(node, selector, dom.getRoot());
}
function isCaretNode(node) {
return node.nodeType === 1 && node.id === '_mce_caret';
}
function defaultFormats() {
register({
alignleft: [
{selector: 'figure,p,h1,h2,h3,h4,h5,h6,td,th,tr,div,ul,ol,li', styles: {textAlign: 'left'}, defaultBlock: 'div'},
{selector: 'img,table', collapsed: false, styles: {'float': 'left'}}
],
aligncenter: [
{selector: 'figure,p,h1,h2,h3,h4,h5,h6,td,th,tr,div,ul,ol,li', styles: {textAlign: 'center'}, defaultBlock: 'div'},
{selector: 'img', collapsed: false, styles: {display: 'block', marginLeft: 'auto', marginRight: 'auto'}},
{selector: 'table', collapsed: false, styles: {marginLeft: 'auto', marginRight: 'auto'}}
],
alignright: [
{selector: 'figure,p,h1,h2,h3,h4,h5,h6,td,th,tr,div,ul,ol,li', styles: {textAlign: 'right'}, defaultBlock: 'div'},
{selector: 'img,table', collapsed: false, styles: {'float': 'right'}}
],
alignjustify: [
{selector: 'figure,p,h1,h2,h3,h4,h5,h6,td,th,tr,div,ul,ol,li', styles: {textAlign: 'justify'}, defaultBlock: 'div'}
],
bold: [
{inline: 'strong', remove: 'all'},
{inline: 'span', styles: {fontWeight: 'bold'}},
{inline: 'b', remove: 'all'}
],
italic: [
{inline: 'em', remove: 'all'},
{inline: 'span', styles: {fontStyle: 'italic'}},
{inline: 'i', remove: 'all'}
],
underline: [
{inline: 'span', styles: {textDecoration: 'underline'}, exact: true},
{inline: 'u', remove: 'all'}
],
strikethrough: [
{inline: 'span', styles: {textDecoration: 'line-through'}, exact: true},
{inline: 'strike', remove: 'all'}
],
forecolor: {inline: 'span', styles: {color: '%value'}, wrap_links: false},
hilitecolor: {inline: 'span', styles: {backgroundColor: '%value'}, wrap_links: false},
fontname: {inline: 'span', styles: {fontFamily: '%value'}},
fontsize: {inline: 'span', styles: {fontSize: '%value'}},
fontsize_class: {inline: 'span', attributes: {'class': '%value'}},
blockquote: {block: 'blockquote', wrapper: 1, remove: 'all'},
subscript: {inline: 'sub'},
superscript: {inline: 'sup'},
code: {inline: 'code'},
link: {inline: 'a', selector: 'a', remove: 'all', split: true, deep: true,
onmatch: function() {
return true;
},
onformat: function(elm, fmt, vars) {
each(vars, function(value, key) {
dom.setAttrib(elm, key, value);
});
}
},
removeformat: [
{selector: 'b,strong,em,i,font,u,strike', remove: 'all', split: true, expand: false, block_expand: true, deep: true},
{selector: 'span', attributes: ['style', 'class'], remove: 'empty', split: true, expand: false, deep: true},
{selector: '*', attributes: ['style', 'class'], split: false, expand: false, deep: true}
]
});
// Register default block formats
each('p h1 h2 h3 h4 h5 h6 div address pre div dt dd samp'.split(/\s/), function(name) {
register(name, {block: name, remove: 'all'});
});
// Register user defined formats
register(ed.settings.formats);
}
function addKeyboardShortcuts() {
// Add some inline shortcuts
ed.addShortcut('ctrl+b', 'bold_desc', 'Bold');
ed.addShortcut('ctrl+i', 'italic_desc', 'Italic');
ed.addShortcut('ctrl+u', 'underline_desc', 'Underline');
// BlockFormat shortcuts keys
for (var i = 1; i <= 6; i++) {
ed.addShortcut('ctrl+' + i, '', ['FormatBlock', false, 'h' + i]);
}
ed.addShortcut('ctrl+7', '', ['FormatBlock', false, 'p']);
ed.addShortcut('ctrl+8', '', ['FormatBlock', false, 'div']);
ed.addShortcut('ctrl+9', '', ['FormatBlock', false, 'address']);
}
// Public functions
/**
* Returns the format by name or all formats if no name is specified.
*
* @method get
* @param {String} name Optional name to retrive by.
* @return {Array/Object} Array/Object with all registred formats or a specific format.
*/
function get(name) {
return name ? formats[name] : formats;
}
/**
* Registers a specific format by name.
*
* @method register
* @param {Object/String} name Name of the format for example "bold".
* @param {Object/Array} format Optional format object or array of format variants
* can only be omitted if the first arg is an object.
*/
function register(name, format) {
if (name) {
if (typeof(name) !== 'string') {
each(name, function(format, name) {
register(name, format);
});
} else {
// Force format into array and add it to internal collection
format = format.length ? format : [format];
each(format, function(format) {
// Set deep to false by default on selector formats this to avoid removing
// alignment on images inside paragraphs when alignment is changed on paragraphs
if (format.deep === undef) {
format.deep = !format.selector;
}
// Default to true
if (format.split === undef) {
format.split = !format.selector || format.inline;
}
// Default to true
if (format.remove === undef && format.selector && !format.inline) {
format.remove = 'none';
}
// Mark format as a mixed format inline + block level
if (format.selector && format.inline) {
format.mixed = true;
format.block_expand = true;
}
// Split classes if needed
if (typeof(format.classes) === 'string') {
format.classes = format.classes.split(/\s+/);
}
});
formats[name] = format;
}
}
}
function getTextDecoration(node) {
var decoration;
ed.dom.getParent(node, function(n) {
decoration = ed.dom.getStyle(n, 'text-decoration');
return decoration && decoration !== 'none';
});
return decoration;
}
function processUnderlineAndColor(node) {
var textDecoration;
if (node.nodeType === 1 && node.parentNode && node.parentNode.nodeType === 1) {
textDecoration = getTextDecoration(node.parentNode);
if (ed.dom.getStyle(node, 'color') && textDecoration) {
ed.dom.setStyle(node, 'text-decoration', textDecoration);
} else if (ed.dom.getStyle(node, 'textdecoration') === textDecoration) {
ed.dom.setStyle(node, 'text-decoration', null);
}
}
}
/**
* Applies the specified format to the current selection or specified node.
*
* @method apply
* @param {String} name Name of format to apply.
* @param {Object} vars Optional list of variables to replace within format before applying it.
* @param {Node} node Optional node to apply the format to defaults to current selection.
*/
function apply(name, vars, node) {
var formatList = get(name), format = formatList[0], bookmark, rng, isCollapsed = !node && selection.isCollapsed();
function setElementFormat(elm, fmt) {
fmt = fmt || format;
if (elm) {
if (fmt.onformat) {
fmt.onformat(elm, fmt, vars, node);
}
each(fmt.styles, function(value, name) {
dom.setStyle(elm, name, replaceVars(value, vars));
});
each(fmt.attributes, function(value, name) {
dom.setAttrib(elm, name, replaceVars(value, vars));
});
each(fmt.classes, function(value) {
value = replaceVars(value, vars);
if (!dom.hasClass(elm, value)) {
dom.addClass(elm, value);
}
});
}
}
function adjustSelectionToVisibleSelection() {
function findSelectionEnd(start, end) {
var walker = new TreeWalker(end);
for (node = walker.current(); node; node = walker.prev()) {
if (node.childNodes.length > 1 || node == start || node.tagName == 'BR') {
return node;
}
}
}
// Adjust selection so that a end container with a end offset of zero is not included in the selection
// as this isn't visible to the user.
var rng = ed.selection.getRng();
var start = rng.startContainer;
var end = rng.endContainer;
if (start != end && rng.endOffset === 0) {
var newEnd = findSelectionEnd(start, end);
var endOffset = newEnd.nodeType == 3 ? newEnd.length : newEnd.childNodes.length;
rng.setEnd(newEnd, endOffset);
}
return rng;
}
function applyStyleToList(node, bookmark, wrapElm, newWrappers, process){
var nodes = [], listIndex = -1, list, startIndex = -1, endIndex = -1, currentWrapElm;
// find the index of the first child list.
each(node.childNodes, function(n, index) {
if (n.nodeName === "UL" || n.nodeName === "OL") {
listIndex = index;
list = n;
return false;
}
});
// get the index of the bookmarks
each(node.childNodes, function(n, index) {
if (n.nodeName === "SPAN" && dom.getAttrib(n, "data-mce-type") == "bookmark") {
if (n.id == bookmark.id + "_start") {
startIndex = index;
} else if (n.id == bookmark.id + "_end") {
endIndex = index;
}
}
});
// if the selection spans across an embedded list, or there isn't an embedded list - handle processing normally
if (listIndex <= 0 || (startIndex < listIndex && endIndex > listIndex)) {
each(grep(node.childNodes), process);
return 0;
} else {
currentWrapElm = dom.clone(wrapElm, FALSE);
// create a list of the nodes on the same side of the list as the selection
each(grep(node.childNodes), function(n, index) {
if ((startIndex < listIndex && index < listIndex) || (startIndex > listIndex && index > listIndex)) {
nodes.push(n);
n.parentNode.removeChild(n);
}
});
// insert the wrapping element either before or after the list.
if (startIndex < listIndex) {
node.insertBefore(currentWrapElm, list);
} else if (startIndex > listIndex) {
node.insertBefore(currentWrapElm, list.nextSibling);
}
// add the new nodes to the list.
newWrappers.push(currentWrapElm);
each(nodes, function(node) {
currentWrapElm.appendChild(node);
});
return currentWrapElm;
}
}
function applyRngStyle(rng, bookmark, node_specific) {
var newWrappers = [], wrapName, wrapElm, contentEditable = true;
// Setup wrapper element
wrapName = format.inline || format.block;
wrapElm = dom.create(wrapName);
setElementFormat(wrapElm);
rangeUtils.walk(rng, function(nodes) {
var currentWrapElm;
/**
* Process a list of nodes wrap them.
*/
function process(node) {
var nodeName, parentName, found, hasContentEditableState, lastContentEditable;
lastContentEditable = contentEditable;
nodeName = node.nodeName.toLowerCase();
parentName = node.parentNode.nodeName.toLowerCase();
// Node has a contentEditable value
if (node.nodeType === 1 && getContentEditable(node)) {
lastContentEditable = contentEditable;
contentEditable = getContentEditable(node) === "true";
hasContentEditableState = true; // We don't want to wrap the container only it's children
}
// Stop wrapping on br elements
if (isEq(nodeName, 'br')) {
currentWrapElm = 0;
// Remove any br elements when we wrap things
if (format.block) {
dom.remove(node);
}
return;
}
// If node is wrapper type
if (format.wrapper && matchNode(node, name, vars)) {
currentWrapElm = 0;
return;
}
// Can we rename the block
// TODO: Break this if up, too complex
if (contentEditable && !hasContentEditableState && format.block &&
!format.wrapper && isTextBlock(nodeName) && isValid(parentName, wrapName)) {
node = dom.rename(node, wrapName);
setElementFormat(node);
newWrappers.push(node);
currentWrapElm = 0;
return;
}
// Handle selector patterns
if (format.selector) {
// Look for matching formats
each(formatList, function(format) {
// Check collapsed state if it exists
if ('collapsed' in format && format.collapsed !== isCollapsed) {
return;
}
if (dom.is(node, format.selector) && !isCaretNode(node)) {
setElementFormat(node, format);
found = true;
}
});
// Continue processing if a selector match wasn't found and a inline element is defined
if (!format.inline || found) {
currentWrapElm = 0;
return;
}
}
// Is it valid to wrap this item
// TODO: Break this if up, too complex
if (contentEditable && !hasContentEditableState && isValid(wrapName, nodeName) && isValid(parentName, wrapName) &&
!(!node_specific && node.nodeType === 3 &&
node.nodeValue.length === 1 &&
node.nodeValue.charCodeAt(0) === 65279) &&
!isCaretNode(node) &&
(!format.inline || !isBlock(node))) {
// Start wrapping
if (!currentWrapElm) {
// Wrap the node
currentWrapElm = dom.clone(wrapElm, FALSE);
node.parentNode.insertBefore(currentWrapElm, node);
newWrappers.push(currentWrapElm);
}
currentWrapElm.appendChild(node);
} else if (nodeName == 'li' && bookmark) {
// Start wrapping - if we are in a list node and have a bookmark, then
// we will always begin by wrapping in a new element.
currentWrapElm = applyStyleToList(node, bookmark, wrapElm, newWrappers, process);
} else {
// Start a new wrapper for possible children
currentWrapElm = 0;
each(grep(node.childNodes), process);
if (hasContentEditableState) {
contentEditable = lastContentEditable; // Restore last contentEditable state from stack
}
// End the last wrapper
currentWrapElm = 0;
}
}
// Process siblings from range
each(nodes, process);
});
// Wrap links inside as well, for example color inside a link when the wrapper is around the link
if (format.wrap_links === false) {
each(newWrappers, function(node) {
function process(node) {
var i, currentWrapElm, children;
if (node.nodeName === 'A') {
currentWrapElm = dom.clone(wrapElm, FALSE);
newWrappers.push(currentWrapElm);
children = grep(node.childNodes);
for (i = 0; i < children.length; i++) {
currentWrapElm.appendChild(children[i]);
}
node.appendChild(currentWrapElm);
}
each(grep(node.childNodes), process);
}
process(node);
});
}
// Cleanup
each(newWrappers, function(node) {
var childCount;
function getChildCount(node) {
var count = 0;
each(node.childNodes, function(node) {
if (!isWhiteSpaceNode(node) && !isBookmarkNode(node)) {
count++;
}
});
return count;
}
function mergeStyles(node) {
var child, clone;
each(node.childNodes, function(node) {
if (node.nodeType == 1 && !isBookmarkNode(node) && !isCaretNode(node)) {
child = node;
return FALSE; // break loop
}
});
// If child was found and of the same type as the current node
if (child && matchName(child, format)) {
clone = dom.clone(child, FALSE);
setElementFormat(clone);
dom.replace(clone, node, TRUE);
dom.remove(child, 1);
}
return clone || node;
}
childCount = getChildCount(node);
// Remove empty nodes but only if there is multiple wrappers and they are not block
// elements so never remove single <h1></h1> since that would remove the
// currrent empty block element where the caret is at
if ((newWrappers.length > 1 || !isBlock(node)) && childCount === 0) {
dom.remove(node, 1);
return;
}
if (format.inline || format.wrapper) {
// Merges the current node with it's children of similar type to reduce the number of elements
if (!format.exact && childCount === 1) {
node = mergeStyles(node);
}
// Remove/merge children
each(formatList, function(format) {
// Merge all children of similar type will move styles from child to parent
// this: <span style="color:red"><b><span style="color:red; font-size:10px">text</span></b></span>
// will become: <span style="color:red"><b><span style="font-size:10px">text</span></b></span>
each(dom.select(format.inline, node), function(child) {
var parent;
// When wrap_links is set to false we don't want
// to remove the format on children within links
if (format.wrap_links === false) {
parent = child.parentNode;
do {
if (parent.nodeName === 'A') {
return;
}
} while ((parent = parent.parentNode));
}
removeFormat(format, vars, child, format.exact ? child : null);
});
});
// Remove child if direct parent is of same type
if (matchNode(node.parentNode, name, vars)) {
dom.remove(node, 1);
node = 0;
return TRUE;
}
// Look for parent with similar style format
if (format.merge_with_parents) {
dom.getParent(node.parentNode, function(parent) {
if (matchNode(parent, name, vars)) {
dom.remove(node, 1);
node = 0;
return TRUE;
}
});
}
// Merge next and previous siblings if they are similar <b>text</b><b>text</b> becomes <b>texttext</b>
if (node && format.merge_siblings !== false) {
node = mergeSiblings(getNonWhiteSpaceSibling(node), node);
node = mergeSiblings(node, getNonWhiteSpaceSibling(node, TRUE));
}
}
});
}
if (format) {
if (node) {
if (node.nodeType) {
rng = dom.createRng();
rng.setStartBefore(node);
rng.setEndAfter(node);
applyRngStyle(expandRng(rng, formatList), null, true);
} else {
applyRngStyle(node, null, true);
}
} else {
if (!isCollapsed || !format.inline || dom.select('td.mce-item-selected,th.mce-item-selected').length) {
// Obtain selection node before selection is unselected by applyRngStyle()
var curSelNode = ed.selection.getNode();
// If the formats have a default block and we can't find a parent block then
// start wrapping it with a DIV this is for forced_root_blocks: false
// It's kind of a hack but people should be using the default block type P since all desktop editors work that way
if (!forcedRootBlock && formatList[0].defaultBlock && !dom.getParent(curSelNode, dom.isBlock)) {
apply(formatList[0].defaultBlock);
}
// Apply formatting to selection
ed.selection.setRng(adjustSelectionToVisibleSelection());
bookmark = selection.getBookmark();
applyRngStyle(expandRng(selection.getRng(TRUE), formatList), bookmark);
// Colored nodes should be underlined so that the color of the underline matches the text color.
if (format.styles && (format.styles.color || format.styles.textDecoration)) {
walk(curSelNode, processUnderlineAndColor, 'childNodes');
processUnderlineAndColor(curSelNode);
}
selection.moveToBookmark(bookmark);
moveStart(selection.getRng(TRUE));
ed.nodeChanged();
} else {
performCaretAction('apply', name, vars);
}
}
}
}
/**
* Removes the specified format from the current selection or specified node.
*
* @method remove
* @param {String} name Name of format to remove.
* @param {Object} vars Optional list of variables to replace within format before removing it.
* @param {Node/Range} node Optional node or DOM range to remove the format from defaults to current selection.
*/
function remove(name, vars, node) {
var formatList = get(name), format = formatList[0], bookmark, rng, contentEditable = true;
// Merges the styles for each node
function process(node) {
var children, i, l, lastContentEditable, hasContentEditableState;
// Node has a contentEditable value
if (node.nodeType === 1 && getContentEditable(node)) {
lastContentEditable = contentEditable;
contentEditable = getContentEditable(node) === "true";
hasContentEditableState = true; // We don't want to wrap the container only it's children
}
// Grab the children first since the nodelist might be changed
children = grep(node.childNodes);
// Process current node
if (contentEditable && !hasContentEditableState) {
for (i = 0, l = formatList.length; i < l; i++) {
if (removeFormat(formatList[i], vars, node, node)) {
break;
}
}
}
// Process the children
if (format.deep) {
if (children.length) {
for (i = 0, l = children.length; i < l; i++) {
process(children[i]);
}
if (hasContentEditableState) {
contentEditable = lastContentEditable; // Restore last contentEditable state from stack
}
}
}
}
function findFormatRoot(container) {
var formatRoot;
// Find format root
each(getParents(container.parentNode).reverse(), function(parent) {
var format;
// Find format root element
if (!formatRoot && parent.id != '_start' && parent.id != '_end') {
// Is the node matching the format we are looking for
format = matchNode(parent, name, vars);
if (format && format.split !== false) {
formatRoot = parent;
}
}
});
return formatRoot;
}
function wrapAndSplit(format_root, container, target, split) {
var parent, clone, lastClone, firstClone, i, formatRootParent;
// Format root found then clone formats and split it
if (format_root) {
formatRootParent = format_root.parentNode;
for (parent = container.parentNode; parent && parent != formatRootParent; parent = parent.parentNode) {
clone = dom.clone(parent, FALSE);
for (i = 0; i < formatList.length; i++) {
if (removeFormat(formatList[i], vars, clone, clone)) {
clone = 0;
break;
}
}
// Build wrapper node
if (clone) {
if (lastClone) {
clone.appendChild(lastClone);
}
if (!firstClone) {
firstClone = clone;
}
lastClone = clone;
}
}
// Never split block elements if the format is mixed
if (split && (!format.mixed || !isBlock(format_root))) {
container = dom.split(format_root, container);
}
// Wrap container in cloned formats
if (lastClone) {
target.parentNode.insertBefore(lastClone, target);
firstClone.appendChild(target);
}
}
return container;
}
function splitToFormatRoot(container) {
return wrapAndSplit(findFormatRoot(container), container, container, true);
}
function unwrap(start) {
var node = dom.get(start ? '_start' : '_end'),
out = node[start ? 'firstChild' : 'lastChild'];
// If the end is placed within the start the result will be removed
// So this checks if the out node is a bookmark node if it is it
// checks for another more suitable node
if (isBookmarkNode(out)) {
out = out[start ? 'firstChild' : 'lastChild'];
}
dom.remove(node, true);
return out;
}
function removeRngStyle(rng) {
var startContainer, endContainer;
rng = expandRng(rng, formatList, TRUE);
if (format.split) {
startContainer = getContainer(rng, TRUE);
endContainer = getContainer(rng);
if (startContainer != endContainer) {
// WebKit will render the table incorrectly if we wrap a TD in a SPAN
// so lets see if the can use the first child instead
// This will happen if you tripple click a table cell and use remove formatting
if (/^(TR|TD)$/.test(startContainer.nodeName) && startContainer.firstChild) {
if (startContainer.nodeName == "TD") {
startContainer = startContainer.firstChild || startContainer;
} else {
startContainer = startContainer.firstChild.firstChild || startContainer;
}
}
// Wrap start/end nodes in span element since these might be cloned/moved
startContainer = wrap(startContainer, 'span', {id: '_start', 'data-mce-type': 'bookmark'});
endContainer = wrap(endContainer, 'span', {id: '_end', 'data-mce-type': 'bookmark'});
// Split start/end
splitToFormatRoot(startContainer);
splitToFormatRoot(endContainer);
// Unwrap start/end to get real elements again
startContainer = unwrap(TRUE);
endContainer = unwrap();
} else {
startContainer = endContainer = splitToFormatRoot(startContainer);
}
// Update range positions since they might have changed after the split operations
rng.startContainer = startContainer.parentNode;
rng.startOffset = nodeIndex(startContainer);
rng.endContainer = endContainer.parentNode;
rng.endOffset = nodeIndex(endContainer) + 1;
}
// Remove items between start/end
rangeUtils.walk(rng, function(nodes) {
each(nodes, function(node) {
process(node);
// Remove parent span if it only contains text-decoration: underline, yet a parent node is also underlined.
if (node.nodeType === 1 && ed.dom.getStyle(node, 'text-decoration') === 'underline' &&
node.parentNode && getTextDecoration(node.parentNode) === 'underline') {
removeFormat({
'deep': false,
'exact': true,
'inline': 'span',
'styles': {
'textDecoration': 'underline'
}
}, null, node);
}
});
});
}
// Handle node
if (node) {
if (node.nodeType) {
rng = dom.createRng();
rng.setStartBefore(node);
rng.setEndAfter(node);
removeRngStyle(rng);
} else {
removeRngStyle(node);
}
return;
}
if (!selection.isCollapsed() || !format.inline || dom.select('td.mce-item-selected,th.mce-item-selected').length) {
bookmark = selection.getBookmark();
removeRngStyle(selection.getRng(TRUE));
selection.moveToBookmark(bookmark);
// Check if start element still has formatting then we are at: "<b>text|</b>text"
// and need to move the start into the next text node
if (format.inline && match(name, vars, selection.getStart())) {
moveStart(selection.getRng(true));
}
ed.nodeChanged();
} else {
performCaretAction('remove', name, vars);
}
}
/**
* Toggles the specified format on/off.
*
* @method toggle
* @param {String} name Name of format to apply/remove.
* @param {Object} vars Optional list of variables to replace within format before applying/removing it.
* @param {Node} node Optional node to apply the format to or remove from. Defaults to current selection.
*/
function toggle(name, vars, node) {
var fmt = get(name);
if (match(name, vars, node) && (!('toggle' in fmt[0]) || fmt[0].toggle)) {
remove(name, vars, node);
} else {
apply(name, vars, node);
}
}
/**
* Return true/false if the specified node has the specified format.
*
* @method matchNode
* @param {Node} node Node to check the format on.
* @param {String} name Format name to check.
* @param {Object} vars Optional list of variables to replace before checking it.
* @param {Boolean} similar Match format that has similar properties.
* @return {Object} Returns the format object it matches or undefined if it doesn't match.
*/
function matchNode(node, name, vars, similar) {
var formatList = get(name), format, i, classes;
function matchItems(node, format, item_name) {
var key, value, items = format[item_name], i;
// Custom match
if (format.onmatch) {
return format.onmatch(node, format, item_name);
}
// Check all items
if (items) {
// Non indexed object
if (items.length === undef) {
for (key in items) {
if (items.hasOwnProperty(key)) {
if (item_name === 'attributes') {
value = dom.getAttrib(node, key);
} else {
value = getStyle(node, key);
}
if (similar && !value && !format.exact) {
return;
}
if ((!similar || format.exact) && !isEq(value, normalizeStyleValue(replaceVars(items[key], vars), key))) {
return;
}
}
}
} else {
// Only one match needed for indexed arrays
for (i = 0; i < items.length; i++) {
if (item_name === 'attributes' ? dom.getAttrib(node, items[i]) : getStyle(node, items[i])) {
return format;
}
}
}
}
return format;
}
if (formatList && node) {
// Check each format in list
for (i = 0; i < formatList.length; i++) {
format = formatList[i];
// Name name, attributes, styles and classes
if (matchName(node, format) && matchItems(node, format, 'attributes') && matchItems(node, format, 'styles')) {
// Match classes
if ((classes = format.classes)) {
for (i = 0; i < classes.length; i++) {
if (!dom.hasClass(node, classes[i])) {
return;
}
}
}
return format;
}
}
}
}
/**
* Matches the current selection or specified node against the specified format name.
*
* @method match
* @param {String} name Name of format to match.
* @param {Object} vars Optional list of variables to replace before checking it.
* @param {Node} node Optional node to check.
* @return {boolean} true/false if the specified selection/node matches the format.
*/
function match(name, vars, node) {
var startNode;
function matchParents(node) {
// Find first node with similar format settings
node = dom.getParent(node, function(node) {
return !!matchNode(node, name, vars, true);
});
// Do an exact check on the similar format element
return matchNode(node, name, vars);
}
// Check specified node
if (node) {
return matchParents(node);
}
// Check selected node
node = selection.getNode();
if (matchParents(node)) {
return TRUE;
}
// Check start node if it's different
startNode = selection.getStart();
if (startNode != node) {
if (matchParents(startNode)) {
return TRUE;
}
}
return FALSE;
}
/**
* Matches the current selection against the array of formats and returns a new array with matching formats.
*
* @method matchAll
* @param {Array} names Name of format to match.
* @param {Object} vars Optional list of variables to replace before checking it.
* @return {Array} Array with matched formats.
*/
function matchAll(names, vars) {
var startElement, matchedFormatNames = [], checkedMap = {};
// Check start of selection for formats
startElement = selection.getStart();
dom.getParent(startElement, function(node) {
var i, name;
for (i = 0; i < names.length; i++) {
name = names[i];
if (!checkedMap[name] && matchNode(node, name, vars)) {
checkedMap[name] = true;
matchedFormatNames.push(name);
}
}
}, dom.getRoot());
return matchedFormatNames;
}
/**
* Returns true/false if the specified format can be applied to the current selection or not. It
* will currently only check the state for selector formats, it returns true on all other format types.
*
* @method canApply
* @param {String} name Name of format to check.
* @return {boolean} true/false if the specified format can be applied to the current selection/node.
*/
function canApply(name) {
var formatList = get(name), startNode, parents, i, x, selector;
if (formatList) {
startNode = selection.getStart();
parents = getParents(startNode);
for (x = formatList.length - 1; x >= 0; x--) {
selector = formatList[x].selector;
// Format is not selector based, then always return TRUE
if (!selector) {
return TRUE;
}
for (i = parents.length - 1; i >= 0; i--) {
if (dom.is(parents[i], selector)) {
return TRUE;
}
}
}
}
return FALSE;
}
/**
* Executes the specified callback when the current selection matches the formats or not.
*
* @method formatChanged
* @param {String} formats Comma separated list of formats to check for.
* @param {function} callback Callback with state and args when the format is changed/toggled on/off.
* @param {Boolean} similar True/false state if the match should handle similar or exact formats.
*/
function formatChanged(formats, callback, similar) {
var currentFormats;
// Setup format node change logic
if (!formatChangeData) {
formatChangeData = {};
currentFormats = {};
ed.on('NodeChange', function(e) {
var parents = getParents(e.element), matchedFormats = {};
// Check for new formats
each(formatChangeData, function(callbacks, format) {
each(parents, function(node) {
if (matchNode(node, format, {}, callbacks.similar)) {
if (!currentFormats[format]) {
// Execute callbacks
each(callbacks, function(callback) {
callback(true, {node: node, format: format, parents: parents});
});
currentFormats[format] = callbacks;
}
matchedFormats[format] = callbacks;
return false;
}
});
});
// Check if current formats still match
each(currentFormats, function(callbacks, format) {
if (!matchedFormats[format]) {
delete currentFormats[format];
each(callbacks, function(callback) {
callback(false, {node: e.element, format: format, parents: parents});
});
}
});
});
}
// Add format listeners
each(formats.split(','), function(format) {
if (!formatChangeData[format]) {
formatChangeData[format] = [];
formatChangeData[format].similar = similar;
}
formatChangeData[format].push(callback);
});
return this;
}
// Expose to public
extend(this, {
get: get,
register: register,
apply: apply,
remove: remove,
toggle: toggle,
match: match,
matchAll: matchAll,
matchNode: matchNode,
canApply: canApply,
formatChanged: formatChanged
});
// Initialize
defaultFormats();
addKeyboardShortcuts();
ed.on('BeforeGetContent', function() {
if (markCaretContainersBogus) {
markCaretContainersBogus();
}
});
ed.on('mouseup keydown', function(e) {
if (disableCaretContainer) {
disableCaretContainer(e);
}
});
// Private functions
/**
* Checks if the specified nodes name matches the format inline/block or selector.
*
* @private
* @param {Node} node Node to match against the specified format.
* @param {Object} format Format object o match with.
* @return {boolean} true/false if the format matches.
*/
function matchName(node, format) {
// Check for inline match
if (isEq(node, format.inline)) {
return TRUE;
}
// Check for block match
if (isEq(node, format.block)) {
return TRUE;
}
// Check for selector match
if (format.selector) {
return node.nodeType == 1 && dom.is(node, format.selector);
}
}
/**
* Compares two string/nodes regardless of their case.
*
* @private
* @param {String/Node} Node or string to compare.
* @param {String/Node} Node or string to compare.
* @return {boolean} True/false if they match.
*/
function isEq(str1, str2) {
str1 = str1 || '';
str2 = str2 || '';
str1 = '' + (str1.nodeName || str1);
str2 = '' + (str2.nodeName || str2);
return str1.toLowerCase() == str2.toLowerCase();
}
/**
* Returns the style by name on the specified node. This method modifies the style
* contents to make it more easy to match. This will resolve a few browser issues.
*
* @private
* @param {Node} node to get style from.
* @param {String} name Style name to get.
* @return {String} Style item value.
*/
function getStyle(node, name) {
return normalizeStyleValue(dom.getStyle(node, name), name);
}
/**
* Normalize style value by name. This method modifies the style contents
* to make it more easy to match. This will resolve a few browser issues.
*
* @private
* @param {Node} node to get style from.
* @param {String} name Style name to get.
* @return {String} Style item value.
*/
function normalizeStyleValue(value, name) {
// Force the format to hex
if (name == 'color' || name == 'backgroundColor') {
value = dom.toHex(value);
}
// Opera will return bold as 700
if (name == 'fontWeight' && value == 700) {
value = 'bold';
}
// Normalize fontFamily so "'Font name', Font" becomes: "Font name,Font"
if (name == 'fontFamily') {
value = value.replace(/[\'\"]/g, '').replace(/,\s+/g, ',');
}
return '' + value;
}
/**
* Replaces variables in the value. The variable format is %var.
*
* @private
* @param {String} value Value to replace variables in.
* @param {Object} vars Name/value array with variables to replace.
* @return {String} New value with replaced variables.
*/
function replaceVars(value, vars) {
if (typeof(value) != "string") {
value = value(vars);
} else if (vars) {
value = value.replace(/%(\w+)/g, function(str, name) {
return vars[name] || str;
});
}
return value;
}
function isWhiteSpaceNode(node) {
return node && node.nodeType === 3 && /^([\t \r\n]+|)$/.test(node.nodeValue);
}
function wrap(node, name, attrs) {
var wrapper = dom.create(name, attrs);
node.parentNode.insertBefore(wrapper, node);
wrapper.appendChild(node);
return wrapper;
}
/**
* Expands the specified range like object to depending on format.
*
* For example on block formats it will move the start/end position
* to the beginning of the current block.
*
* @private
* @param {Object} rng Range like object.
* @param {Array} formats Array with formats to expand by.
* @return {Object} Expanded range like object.
*/
function expandRng(rng, format, remove) {
var lastIdx, leaf, endPoint,
startContainer = rng.startContainer,
startOffset = rng.startOffset,
endContainer = rng.endContainer,
endOffset = rng.endOffset;
// This function walks up the tree if there is no siblings before/after the node
function findParentContainer(start) {
var container, parent, sibling, siblingName, root;
container = parent = start ? startContainer : endContainer;
siblingName = start ? 'previousSibling' : 'nextSibling';
root = dom.getRoot();
function isBogusBr(node) {
return node.nodeName == "BR" && node.getAttribute('data-mce-bogus') && !node.nextSibling;
}
// If it's a text node and the offset is inside the text
if (container.nodeType == 3 && !isWhiteSpaceNode(container)) {
if (start ? startOffset > 0 : endOffset < container.nodeValue.length) {
return container;
}
}
for (;;) {
// Stop expanding on block elements
if (!format[0].block_expand && isBlock(parent)) {
return parent;
}
// Walk left/right
for (sibling = parent[siblingName]; sibling; sibling = sibling[siblingName]) {
if (!isBookmarkNode(sibling) && !isWhiteSpaceNode(sibling) && !isBogusBr(sibling)) {
return parent;
}
}
// Check if we can move up are we at root level or body level
if (parent.parentNode == root) {
container = parent;
break;
}
parent = parent.parentNode;
}
return container;
}
// This function walks down the tree to find the leaf at the selection.
// The offset is also returned as if node initially a leaf, the offset may be in the middle of the text node.
function findLeaf(node, offset) {
if (offset === undef) {
offset = node.nodeType === 3 ? node.length : node.childNodes.length;
}
while (node && node.hasChildNodes()) {
node = node.childNodes[offset];
if (node) {
offset = node.nodeType === 3 ? node.length : node.childNodes.length;
}
}
return { node: node, offset: offset };
}
// If index based start position then resolve it
if (startContainer.nodeType == 1 && startContainer.hasChildNodes()) {
lastIdx = startContainer.childNodes.length - 1;
startContainer = startContainer.childNodes[startOffset > lastIdx ? lastIdx : startOffset];
if (startContainer.nodeType == 3) {
startOffset = 0;
}
}
// If index based end position then resolve it
if (endContainer.nodeType == 1 && endContainer.hasChildNodes()) {
lastIdx = endContainer.childNodes.length - 1;
endContainer = endContainer.childNodes[endOffset > lastIdx ? lastIdx : endOffset - 1];
if (endContainer.nodeType == 3) {
endOffset = endContainer.nodeValue.length;
}
}
// Expands the node to the closes contentEditable false element if it exists
function findParentContentEditable(node) {
var parent = node;
while (parent) {
if (parent.nodeType === 1 && getContentEditable(parent)) {
return getContentEditable(parent) === "false" ? parent : node;
}
parent = parent.parentNode;
}
return node;
}
function findWordEndPoint(container, offset, start) {
var walker, node, pos, lastTextNode;
function findSpace(node, offset) {
var pos, pos2, str = node.nodeValue;
if (typeof(offset) == "undefined") {
offset = start ? str.length : 0;
}
if (start) {
pos = str.lastIndexOf(' ', offset);
pos2 = str.lastIndexOf('\u00a0', offset);
pos = pos > pos2 ? pos : pos2;
// Include the space on remove to avoid tag soup
if (pos !== -1 && !remove) {
pos++;
}
} else {
pos = str.indexOf(' ', offset);
pos2 = str.indexOf('\u00a0', offset);
pos = pos !== -1 && (pos2 === -1 || pos < pos2) ? pos : pos2;
}
return pos;
}
if (container.nodeType === 3) {
pos = findSpace(container, offset);
if (pos !== -1) {
return {container: container, offset: pos};
}
lastTextNode = container;
}
// Walk the nodes inside the block
walker = new TreeWalker(container, dom.getParent(container, isBlock) || ed.getBody());
while ((node = walker[start ? 'prev' : 'next']())) {
if (node.nodeType === 3) {
lastTextNode = node;
pos = findSpace(node);
if (pos !== -1) {
return {container: node, offset: pos};
}
} else if (isBlock(node)) {
break;
}
}
if (lastTextNode) {
if (start) {
offset = 0;
} else {
offset = lastTextNode.length;
}
return {container: lastTextNode, offset: offset};
}
}
function findSelectorEndPoint(container, sibling_name) {
var parents, i, y, curFormat;
if (container.nodeType == 3 && container.nodeValue.length === 0 && container[sibling_name]) {
container = container[sibling_name];
}
parents = getParents(container);
for (i = 0; i < parents.length; i++) {
for (y = 0; y < format.length; y++) {
curFormat = format[y];
// If collapsed state is set then skip formats that doesn't match that
if ("collapsed" in curFormat && curFormat.collapsed !== rng.collapsed) {
continue;
}
if (dom.is(parents[i], curFormat.selector)) {
return parents[i];
}
}
}
return container;
}
function findBlockEndPoint(container, sibling_name) {
var node;
// Expand to block of similar type
if (!format[0].wrapper) {
node = dom.getParent(container, format[0].block);
}
// Expand to first wrappable block element or any block element
if (!node) {
node = dom.getParent(container.nodeType == 3 ? container.parentNode : container, isTextBlock);
}
// Exclude inner lists from wrapping
if (node && format[0].wrapper) {
node = getParents(node, 'ul,ol').reverse()[0] || node;
}
// Didn't find a block element look for first/last wrappable element
if (!node) {
node = container;
while (node[sibling_name] && !isBlock(node[sibling_name])) {
node = node[sibling_name];
// Break on BR but include it will be removed later on
// we can't remove it now since we need to check if it can be wrapped
if (isEq(node, 'br')) {
break;
}
}
}
return node || container;
}
// Expand to closest contentEditable element
startContainer = findParentContentEditable(startContainer);
endContainer = findParentContentEditable(endContainer);
// Exclude bookmark nodes if possible
if (isBookmarkNode(startContainer.parentNode) || isBookmarkNode(startContainer)) {
startContainer = isBookmarkNode(startContainer) ? startContainer : startContainer.parentNode;
startContainer = startContainer.nextSibling || startContainer;
if (startContainer.nodeType == 3) {
startOffset = 0;
}
}
if (isBookmarkNode(endContainer.parentNode) || isBookmarkNode(endContainer)) {
endContainer = isBookmarkNode(endContainer) ? endContainer : endContainer.parentNode;
endContainer = endContainer.previousSibling || endContainer;
if (endContainer.nodeType == 3) {
endOffset = endContainer.length;
}
}
if (format[0].inline) {
if (rng.collapsed) {
// Expand left to closest word boundery
endPoint = findWordEndPoint(startContainer, startOffset, true);
if (endPoint) {
startContainer = endPoint.container;
startOffset = endPoint.offset;
}
// Expand right to closest word boundery
endPoint = findWordEndPoint(endContainer, endOffset);
if (endPoint) {
endContainer = endPoint.container;
endOffset = endPoint.offset;
}
}
// Avoid applying formatting to a trailing space.
leaf = findLeaf(endContainer, endOffset);
if (leaf.node) {
while (leaf.node && leaf.offset === 0 && leaf.node.previousSibling) {
leaf = findLeaf(leaf.node.previousSibling);
}
if (leaf.node && leaf.offset > 0 && leaf.node.nodeType === 3 &&
leaf.node.nodeValue.charAt(leaf.offset - 1) === ' ') {
if (leaf.offset > 1) {
endContainer = leaf.node;
endContainer.splitText(leaf.offset - 1);
}
}
}
}
// Move start/end point up the tree if the leaves are sharp and if we are in different containers
// Example * becomes !: !<p><b><i>*text</i><i>text*</i></b></p>!
// This will reduce the number of wrapper elements that needs to be created
// Move start point up the tree
if (format[0].inline || format[0].block_expand) {
if (!format[0].inline || (startContainer.nodeType != 3 || startOffset === 0)) {
startContainer = findParentContainer(true);
}
if (!format[0].inline || (endContainer.nodeType != 3 || endOffset === endContainer.nodeValue.length)) {
endContainer = findParentContainer();
}
}
// Expand start/end container to matching selector
if (format[0].selector && format[0].expand !== FALSE && !format[0].inline) {
// Find new startContainer/endContainer if there is better one
startContainer = findSelectorEndPoint(startContainer, 'previousSibling');
endContainer = findSelectorEndPoint(endContainer, 'nextSibling');
}
// Expand start/end container to matching block element or text node
if (format[0].block || format[0].selector) {
// Find new startContainer/endContainer if there is better one
startContainer = findBlockEndPoint(startContainer, 'previousSibling');
endContainer = findBlockEndPoint(endContainer, 'nextSibling');
// Non block element then try to expand up the leaf
if (format[0].block) {
if (!isBlock(startContainer)) {
startContainer = findParentContainer(true);
}
if (!isBlock(endContainer)) {
endContainer = findParentContainer();
}
}
}
// Setup index for startContainer
if (startContainer.nodeType == 1) {
startOffset = nodeIndex(startContainer);
startContainer = startContainer.parentNode;
}
// Setup index for endContainer
if (endContainer.nodeType == 1) {
endOffset = nodeIndex(endContainer) + 1;
endContainer = endContainer.parentNode;
}
// Return new range like object
return {
startContainer: startContainer,
startOffset: startOffset,
endContainer: endContainer,
endOffset: endOffset
};
}
/**
* Removes the specified format for the specified node. It will also remove the node if it doesn't have
* any attributes if the format specifies it to do so.
*
* @private
* @param {Object} format Format object with items to remove from node.
* @param {Object} vars Name/value object with variables to apply to format.
* @param {Node} node Node to remove the format styles on.
* @param {Node} compare_node Optional compare node, if specified the styles will be compared to that node.
* @return {Boolean} True/false if the node was removed or not.
*/
function removeFormat(format, vars, node, compare_node) {
var i, attrs, stylesModified;
// Check if node matches format
if (!matchName(node, format)) {
return FALSE;
}
// Should we compare with format attribs and styles
if (format.remove != 'all') {
// Remove styles
each(format.styles, function(value, name) {
value = normalizeStyleValue(replaceVars(value, vars), name);
// Indexed array
if (typeof(name) === 'number') {
name = value;
compare_node = 0;
}
if (!compare_node || isEq(getStyle(compare_node, name), value)) {
dom.setStyle(node, name, '');
}
stylesModified = 1;
});
// Remove style attribute if it's empty
if (stylesModified && dom.getAttrib(node, 'style') === '') {
node.removeAttribute('style');
node.removeAttribute('data-mce-style');
}
// Remove attributes
each(format.attributes, function(value, name) {
var valueOut;
value = replaceVars(value, vars);
// Indexed array
if (typeof(name) === 'number') {
name = value;
compare_node = 0;
}
if (!compare_node || isEq(dom.getAttrib(compare_node, name), value)) {
// Keep internal classes
if (name == 'class') {
value = dom.getAttrib(node, name);
if (value) {
// Build new class value where everything is removed except the internal prefixed classes
valueOut = '';
each(value.split(/\s+/), function(cls) {
if (/mce\w+/.test(cls)) {
valueOut += (valueOut ? ' ' : '') + cls;
}
});
// We got some internal classes left
if (valueOut) {
dom.setAttrib(node, name, valueOut);
return;
}
}
}
// IE6 has a bug where the attribute doesn't get removed correctly
if (name == "class") {
node.removeAttribute('className');
}
// Remove mce prefixed attributes
if (MCE_ATTR_RE.test(name)) {
node.removeAttribute('data-mce-' + name);
}
node.removeAttribute(name);
}
});
// Remove classes
each(format.classes, function(value) {
value = replaceVars(value, vars);
if (!compare_node || dom.hasClass(compare_node, value)) {
dom.removeClass(node, value);
}
});
// Check for non internal attributes
attrs = dom.getAttribs(node);
for (i = 0; i < attrs.length; i++) {
if (attrs[i].nodeName.indexOf('_') !== 0) {
return FALSE;
}
}
}
// Remove the inline child if it's empty for example <b> or <span>
if (format.remove != 'none') {
removeNode(node, format);
return TRUE;
}
}
/**
* Removes the node and wrap it's children in paragraphs before doing so or
* appends BR elements to the beginning/end of the block element if forcedRootBlocks is disabled.
*
* If the div in the node below gets removed:
* text<div>text</div>text
*
* Output becomes:
* text<div><br />text<br /></div>text
*
* So when the div is removed the result is:
* text<br />text<br />text
*
* @private
* @param {Node} node Node to remove + apply BR/P elements to.
* @param {Object} format Format rule.
* @return {Node} Input node.
*/
function removeNode(node, format) {
var parentNode = node.parentNode, rootBlockElm;
function find(node, next, inc) {
node = getNonWhiteSpaceSibling(node, next, inc);
return !node || (node.nodeName == 'BR' || isBlock(node));
}
if (format.block) {
if (!forcedRootBlock) {
// Append BR elements if needed before we remove the block
if (isBlock(node) && !isBlock(parentNode)) {
if (!find(node, FALSE) && !find(node.firstChild, TRUE, 1)) {
node.insertBefore(dom.create('br'), node.firstChild);
}
if (!find(node, TRUE) && !find(node.lastChild, FALSE, 1)) {
node.appendChild(dom.create('br'));
}
}
} else {
// Wrap the block in a forcedRootBlock if we are at the root of document
if (parentNode == dom.getRoot()) {
if (!format.list_block || !isEq(node, format.list_block)) {
each(grep(node.childNodes), function(node) {
if (isValid(forcedRootBlock, node.nodeName.toLowerCase())) {
if (!rootBlockElm) {
rootBlockElm = wrap(node, forcedRootBlock);
} else {
rootBlockElm.appendChild(node);
}
} else {
rootBlockElm = 0;
}
});
}
}
}
}
// Never remove nodes that isn't the specified inline element if a selector is specified too
if (format.selector && format.inline && !isEq(format.inline, node)) {
return;
}
dom.remove(node, 1);
}
/**
* Returns the next/previous non whitespace node.
*
* @private
* @param {Node} node Node to start at.
* @param {boolean} next (Optional) Include next or previous node defaults to previous.
* @param {boolean} inc (Optional) Include the current node in checking. Defaults to false.
* @return {Node} Next or previous node or undefined if it wasn't found.
*/
function getNonWhiteSpaceSibling(node, next, inc) {
if (node) {
next = next ? 'nextSibling' : 'previousSibling';
for (node = inc ? node : node[next]; node; node = node[next]) {
if (node.nodeType == 1 || !isWhiteSpaceNode(node)) {
return node;
}
}
}
}
/**
* Checks if the specified node is a bookmark node or not.
*
* @private
* @param {Node} node Node to check if it's a bookmark node or not.
* @return {Boolean} true/false if the node is a bookmark node.
*/
function isBookmarkNode(node) {
return node && node.nodeType == 1 && node.getAttribute('data-mce-type') == 'bookmark';
}
/**
* Merges the next/previous sibling element if they match.
*
* @private
* @param {Node} prev Previous node to compare/merge.
* @param {Node} next Next node to compare/merge.
* @return {Node} Next node if we didn't merge and prev node if we did.
*/
function mergeSiblings(prev, next) {
var sibling, tmpSibling;
/**
* Compares two nodes and checks if it's attributes and styles matches.
* This doesn't compare classes as items since their order is significant.
*
* @private
* @param {Node} node1 First node to compare with.
* @param {Node} node2 Second node to compare with.
* @return {boolean} True/false if the nodes are the same or not.
*/
function compareElements(node1, node2) {
// Not the same name
if (node1.nodeName != node2.nodeName) {
return FALSE;
}
/**
* Returns all the nodes attributes excluding internal ones, styles and classes.
*
* @private
* @param {Node} node Node to get attributes from.
* @return {Object} Name/value object with attributes and attribute values.
*/
function getAttribs(node) {
var attribs = {};
each(dom.getAttribs(node), function(attr) {
var name = attr.nodeName.toLowerCase();
// Don't compare internal attributes or style
if (name.indexOf('_') !== 0 && name !== 'style') {
attribs[name] = dom.getAttrib(node, name);
}
});
return attribs;
}
/**
* Compares two objects checks if it's key + value exists in the other one.
*
* @private
* @param {Object} obj1 First object to compare.
* @param {Object} obj2 Second object to compare.
* @return {boolean} True/false if the objects matches or not.
*/
function compareObjects(obj1, obj2) {
var value, name;
for (name in obj1) {
// Obj1 has item obj2 doesn't have
if (obj1.hasOwnProperty(name)) {
value = obj2[name];
// Obj2 doesn't have obj1 item
if (value === undef) {
return FALSE;
}
// Obj2 item has a different value
if (obj1[name] != value) {
return FALSE;
}
// Delete similar value
delete obj2[name];
}
}
// Check if obj 2 has something obj 1 doesn't have
for (name in obj2) {
// Obj2 has item obj1 doesn't have
if (obj2.hasOwnProperty(name)) {
return FALSE;
}
}
return TRUE;
}
// Attribs are not the same
if (!compareObjects(getAttribs(node1), getAttribs(node2))) {
return FALSE;
}
// Styles are not the same
if (!compareObjects(dom.parseStyle(dom.getAttrib(node1, 'style')), dom.parseStyle(dom.getAttrib(node2, 'style')))) {
return FALSE;
}
return TRUE;
}
function findElementSibling(node, sibling_name) {
for (sibling = node; sibling; sibling = sibling[sibling_name]) {
if (sibling.nodeType == 3 && sibling.nodeValue.length !== 0) {
return node;
}
if (sibling.nodeType == 1 && !isBookmarkNode(sibling)) {
return sibling;
}
}
return node;
}
// Check if next/prev exists and that they are elements
if (prev && next) {
// If previous sibling is empty then jump over it
prev = findElementSibling(prev, 'previousSibling');
next = findElementSibling(next, 'nextSibling');
// Compare next and previous nodes
if (compareElements(prev, next)) {
// Append nodes between
for (sibling = prev.nextSibling; sibling && sibling != next;) {
tmpSibling = sibling;
sibling = sibling.nextSibling;
prev.appendChild(tmpSibling);
}
// Remove next node
dom.remove(next);
// Move children into prev node
each(grep(next.childNodes), function(node) {
prev.appendChild(node);
});
return prev;
}
}
return next;
}
function getContainer(rng, start) {
var container, offset, lastIdx;
container = rng[start ? 'startContainer' : 'endContainer'];
offset = rng[start ? 'startOffset' : 'endOffset'];
if (container.nodeType == 1) {
lastIdx = container.childNodes.length - 1;
if (!start && offset) {
offset--;
}
container = container.childNodes[offset > lastIdx ? lastIdx : offset];
}
// If start text node is excluded then walk to the next node
if (container.nodeType === 3 && start && offset >= container.nodeValue.length) {
container = new TreeWalker(container, ed.getBody()).next() || container;
}
// If end text node is excluded then walk to the previous node
if (container.nodeType === 3 && !start && offset === 0) {
container = new TreeWalker(container, ed.getBody()).prev() || container;
}
return container;
}
function performCaretAction(type, name, vars) {
var caretContainerId = '_mce_caret', debug = ed.settings.caret_debug;
// Creates a caret container bogus element
function createCaretContainer(fill) {
var caretContainer = dom.create('span', {id: caretContainerId, 'data-mce-bogus': true, style: debug ? 'color:red' : ''});
if (fill) {
caretContainer.appendChild(ed.getDoc().createTextNode(INVISIBLE_CHAR));
}
return caretContainer;
}
function isCaretContainerEmpty(node, nodes) {
while (node) {
if ((node.nodeType === 3 && node.nodeValue !== INVISIBLE_CHAR) || node.childNodes.length > 1) {
return false;
}
// Collect nodes
if (nodes && node.nodeType === 1) {
nodes.push(node);
}
node = node.firstChild;
}
return true;
}
// Returns any parent caret container element
function getParentCaretContainer(node) {
while (node) {
if (node.id === caretContainerId) {
return node;
}
node = node.parentNode;
}
}
// Finds the first text node in the specified node
function findFirstTextNode(node) {
var walker;
if (node) {
walker = new TreeWalker(node, node);
for (node = walker.current(); node; node = walker.next()) {
if (node.nodeType === 3) {
return node;
}
}
}
}
// Removes the caret container for the specified node or all on the current document
function removeCaretContainer(node, move_caret) {
var child, rng;
if (!node) {
node = getParentCaretContainer(selection.getStart());
if (!node) {
while ((node = dom.get(caretContainerId))) {
removeCaretContainer(node, false);
}
}
} else {
rng = selection.getRng(true);
if (isCaretContainerEmpty(node)) {
if (move_caret !== false) {
rng.setStartBefore(node);
rng.setEndBefore(node);
}
dom.remove(node);
} else {
child = findFirstTextNode(node);
if (child.nodeValue.charAt(0) === INVISIBLE_CHAR) {
child = child.deleteData(0, 1);
}
dom.remove(node, 1);
}
selection.setRng(rng);
}
}
// Applies formatting to the caret postion
function applyCaretFormat() {
var rng, caretContainer, textNode, offset, bookmark, container, text;
rng = selection.getRng(true);
offset = rng.startOffset;
container = rng.startContainer;
text = container.nodeValue;
caretContainer = getParentCaretContainer(selection.getStart());
if (caretContainer) {
textNode = findFirstTextNode(caretContainer);
}
// Expand to word is caret is in the middle of a text node and the char before/after is a alpha numeric character
if (text && offset > 0 && offset < text.length && /\w/.test(text.charAt(offset)) && /\w/.test(text.charAt(offset - 1))) {
// Get bookmark of caret position
bookmark = selection.getBookmark();
// Collapse bookmark range (WebKit)
rng.collapse(true);
// Expand the range to the closest word and split it at those points
rng = expandRng(rng, get(name));
rng = rangeUtils.split(rng);
// Apply the format to the range
apply(name, vars, rng);
// Move selection back to caret position
selection.moveToBookmark(bookmark);
} else {
if (!caretContainer || textNode.nodeValue !== INVISIBLE_CHAR) {
caretContainer = createCaretContainer(true);
textNode = caretContainer.firstChild;
rng.insertNode(caretContainer);
offset = 1;
apply(name, vars, caretContainer);
} else {
apply(name, vars, caretContainer);
}
// Move selection to text node
selection.setCursorLocation(textNode, offset);
}
}
function removeCaretFormat() {
var rng = selection.getRng(true), container, offset, bookmark,
hasContentAfter, node, formatNode, parents = [], i, caretContainer;
container = rng.startContainer;
offset = rng.startOffset;
node = container;
if (container.nodeType == 3) {
if (offset != container.nodeValue.length || container.nodeValue === INVISIBLE_CHAR) {
hasContentAfter = true;
}
node = node.parentNode;
}
while (node) {
if (matchNode(node, name, vars)) {
formatNode = node;
break;
}
if (node.nextSibling) {
hasContentAfter = true;
}
parents.push(node);
node = node.parentNode;
}
// Node doesn't have the specified format
if (!formatNode) {
return;
}
// Is there contents after the caret then remove the format on the element
if (hasContentAfter) {
// Get bookmark of caret position
bookmark = selection.getBookmark();
// Collapse bookmark range (WebKit)
rng.collapse(true);
// Expand the range to the closest word and split it at those points
rng = expandRng(rng, get(name), true);
rng = rangeUtils.split(rng);
// Remove the format from the range
remove(name, vars, rng);
// Move selection back to caret position
selection.moveToBookmark(bookmark);
} else {
caretContainer = createCaretContainer();
node = caretContainer;
for (i = parents.length - 1; i >= 0; i--) {
node.appendChild(dom.clone(parents[i], false));
node = node.firstChild;
}
// Insert invisible character into inner most format element
node.appendChild(dom.doc.createTextNode(INVISIBLE_CHAR));
node = node.firstChild;
var block = dom.getParent(formatNode, isTextBlock);
if (block && dom.isEmpty(block)) {
// Replace formatNode with caretContainer when removing format from empty block like <p><b>|</b></p>
formatNode.parentNode.replaceChild(caretContainer, formatNode);
} else {
// Insert caret container after the formated node
dom.insertAfter(caretContainer, formatNode);
}
// Move selection to text node
selection.setCursorLocation(node, 1);
}
}
// Checks if the parent caret container node isn't empty if that is the case it
// will remove the bogus state on all children that isn't empty
function unmarkBogusCaretParents() {
var caretContainer;
caretContainer = getParentCaretContainer(selection.getStart());
if (caretContainer && !dom.isEmpty(caretContainer)) {
walk(caretContainer, function(node) {
if (node.nodeType == 1 && node.id !== caretContainerId && !dom.isEmpty(node)) {
dom.setAttrib(node, 'data-mce-bogus', null);
}
}, 'childNodes');
}
}
// Only bind the caret events once
if (!ed._hasCaretEvents) {
// Mark current caret container elements as bogus when getting the contents so we don't end up with empty elements
markCaretContainersBogus = function() {
var nodes = [], i;
if (isCaretContainerEmpty(getParentCaretContainer(selection.getStart()), nodes)) {
// Mark children
i = nodes.length;
while (i--) {
dom.setAttrib(nodes[i], 'data-mce-bogus', '1');
}
}
};
disableCaretContainer = function(e) {
var keyCode = e.keyCode;
removeCaretContainer();
// Remove caret container on keydown and it's a backspace, enter or left/right arrow keys
if (keyCode == 8 || keyCode == 37 || keyCode == 39) {
removeCaretContainer(getParentCaretContainer(selection.getStart()));
}
unmarkBogusCaretParents();
};
// Remove bogus state if they got filled by contents using editor.selection.setContent
ed.on('SetContent', function(e) {
if (e.selection) {
unmarkBogusCaretParents();
}
});
ed._hasCaretEvents = true;
}
// Do apply or remove caret format
if (type == "apply") {
applyCaretFormat();
} else {
removeCaretFormat();
}
}
/**
* Moves the start to the first suitable text node.
*/
function moveStart(rng) {
var container = rng.startContainer,
offset = rng.startOffset, isAtEndOfText,
walker, node, nodes, tmpNode;
// Convert text node into index if possible
if (container.nodeType == 3 && offset >= container.nodeValue.length) {
// Get the parent container location and walk from there
offset = nodeIndex(container);
container = container.parentNode;
isAtEndOfText = true;
}
// Move startContainer/startOffset in to a suitable node
if (container.nodeType == 1) {
nodes = container.childNodes;
container = nodes[Math.min(offset, nodes.length - 1)];
walker = new TreeWalker(container, dom.getParent(container, dom.isBlock));
// If offset is at end of the parent node walk to the next one
if (offset > nodes.length - 1 || isAtEndOfText) {
walker.next();
}
for (node = walker.current(); node; node = walker.next()) {
if (node.nodeType == 3 && !isWhiteSpaceNode(node)) {
// IE has a "neat" feature where it moves the start node into the closest element
// we can avoid this by inserting an element before it and then remove it after we set the selection
tmpNode = dom.create('a', null, INVISIBLE_CHAR);
node.parentNode.insertBefore(tmpNode, node);
// Set selection and remove tmpNode
rng.setStart(node, 0);
selection.setRng(rng);
dom.remove(tmpNode);
return;
}
}
}
}
};
});