window.wp = window.wp || {};
( function( $, wp ) {
wp.editor = wp.editor || {};
/**
* @summary Utility functions for the editor.
*
* @since 2.5.0
*/
function SwitchEditors() {
var tinymce, $$,
exports = {};
function init() {
if ( ! tinymce && window.tinymce ) {
tinymce = window.tinymce;
$$ = tinymce.$;
/**
* @summary Handles onclick events for the Visual/Text tabs.
*
* @since 4.3.0
*
* @returns {void}
*/
$$( document ).on( 'click', function( event ) {
var id, mode,
target = $$( event.target );
if ( target.hasClass( 'wp-switch-editor' ) ) {
id = target.attr( 'data-wp-editor-id' );
mode = target.hasClass( 'switch-tmce' ) ? 'tmce' : 'html';
switchEditor( id, mode );
}
});
}
}
/**
* @summary Returns the height of the editor toolbar(s) in px.
*
* @since 3.9.0
*
* @param {Object} editor The TinyMCE editor.
* @returns {number} If the height is between 10 and 200 return the height,
* else return 30.
*/
function getToolbarHeight( editor ) {
var node = $$( '.mce-toolbar-grp', editor.getContainer() )[0],
height = node && node.clientHeight;
if ( height && height > 10 && height < 200 ) {
return parseInt( height, 10 );
}
return 30;
}
/**
* @summary Switches the editor between Visual and Text mode.
*
* @since 2.5.0
*
* @memberof switchEditors
*
* @param {string} id The id of the editor you want to change the editor mode for. Default: `content`.
* @param {string} mode The mode you want to switch to. Default: `toggle`.
* @returns {void}
*/
function switchEditor( id, mode ) {
id = id || 'content';
mode = mode || 'toggle';
var editorHeight, toolbarHeight, iframe,
editor = tinymce.get( id ),
wrap = $$( '#wp-' + id + '-wrap' ),
$textarea = $$( '#' + id ),
textarea = $textarea[0];
if ( 'toggle' === mode ) {
if ( editor && ! editor.isHidden() ) {
mode = 'html';
} else {
mode = 'tmce';
}
}
if ( 'tmce' === mode || 'tinymce' === mode ) {
// If the editor is visible we are already in `tinymce` mode.
if ( editor && ! editor.isHidden() ) {
return false;
}
// Insert closing tags for any open tags in QuickTags.
if ( typeof( window.QTags ) !== 'undefined' ) {
window.QTags.closeAllTags( id );
}
editorHeight = parseInt( textarea.style.height, 10 ) || 0;
var keepSelection = false;
if ( editor ) {
keepSelection = editor.getParam( 'wp_keep_scroll_position' );
} else {
keepSelection = window.tinyMCEPreInit.mceInit[ id ] &&
window.tinyMCEPreInit.mceInit[ id ].wp_keep_scroll_position;
}
if ( keepSelection ) {
// Save the selection
addHTMLBookmarkInTextAreaContent( $textarea );
}
if ( editor ) {
editor.show();
// No point to resize the iframe in iOS.
if ( ! tinymce.Env.iOS && editorHeight ) {
toolbarHeight = getToolbarHeight( editor );
editorHeight = editorHeight - toolbarHeight + 14;
// Sane limit for the editor height.
if ( editorHeight > 50 && editorHeight < 5000 ) {
editor.theme.resizeTo( null, editorHeight );
}
}
if ( editor.getParam( 'wp_keep_scroll_position' ) ) {
// Restore the selection
focusHTMLBookmarkInVisualEditor( editor );
}
} else {
tinymce.init( window.tinyMCEPreInit.mceInit[ id ] );
}
wrap.removeClass( 'html-active' ).addClass( 'tmce-active' );
$textarea.attr( 'aria-hidden', true );
window.setUserSetting( 'editor', 'tinymce' );
} else if ( 'html' === mode ) {
// If the editor is hidden (Quicktags is shown) we don't need to switch.
if ( editor && editor.isHidden() ) {
return false;
}
if ( editor ) {
// Don't resize the textarea in iOS. The iframe is forced to 100% height there, we shouldn't match it.
if ( ! tinymce.Env.iOS ) {
iframe = editor.iframeElement;
editorHeight = iframe ? parseInt( iframe.style.height, 10 ) : 0;
if ( editorHeight ) {
toolbarHeight = getToolbarHeight( editor );
editorHeight = editorHeight + toolbarHeight - 14;
// Sane limit for the textarea height.
if ( editorHeight > 50 && editorHeight < 5000 ) {
textarea.style.height = editorHeight + 'px';
}
}
}
var selectionRange = null;
if ( editor.getParam( 'wp_keep_scroll_position' ) ) {
selectionRange = findBookmarkedPosition( editor );
}
editor.hide();
if ( selectionRange ) {
selectTextInTextArea( editor, selectionRange );
}
} else {
// There is probably a JS error on the page. The TinyMCE editor instance doesn't exist. Show the textarea.
$textarea.css({ 'display': '', 'visibility': '' });
}
wrap.removeClass( 'tmce-active' ).addClass( 'html-active' );
$textarea.attr( 'aria-hidden', false );
window.setUserSetting( 'editor', 'html' );
}
}
/**
* @summary Checks if a cursor is inside an HTML tag.
*
* In order to prevent breaking HTML tags when selecting text, the cursor
* must be moved to either the start or end of the tag.
*
* This will prevent the selection marker to be inserted in the middle of an HTML tag.
*
* This function gives information whether the cursor is inside a tag or not, as well as
* the tag type, if it is a closing tag and check if the HTML tag is inside a shortcode tag,
* e.g. `[caption]
)(https?:\\/\\/[^\s"]+?)(<\\/p>\s*|[\\n\\r][\\n\\r]|$)', 'gi'
);
while ( shortcodeMatch = urlRegexp.exec( content ) ) {
shortcodeInfo = {
shortcodeName: 'url',
showAsPlainText: false,
startIndex: shortcodeMatch.index,
endIndex: shortcodeMatch.index + shortcodeMatch[ 0 ].length,
length: shortcodeMatch[ 0 ].length,
isPreviewable: true,
urlAtStartOfContent: shortcodeMatch[ 1 ] === '',
urlAtEndOfContent: shortcodeMatch[ 3 ] === ''
};
shortcodesDetails.push( shortcodeInfo );
}
return shortcodesDetails;
}
/**
* Generate a cursor marker element to be inserted in the content.
*
* `span` seems to be the least destructive element that can be used.
*
* Using DomQuery syntax to create it, since it's used as both text and as a DOM element.
*
* @param {Object} domLib DOM library instance.
* @param {string} content The content to insert into the cusror marker element.
*/
function getCursorMarkerSpan( domLib, content ) {
return domLib( '' ).css( {
display: 'inline-block',
width: 0,
overflow: 'hidden',
'line-height': 0
} )
.html( content ? content : '' );
}
/**
* @summary Get adjusted selection cursor positions according to HTML tags/shortcodes
*
* Shortcodes and HTML codes are a bit of a special case when selecting, since they may render
* content in Visual mode. If we insert selection markers somewhere inside them, it's really possible
* to break the syntax and render the HTML tag or shortcode broken.
*
* @link getShortcodeWrapperInfo
*
* @param {string} content Textarea content that the cursors are in
* @param {{cursorStart: number, cursorEnd: number}} cursorPositions Cursor start and end positions
*
* @return {{cursorStart: number, cursorEnd: number}}
*/
function adjustTextAreaSelectionCursors( content, cursorPositions ) {
var voidElements = [
'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
'keygen', 'link', 'meta', 'param', 'source', 'track', 'wbr'
];
var cursorStart = cursorPositions.cursorStart,
cursorEnd = cursorPositions.cursorEnd,
// check if the cursor is in a tag and if so, adjust it
isCursorStartInTag = getContainingTagInfo( content, cursorStart );
if ( isCursorStartInTag ) {
/**
* Only move to the start of the HTML tag (to select the whole element) if the tag
* is part of the voidElements list above.
*
* This list includes tags that are self-contained and don't need a closing tag, according to the
* HTML5 specification.
*
* This is done in order to make selection of text a bit more consistent when selecting text in
* ` ` tags or such.
*
* In cases where the tag is not a void element, the cursor is put to the end of the tag,
* so it's either between the opening and closing tag elements or after the closing tag.
*/
if ( voidElements.indexOf( isCursorStartInTag.tagType ) !== -1 ) {
cursorStart = isCursorStartInTag.ltPos;
} else {
cursorStart = isCursorStartInTag.gtPos;
}
}
var isCursorEndInTag = getContainingTagInfo( content, cursorEnd );
if ( isCursorEndInTag ) {
cursorEnd = isCursorEndInTag.gtPos;
}
var isCursorStartInShortcode = getShortcodeWrapperInfo( content, cursorStart );
if ( isCursorStartInShortcode && isCursorStartInShortcode.isPreviewable ) {
/**
* If a URL is at the start or the end of the content,
* the selection doesn't work, because it inserts a marker in the text,
* which breaks the embedURL detection.
*
* The best way to avoid that and not modify the user content is to
* adjust the cursor to either after or before URL.
*/
if ( isCursorStartInShortcode.urlAtStartOfContent ) {
cursorStart = isCursorStartInShortcode.endIndex;
} else {
cursorStart = isCursorStartInShortcode.startIndex;
}
}
var isCursorEndInShortcode = getShortcodeWrapperInfo( content, cursorEnd );
if ( isCursorEndInShortcode && isCursorEndInShortcode.isPreviewable ) {
if ( isCursorEndInShortcode.urlAtEndOfContent ) {
cursorEnd = isCursorEndInShortcode.startIndex;
} else {
cursorEnd = isCursorEndInShortcode.endIndex;
}
}
return {
cursorStart: cursorStart,
cursorEnd: cursorEnd
};
}
/**
* @summary Adds text selection markers in the editor textarea.
*
* Adds selection markers in the content of the editor `textarea`.
* The method directly manipulates the `textarea` content, to allow TinyMCE plugins
* to run after the markers are added.
*
* @param {object} $textarea TinyMCE's textarea wrapped as a DomQuery object
*/
function addHTMLBookmarkInTextAreaContent( $textarea ) {
if ( ! $textarea || ! $textarea.length ) {
// If no valid $textarea object is provided, there's nothing we can do.
return;
}
var textArea = $textarea[0],
textAreaContent = textArea.value,
adjustedCursorPositions = adjustTextAreaSelectionCursors( textAreaContent, {
cursorStart: textArea.selectionStart,
cursorEnd: textArea.selectionEnd
} ),
htmlModeCursorStartPosition = adjustedCursorPositions.cursorStart,
htmlModeCursorEndPosition = adjustedCursorPositions.cursorEnd,
mode = htmlModeCursorStartPosition !== htmlModeCursorEndPosition ? 'range' : 'single',
selectedText = null,
cursorMarkerSkeleton = getCursorMarkerSpan( $$, '' ).attr( 'data-mce-type','bookmark' );
if ( mode === 'range' ) {
var markedText = textArea.value.slice( htmlModeCursorStartPosition, htmlModeCursorEndPosition ),
bookMarkEnd = cursorMarkerSkeleton.clone().addClass( 'mce_SELRES_end' );
selectedText = [
markedText,
bookMarkEnd[0].outerHTML
].join( '' );
}
textArea.value = [
textArea.value.slice( 0, htmlModeCursorStartPosition ), // text until the cursor/selection position
cursorMarkerSkeleton.clone() // cursor/selection start marker
.addClass( 'mce_SELRES_start' )[0].outerHTML,
selectedText, // selected text with end cursor/position marker
textArea.value.slice( htmlModeCursorEndPosition ) // text from last cursor/selection position to end
].join( '' );
}
/**
* @summary Focus the selection markers in Visual mode.
*
* The method checks for existing selection markers inside the editor DOM (Visual mode)
* and create a selection between the two nodes using the DOM `createRange` selection API
*
* If there is only a single node, select only the single node through TinyMCE's selection API
*
* @param {Object} editor TinyMCE editor instance.
*/
function focusHTMLBookmarkInVisualEditor( editor ) {
var startNode = editor.$( '.mce_SELRES_start' ).attr( 'data-mce-bogus', 1 ),
endNode = editor.$( '.mce_SELRES_end' ).attr( 'data-mce-bogus', 1 );
if ( startNode.length ) {
editor.focus();
if ( ! endNode.length ) {
editor.selection.select( startNode[0] );
} else {
var selection = editor.getDoc().createRange();
selection.setStartAfter( startNode[0] );
selection.setEndBefore( endNode[0] );
editor.selection.setRng( selection );
}
}
if ( editor.getParam( 'wp_keep_scroll_position' ) ) {
scrollVisualModeToStartElement( editor, startNode );
}
removeSelectionMarker( startNode );
removeSelectionMarker( endNode );
}
/**
* @summary Remove selection marker and the parent node if it is an empty paragraph.
*
* By default TinyMCE wraps loose inline tags in a ` `.
* When removing selection markers an empty ` ` may be left behind, remove it.
*
* @param {object} $marker The marker to be removed from the editor DOM, wrapped in an instnce of `editor.$`
*/
function removeSelectionMarker( $marker ) {
var $markerParent = $marker.parent();
$marker.remove();
//Remove empty paragraph left over after removing the marker.
if ( $markerParent.is( 'p' ) && ! $markerParent.children().length && ! $markerParent.text() ) {
$markerParent.remove();
}
}
/**
* @summary Scrolls the content to place the selected element in the center of the screen.
*
* Takes an element, that is usually the selection start element, selected in
* `focusHTMLBookmarkInVisualEditor()` and scrolls the screen so the element appears roughly
* in the middle of the screen.
*
* I order to achieve the proper positioning, the editor media bar and toolbar are subtracted
* from the window height, to get the proper viewport window, that the user sees.
*
* @param {Object} editor TinyMCE editor instance.
* @param {Object} element HTMLElement that should be scrolled into view.
*/
function scrollVisualModeToStartElement( editor, element ) {
var elementTop = editor.$( element ).offset().top,
TinyMCEContentAreaTop = editor.$( editor.getContentAreaContainer() ).offset().top,
toolbarHeight = getToolbarHeight( editor ),
edTools = $( '#wp-content-editor-tools' ),
edToolsHeight = 0,
edToolsOffsetTop = 0;
if ( edTools.length ) {
edToolsHeight = edTools.height();
edToolsOffsetTop = edTools.offset().top;
}
var windowHeight = window.innerHeight || document.documentElement.clientHeight || document.body.clientHeight,
selectionPosition = TinyMCEContentAreaTop + elementTop,
visibleAreaHeight = windowHeight - ( edToolsHeight + toolbarHeight );
/**
* The minimum scroll height should be to the top of the editor, to offer a consistent
* experience.
*
* In order to find the top of the editor, we calculate the offset of `#wp-content-editor-tools` and
* subtracting the height. This gives the scroll position where the top of the editor tools aligns with
* the top of the viewport (under the Master Bar)
*/
var adjustedScroll = Math.max( selectionPosition - visibleAreaHeight / 2, edToolsOffsetTop - edToolsHeight );
$( 'html,body' ).animate( {
scrollTop: parseInt( adjustedScroll, 10 )
}, 100 );
}
/**
* This method was extracted from the `SaveContent` hook in
* `wp-includes/js/tinymce/plugins/wordpress/plugin.js`.
*
* It's needed here, since the method changes the content a bit, which confuses the cursor position.
*
* @param {Object} event TinyMCE event object.
*/
function fixTextAreaContent( event ) {
// Keep empty paragraphs :(
event.content = event.content.replace( / (?: tags with two line breaks. "Opposite" of wpautop().
*
* Replaces tags with two line breaks except where the has attributes.
* Unifies whitespace.
* Indents
|\u00a0|\uFEFF| )*<\/p>/g, '