mirror of
https://github.com/fastmail/Squire.git
synced 2024-12-22 15:23:29 -05:00
aa8b4b7ac6
And make propagating native events more elegant (only adds observer to document when first function added).
1504 lines
No EOL
50 KiB
JavaScript
1504 lines
No EOL
50 KiB
JavaScript
/* Copyright © 2011 by Neil Jenkins. Licensed under the MIT license. */
|
|
|
|
/*global Range, window, document, setTimeout */
|
|
|
|
document.addEventListener( 'DOMContentLoaded', function () {
|
|
|
|
"use strict";
|
|
|
|
// --- Constants ---
|
|
|
|
var DOCUMENT_POSITION_PRECEDING = 2, // Node.DOCUMENT_POSITION_PRECEDING
|
|
ELEMENT_NODE = 1, // Node.ELEMENT_NODE,
|
|
TEXT_NODE = 3, // Node.TEXT_NODE,
|
|
SHOW_TEXT = 4, // NodeFilter.SHOW_TEXT,
|
|
SHOW_ELEMENT = 1, // NodeFilter.SHOW_ELEMENT,
|
|
FILTER_ACCEPT = 1, // NodeFilter.FILTER_ACCEPT,
|
|
FILTER_SKIP = 3; // NodeFilter.FILTER_SKIP;
|
|
|
|
var doc = document,
|
|
win = doc.defaultView,
|
|
body = doc.body;
|
|
|
|
var isOpera = !!win.opera;
|
|
var isIE = !!win.ie;
|
|
var useTextFixer = isOpera || isOpera;
|
|
|
|
// --- DOM Sugar ---
|
|
|
|
var createElement = function ( tag, props, children ) {
|
|
var el = doc.createElement( tag ),
|
|
attr, i, l;
|
|
if ( props instanceof Array ) {
|
|
children = props;
|
|
props = null;
|
|
}
|
|
if ( props ) {
|
|
for ( attr in props ) {
|
|
el.setAttribute( attr, props[ attr ] );
|
|
}
|
|
}
|
|
if ( children ) {
|
|
for ( i = 0, l = children.length; i < l; i += 1 ) {
|
|
el.appendChild( children[i] );
|
|
}
|
|
}
|
|
return el;
|
|
};
|
|
|
|
// --- Events ---
|
|
|
|
var events = {},
|
|
customEvents = {
|
|
cut: 1, paste: 1, focus: 1, blur: 1,
|
|
pathChange: 1, select: 1, input: 1, undoStateChange: 1
|
|
};
|
|
|
|
var fireEvent = function ( type, event ) {
|
|
var handlers = events[ type ],
|
|
l, obj;
|
|
if ( handlers ) {
|
|
if ( typeof event !== 'object' ) {
|
|
event = {
|
|
data: event
|
|
};
|
|
}
|
|
if ( event.type !== type ) {
|
|
event.type = type;
|
|
}
|
|
l = handlers.length;
|
|
while ( l-- ) {
|
|
obj = handlers[l];
|
|
if ( obj.handleEvent ) {
|
|
obj.handleEvent( event );
|
|
} else {
|
|
obj( event );
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
var propagateEvent = function ( event ) {
|
|
fireEvent( event.type, event );
|
|
};
|
|
|
|
var addEventListener = function ( type, fn ) {
|
|
var handlers = events[ type ];
|
|
if ( !handlers ) {
|
|
handlers = events[ type ] = [];
|
|
if ( !customEvents[ type ] ) {
|
|
doc.addEventListener( type, propagateEvent, false );
|
|
}
|
|
}
|
|
handlers.push( fn );
|
|
};
|
|
|
|
var removeEventListener = function ( type, fn ) {
|
|
var handlers = events[ type ],
|
|
l;
|
|
if ( handlers ) {
|
|
l = handlers.length;
|
|
while ( l-- ) {
|
|
if ( handlers[l] === fn ) {
|
|
handlers.splice( l, 1 );
|
|
}
|
|
}
|
|
if ( !handlers.length ) {
|
|
delete events[ type ];
|
|
if ( !customEvents[ type ] ) {
|
|
doc.removeEventListener( type, propagateEvent, false );
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
// --- Selection and Path ---
|
|
|
|
var createRange = function ( range, startOffset, endContainer, endOffset ) {
|
|
if ( range instanceof Range ) {
|
|
return range.cloneRange();
|
|
}
|
|
var domRange = doc.createRange();
|
|
domRange.setStart( range, startOffset );
|
|
if ( endContainer ) {
|
|
domRange.setEnd( endContainer, endOffset );
|
|
} else {
|
|
domRange.setEnd( range, startOffset );
|
|
}
|
|
return domRange;
|
|
};
|
|
|
|
var sel = win.getSelection();
|
|
|
|
var lastSelection = null;
|
|
var getSelection = function () {
|
|
if ( sel.rangeCount ) {
|
|
lastSelection =
|
|
sel.getRangeAt( 0 ).cloneRange();
|
|
}
|
|
return lastSelection;
|
|
};
|
|
|
|
// IE9 loses selection state of iframe on blur, so make sure we
|
|
// cache it just before it loses focus.
|
|
if ( win.ie ) {
|
|
win.addEventListener( 'beforedeactivate', getSelection, true );
|
|
}
|
|
|
|
var lastAnchorNode;
|
|
var lastFocusNode;
|
|
var path = '';
|
|
|
|
var updatePath = function ( force ) {
|
|
var anchor = sel.anchorNode,
|
|
focus = sel.focusNode,
|
|
newPath;
|
|
if ( force || anchor !== lastAnchorNode || focus !== lastFocusNode ) {
|
|
lastAnchorNode = anchor;
|
|
lastFocusNode = focus;
|
|
newPath = ( anchor && focus ) ? ( anchor === focus ) ?
|
|
focus.getPath() : '(selection)' : '';
|
|
if ( path !== newPath ) {
|
|
path = newPath;
|
|
fireEvent( 'pathChange', newPath );
|
|
}
|
|
}
|
|
if ( anchor !== focus ) {
|
|
fireEvent( 'select' );
|
|
}
|
|
};
|
|
addEventListener( 'keyup', updatePath );
|
|
addEventListener( 'mouseup', updatePath );
|
|
|
|
var setSelection = function ( range ) {
|
|
if ( range ) {
|
|
sel.removeAllRanges();
|
|
sel.addRange( range );
|
|
}
|
|
updatePath();
|
|
};
|
|
|
|
// --- Focus ---
|
|
|
|
var focus = function () {
|
|
win.focus();
|
|
};
|
|
|
|
var blur = function () {
|
|
win.blur();
|
|
};
|
|
|
|
win.addEventListener( 'focus', propagateEvent, false );
|
|
win.addEventListener( 'blur', propagateEvent, false );
|
|
|
|
// --- Get/Set data ---
|
|
|
|
var getHTML = function () {
|
|
return body.innerHTML;
|
|
};
|
|
|
|
var setHTML = function ( html ) {
|
|
body.innerHTML = html;
|
|
body.fixCursor();
|
|
};
|
|
|
|
var insertElement = function ( el, range ) {
|
|
if ( !range ) { range = getSelection(); }
|
|
range.collapse( true );
|
|
range._insertNode( el );
|
|
range.setStartAfter( el );
|
|
setSelection( range );
|
|
};
|
|
|
|
// --- Bookmarking ---
|
|
|
|
var startSelectionId = 'ss-' + Date.now() + '-' + Math.random();
|
|
var endSelectionId = 'es-' + Date.now() + '-' + Math.random();
|
|
|
|
var saveRangeToBookmark = function ( range ) {
|
|
var startNode = createElement( 'INPUT', {
|
|
id: startSelectionId,
|
|
type: 'hidden'
|
|
}),
|
|
endNode = createElement( 'INPUT', {
|
|
id: endSelectionId,
|
|
type: 'hidden'
|
|
}),
|
|
temp;
|
|
|
|
range._insertNode( startNode );
|
|
range.collapse( false );
|
|
range._insertNode( endNode );
|
|
|
|
// In a collapsed range, the start is sometimes inserted after the end!
|
|
if ( startNode.compareDocumentPosition( endNode ) &
|
|
DOCUMENT_POSITION_PRECEDING ) {
|
|
startNode.id = endSelectionId;
|
|
endNode.id = startSelectionId;
|
|
temp = startNode;
|
|
startNode = endNode;
|
|
endNode = temp;
|
|
}
|
|
|
|
range.setStartAfter( startNode );
|
|
range.setEndBefore( endNode );
|
|
};
|
|
|
|
var indexOf = Array.prototype.indexOf;
|
|
|
|
var getRangeAndRemoveBookmark = function ( range ) {
|
|
var start = doc.getElementById( startSelectionId ),
|
|
end = doc.getElementById( endSelectionId );
|
|
|
|
if ( start && end ) {
|
|
var startContainer = start.parentNode,
|
|
endContainer = end.parentNode;
|
|
|
|
var _range = {
|
|
startContainer: startContainer,
|
|
endContainer: endContainer,
|
|
startOffset: indexOf.call( startContainer.childNodes, start ),
|
|
endOffset: indexOf.call( endContainer.childNodes, end )
|
|
};
|
|
|
|
if ( startContainer === endContainer ) {
|
|
_range.endOffset -= 1;
|
|
}
|
|
|
|
start.detach();
|
|
end.detach();
|
|
|
|
// Merge any text nodes we split
|
|
startContainer.mergeInlines( _range );
|
|
if ( startContainer !== endContainer ) {
|
|
endContainer.mergeInlines( _range );
|
|
}
|
|
|
|
if ( !range ) {
|
|
range = doc.createRange();
|
|
}
|
|
range.setStart( _range.startContainer, _range.startOffset );
|
|
range.setEnd( _range.endContainer, _range.endOffset );
|
|
|
|
if ( !range.collapsed ) {
|
|
range.moveBoundariesDownTree();
|
|
}
|
|
}
|
|
return range;
|
|
};
|
|
|
|
// --- Undo ---
|
|
|
|
var undoIndex, // = -1,
|
|
undoStack, // = [],
|
|
undoStackLength, // = 0,
|
|
isInUndoState, // = false,
|
|
docWasChanged = function docWasChanged () {
|
|
if ( isInUndoState ) {
|
|
isInUndoState = false;
|
|
fireEvent( 'undoStateChange', {
|
|
canUndo: true,
|
|
canRedo: false
|
|
});
|
|
}
|
|
fireEvent( 'input' );
|
|
};
|
|
|
|
body.addEventListener( 'DOMCharacterDataModified', docWasChanged, false );
|
|
|
|
// Leaves bookmark
|
|
var recordUndoState = function ( range ) {
|
|
// Don't record if we're already in an undo state
|
|
if ( !isInUndoState ) {
|
|
// Advance pointer to new position
|
|
undoIndex += 1;
|
|
|
|
// Truncate stack if longer (i.e. if has been previously undone)
|
|
if ( undoIndex < undoStackLength) {
|
|
undoStack.length = undoStackLength = undoIndex;
|
|
}
|
|
|
|
// Write out data
|
|
if ( range ) {
|
|
saveRangeToBookmark( range );
|
|
}
|
|
undoStack[ undoIndex ] = getHTML();
|
|
undoStackLength += 1;
|
|
isInUndoState = true;
|
|
}
|
|
};
|
|
|
|
var undo = function () {
|
|
// Sanity check: must not be at beginning of the history stack
|
|
if ( undoIndex !== 0 || !isInUndoState ) {
|
|
// Make sure any changes since last checkpoint are saved.
|
|
recordUndoState( getSelection() );
|
|
|
|
undoIndex -= 1;
|
|
setHTML( undoStack[ undoIndex ] );
|
|
var range = getRangeAndRemoveBookmark();
|
|
if ( range ) {
|
|
setSelection( range );
|
|
}
|
|
isInUndoState = true;
|
|
fireEvent( 'undoStateChange', {
|
|
canUndo: undoIndex !== 0,
|
|
canRedo: true
|
|
});
|
|
fireEvent( 'input' );
|
|
}
|
|
};
|
|
|
|
var redo = function () {
|
|
// Sanity check: must not be at end of stack and must be in an undo
|
|
// state.
|
|
if ( undoIndex + 1 < undoStackLength && isInUndoState ) {
|
|
undoIndex += 1;
|
|
setHTML( undoStack[ undoIndex ] );
|
|
var range = getRangeAndRemoveBookmark();
|
|
if ( range ) {
|
|
setSelection( range );
|
|
}
|
|
fireEvent( 'undoStateChange', {
|
|
canUndo: true,
|
|
canRedo: undoIndex + 1 < undoStackLength
|
|
});
|
|
fireEvent( 'input' );
|
|
}
|
|
};
|
|
|
|
// --- Inline formatting ---
|
|
|
|
// Looks for matching tag and attributes, so won't work
|
|
// if <strong> instead of <b> etc.
|
|
var hasFormat = function ( tag, attributes, range ) {
|
|
// 1. Normalise the arguments and get selection
|
|
tag = tag.toUpperCase();
|
|
if ( !attributes ) { attributes = {}; }
|
|
if ( !range && !( range = getSelection() ) ) {
|
|
return false;
|
|
}
|
|
|
|
// If the common ancestor is inside the tag we require, we definitely
|
|
// have the format.
|
|
var root = range.commonAncestorContainer,
|
|
walker, node;
|
|
if ( root.nearest( tag, attributes ) ) {
|
|
return true;
|
|
}
|
|
|
|
// If common ancestor is a text node and doesn't have the format, we
|
|
// definitely don't have it.
|
|
if ( root.nodeType === TEXT_NODE ) {
|
|
return false;
|
|
}
|
|
|
|
// Otherwise, check each text node at least partially contained within
|
|
// the selection and make sure all of them have the format we want.
|
|
walker = doc.createTreeWalker( root, SHOW_TEXT, function ( node ) {
|
|
return range.containsNode( node, true ) ?
|
|
FILTER_ACCEPT : FILTER_SKIP;
|
|
}, false );
|
|
|
|
while ( node = walker.nextNode() ) {
|
|
if ( !node.nearest( tag, attributes ) ) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
};
|
|
|
|
var addFormat = function ( tag, attributes, range ) {
|
|
// If the range is collapsed we simply insert the node by wrapping
|
|
// it round the range and focus it.
|
|
if ( range.collapsed ) {
|
|
var el = createElement( tag, attributes ).fixCursor();
|
|
range._insertNode( el );
|
|
range.selectNodeContents( el );
|
|
}
|
|
// Otherwise we find all the textnodes in the range (splitting
|
|
// partially selected nodes) and if they're not already formatted
|
|
// correctly we wrap them in the appropriate tag.
|
|
else {
|
|
// We don't want to apply formatting twice so we check each text
|
|
// node to see if it has an ancestor with the formatting already.
|
|
// Create an iterator to walk over all the text nodes under this
|
|
// ancestor which are in the range and not already formatted
|
|
// correctly.
|
|
var walker = doc.createTreeWalker(
|
|
range.commonAncestorContainer,
|
|
SHOW_TEXT,
|
|
function ( node ) {
|
|
return range.containsNode( node, true ) ?
|
|
FILTER_ACCEPT : FILTER_SKIP;
|
|
}, false );
|
|
|
|
// Start at the beginning node of the range and iterate through
|
|
// all the nodes in the range that need formatting.
|
|
var startContainer,
|
|
endContainer,
|
|
startOffset = 0,
|
|
endOffset = 0,
|
|
textnode = walker.currentNode = range.startContainer,
|
|
needsFormat;
|
|
|
|
if ( textnode.nodeType !== TEXT_NODE ) {
|
|
textnode = walker.nextNode();
|
|
}
|
|
|
|
do {
|
|
needsFormat = !textnode.nearest( tag, attributes );
|
|
if ( textnode === range.endContainer ) {
|
|
if ( needsFormat && textnode.length > range.endOffset ) {
|
|
textnode.splitText( range.endOffset );
|
|
} else {
|
|
endOffset = range.endOffset;
|
|
}
|
|
}
|
|
if ( textnode === range.startContainer ) {
|
|
if ( needsFormat && range.startOffset ) {
|
|
textnode = textnode.splitText( range.startOffset );
|
|
} else {
|
|
startOffset = range.startOffset;
|
|
}
|
|
}
|
|
if ( needsFormat ) {
|
|
createElement( tag, attributes ).wraps( textnode );
|
|
endOffset = textnode.length;
|
|
}
|
|
endContainer = textnode;
|
|
if ( !startContainer ) { startContainer = endContainer; }
|
|
} while ( textnode = walker.nextNode() );
|
|
|
|
// Now set the selection to as it was before
|
|
range = createRange(
|
|
startContainer, startOffset, endContainer, endOffset );
|
|
}
|
|
return range;
|
|
};
|
|
|
|
var removeFormat = function ( tag, attributes, range ) {
|
|
// Add bookmark
|
|
saveRangeToBookmark( range );
|
|
|
|
// Find block-level ancestor of selection
|
|
var root = range.commonAncestorContainer;
|
|
while ( root.isInline() ) {
|
|
root = root.parentNode;
|
|
}
|
|
|
|
// Find text nodes inside formatTags that are not in selection and
|
|
// add an extra tag with the same formatting.
|
|
var startContainer = range.startContainer,
|
|
startOffset = range.startOffset,
|
|
endContainer = range.endContainer,
|
|
endOffset = range.endOffset,
|
|
toWrap = [],
|
|
examineNode = function examineNode ( node, exemplar ) {
|
|
// If the node is completely contained by the range then
|
|
// we're going to remove all formatting so ignore it.
|
|
if ( range.containsNode( node, false ) ) {
|
|
return;
|
|
}
|
|
|
|
var isText = node.nodeType === TEXT_NODE,
|
|
child, next;
|
|
|
|
// If not at least partially contained, wrap entire contents
|
|
// in a clone of the tag we're removing and we're done.
|
|
if ( !range.containsNode( node, true ) ) {
|
|
// Tidy up as we go...
|
|
if ( isText && !node.length ) {
|
|
node.detach();
|
|
}
|
|
// Ignore bookmarks
|
|
else if ( node.nodeName !== 'INPUT' ) {
|
|
toWrap.push([ exemplar, node ]);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Split any partially selected text nodes.
|
|
if ( isText ) {
|
|
if ( node === endContainer && endOffset !== node.length ) {
|
|
toWrap.push([ exemplar, node.splitText( endOffset ) ]);
|
|
}
|
|
if ( node === startContainer && startOffset ) {
|
|
node.splitText( startOffset );
|
|
toWrap.push([ exemplar, node ]);
|
|
}
|
|
}
|
|
// If not a text node, recurse onto all children.
|
|
// Beware, the tree may be rewritten with each call
|
|
// to examineNode, hence find the next sibling first.
|
|
else {
|
|
for ( child = node.firstChild; child; child = next ) {
|
|
next = child.nextSibling;
|
|
examineNode( child, exemplar );
|
|
}
|
|
}
|
|
},
|
|
formatTags = Array.prototype.filter.call(
|
|
root.getElementsByTagName( tag ), function ( el ) {
|
|
return range.containsNode( el, true ) &&
|
|
el.is( tag, attributes );
|
|
}
|
|
);
|
|
|
|
formatTags.forEach( function ( node ) {
|
|
examineNode( node, node );
|
|
});
|
|
|
|
// Now wrap unselected nodes in the tag
|
|
toWrap.forEach( function ( item ) {
|
|
// [ exemplar, node ] tuple
|
|
item[0].cloneNode( false ).wraps( item[1] );
|
|
});
|
|
// and remove old formatting tags.
|
|
formatTags.forEach( function ( el ) {
|
|
el.replaceWith( el.empty() );
|
|
});
|
|
|
|
// Merge adjacent inlines:
|
|
range = getRangeAndRemoveBookmark();
|
|
var _range = {
|
|
startContainer: range.startContainer,
|
|
startOffset: range.startOffset,
|
|
endContainer: range.endContainer,
|
|
endOffset: range.endOffset
|
|
};
|
|
root.mergeInlines( _range );
|
|
range.setStart( _range.startContainer, _range.startOffset );
|
|
range.setEnd( _range.endContainer, _range.endOffset );
|
|
|
|
return range;
|
|
};
|
|
|
|
var changeFormat = function ( add, remove, range ) {
|
|
// Normalise the arguments and get selection
|
|
if ( !range && !( range = getSelection() ) ) {
|
|
return;
|
|
}
|
|
|
|
// Save undo checkpoint
|
|
recordUndoState( range );
|
|
getRangeAndRemoveBookmark( range );
|
|
|
|
if ( remove ) {
|
|
range = removeFormat( remove.tag.toUpperCase(),
|
|
remove.attributes || {}, range );
|
|
}
|
|
if ( add ) {
|
|
range = addFormat( add.tag.toUpperCase(),
|
|
add.attributes || {}, range );
|
|
}
|
|
|
|
setSelection( range );
|
|
|
|
// We're not still in an undo state
|
|
docWasChanged();
|
|
};
|
|
|
|
// --- Block formatting ---
|
|
|
|
var forEachBlock = function ( fn, range ) {
|
|
if ( !range && !( range = getSelection() ) ) {
|
|
return;
|
|
}
|
|
|
|
// Save undo checkpoint
|
|
recordUndoState( range );
|
|
getRangeAndRemoveBookmark( range );
|
|
|
|
var start = range.getStartBlock(),
|
|
end = range.getEndBlock();
|
|
if ( start && end ) {
|
|
while ( true ) {
|
|
fn( start );
|
|
if ( start === end ) { break; }
|
|
start = start.getNextBlock();
|
|
}
|
|
}
|
|
|
|
// Path may have changed
|
|
updatePath( true );
|
|
|
|
// We're not still in an undo state
|
|
docWasChanged();
|
|
};
|
|
|
|
var modifyBlocks = function ( modify, range ) {
|
|
if ( !range && !( range = getSelection() ) ) {
|
|
return;
|
|
}
|
|
// 1. Stop firefox adding an extra <BR> to <BODY>
|
|
// if we remove everything. Don't want to do this in Opera
|
|
// as it can cause focus problems.
|
|
if ( !isOpera ) {
|
|
body.setAttribute( 'contenteditable', 'false' );
|
|
}
|
|
|
|
// 2. Save undo checkpoint and bookmark selection
|
|
if ( isInUndoState ) {
|
|
saveRangeToBookmark( range );
|
|
} else {
|
|
recordUndoState( range );
|
|
}
|
|
|
|
// 3. Expand range to block boundaries
|
|
range.expandToBlockBoundaries();
|
|
|
|
// 4. Remove range.
|
|
range.moveBoundariesUpTree( body );
|
|
var frag = range._extractContents( body );
|
|
|
|
// 5. Modify tree of fragment and reinsert.
|
|
range._insertNode( modify( frag ) );
|
|
|
|
// 6. Merge containers at edges
|
|
if ( range.endOffset < range.endContainer.childNodes.length ) {
|
|
range.endContainer.childNodes[ range.endOffset ].mergeContainers();
|
|
}
|
|
range.startContainer.childNodes[ range.startOffset ].mergeContainers();
|
|
|
|
// 7. Make it editable again
|
|
if ( !isOpera ) {
|
|
body.setAttribute( 'contenteditable', 'true' );
|
|
}
|
|
|
|
// 8. Restore selection
|
|
setSelection( getRangeAndRemoveBookmark() );
|
|
|
|
// 9. We're not still in an undo state
|
|
docWasChanged();
|
|
};
|
|
|
|
var increaseBlockQuoteLevel = function ( frag ) {
|
|
return createElement( 'BLOCKQUOTE', [
|
|
frag
|
|
]);
|
|
};
|
|
|
|
var decreaseBlockQuoteLevel = function ( frag ) {
|
|
var blockquotes = frag.querySelectorAll( 'blockquote' );
|
|
Array.prototype.filter.call( blockquotes, function ( el ) {
|
|
return !el.parentNode.nearest( 'BLOCKQUOTE' );
|
|
}).forEach( function ( el ) {
|
|
el.replaceWith( el.empty() );
|
|
});
|
|
return frag;
|
|
};
|
|
|
|
var makeList = function makeList ( nodes, type ) {
|
|
var i, l, node, tag, prev, replacement;
|
|
for ( i = 0, l = nodes.length; i < l; i += 1 ) {
|
|
node = nodes[i];
|
|
tag = node.nodeName;
|
|
if ( node.isBlock() ) {
|
|
if ( tag !== 'LI' ) {
|
|
replacement = createElement( 'LI', [
|
|
node.empty()
|
|
]);
|
|
if ( node.parentNode.nodeName === type ) {
|
|
node.replaceWith( replacement );
|
|
}
|
|
else if ( ( prev = node.previousSibling ) &&
|
|
prev.nodeName === type ) {
|
|
prev.appendChild( replacement );
|
|
node.detach();
|
|
i -= 1;
|
|
l -= 1;
|
|
}
|
|
else {
|
|
node.replaceWith(
|
|
createElement( type, [
|
|
replacement
|
|
])
|
|
);
|
|
}
|
|
}
|
|
} else if ( node.isContainer() ) {
|
|
if ( tag !== type && ( /^[DOU]L$/.test( tag ) ) ) {
|
|
node.replaceWith( createElement( type, [
|
|
node.empty()
|
|
]) );
|
|
} else {
|
|
makeList( node.childNodes, type );
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
var makeUnorderedList = function ( frag ) {
|
|
makeList( frag.childNodes, 'UL' );
|
|
return frag;
|
|
};
|
|
|
|
var makeOrderedList = function ( frag ) {
|
|
makeList( frag.childNodes, 'OL' );
|
|
return frag;
|
|
};
|
|
|
|
var decreaseListLevel = function ( frag ) {
|
|
var lists = frag.querySelectorAll( 'UL, OL' );
|
|
Array.prototype.filter.call( lists, function ( el ) {
|
|
return !el.parentNode.nearest( 'UL' ) &&
|
|
!el.parentNode.nearest( 'OL' );
|
|
}).forEach( function ( el ) {
|
|
var frag = el.empty(),
|
|
children = frag.childNodes,
|
|
l = children.length,
|
|
child;
|
|
while ( l-- ) {
|
|
child = children[l];
|
|
if ( child.nodeName === 'LI' ) {
|
|
frag.replaceChild( createElement( 'DIV', [
|
|
child.empty()
|
|
]), child );
|
|
}
|
|
}
|
|
el.replaceWith( frag );
|
|
});
|
|
return frag;
|
|
};
|
|
|
|
var tagAfterSplit = {
|
|
DIV: 'DIV',
|
|
PRE: 'DIV',
|
|
H1: 'DIV',
|
|
H2: 'DIV',
|
|
H3: 'DIV',
|
|
H4: 'DIV',
|
|
H5: 'DIV',
|
|
H6: 'DIV',
|
|
P: 'DIV',
|
|
DT: 'DD',
|
|
DD: 'DT',
|
|
LI: 'LI'
|
|
};
|
|
|
|
var splitBlock = function ( block, node, offset ) {
|
|
var splitTag = tagAfterSplit[ block.nodeName ],
|
|
nodeAfterSplit = node.split( offset, block.parentNode );
|
|
|
|
// Make sure the new node is the correct type.
|
|
if ( nodeAfterSplit.nodeName !== splitTag ) {
|
|
block = createElement( splitTag );
|
|
block.replaces( nodeAfterSplit )
|
|
.appendChild( nodeAfterSplit.empty() );
|
|
nodeAfterSplit = block;
|
|
}
|
|
return nodeAfterSplit;
|
|
};
|
|
|
|
// --- Clean ---
|
|
|
|
var allowedBlock = /^A(?:DDRESS|RTICLE|SIDE)|BLOCKQUOTE|CAPTION|D(?:[DLT]|IV)|F(?:IGURE|OOTER)|H[1-6]|HEADER|L(?:ABEL|EGEND|I)|O(?:L|UTPUT)|P(?:RE)?|SECTION|T(?:ABLE|BODY|D|FOOT|H|HEAD|R)|UL$/;
|
|
|
|
var spanToSemantic = {
|
|
color: {
|
|
regexp: /\S/,
|
|
replace: function ( color ) {
|
|
return createElement( 'SPAN', {
|
|
'class': 'colour',
|
|
style: 'color:' + color
|
|
});
|
|
}
|
|
},
|
|
fontWeight: {
|
|
regexp: /^bold/i,
|
|
replace: function () {
|
|
return createElement( 'B' );
|
|
}
|
|
},
|
|
fontStyle: {
|
|
regexp: /^italic/i,
|
|
replace: function () {
|
|
return createElement( 'I' );
|
|
}
|
|
},
|
|
fontFamily: {
|
|
regexp: /\S/,
|
|
replace: function ( family ) {
|
|
return createElement( 'SPAN', {
|
|
'class': 'font',
|
|
style: 'font-family:' + family
|
|
});
|
|
}
|
|
},
|
|
fontSize: {
|
|
regexp: /\S/,
|
|
replace: function ( size ) {
|
|
return createElement( 'SPAN', {
|
|
'class': 'size',
|
|
style: 'font-size:' + size
|
|
});
|
|
}
|
|
}
|
|
};
|
|
|
|
var stylesRewriters = {
|
|
SPAN: function ( span, parent ) {
|
|
var style = span.style,
|
|
attr, converter, css, newTreeBottom, newTreeTop, el;
|
|
|
|
for ( attr in spanToSemantic ) {
|
|
converter = spanToSemantic[ attr ];
|
|
css = style[ attr ];
|
|
if ( css && converter.regexp.test( css ) ) {
|
|
el = converter.replace( css );
|
|
if ( newTreeBottom ) {
|
|
newTreeBottom.appendChild( el );
|
|
}
|
|
newTreeBottom = el;
|
|
if ( !newTreeTop ) {
|
|
newTreeTop = el;
|
|
}
|
|
}
|
|
}
|
|
|
|
if ( newTreeTop ) {
|
|
newTreeBottom.appendChild( span.empty() );
|
|
parent.replaceChild( newTreeTop, span );
|
|
}
|
|
|
|
return newTreeBottom || span;
|
|
},
|
|
STRONG: function ( node, parent ) {
|
|
var el = createElement( 'B' );
|
|
parent.replaceChild( el, node );
|
|
el.appendChild( node.empty() );
|
|
return el;
|
|
},
|
|
EM: function ( node, parent ) {
|
|
var el = createElement( 'I' );
|
|
parent.replaceChild( el, node );
|
|
el.appendChild( node.empty() );
|
|
return el;
|
|
}
|
|
};
|
|
|
|
/*
|
|
Two purposes:
|
|
|
|
1. Remove nodes we don't want, such as weird <o:p> tags, comment nodes
|
|
and whitespace nodes.
|
|
2. Convert inline tags into our preferred format.
|
|
*/
|
|
var cleanTree = function ( node, allowStyles ) {
|
|
var children = node.childNodes,
|
|
i, l, child, nodeName, nodeType, rewriter, parent;
|
|
for ( i = 0, l = children.length; i < l; i += 1 ) {
|
|
child = children[i];
|
|
nodeName = child.nodeName;
|
|
nodeType = child.nodeType;
|
|
rewriter = stylesRewriters[ nodeName ];
|
|
if ( rewriter ) {
|
|
child = rewriter( child, node );
|
|
} else if (
|
|
( !allowedBlock.test( nodeName ) && !child.isInline() ) ||
|
|
( nodeType === TEXT_NODE && !( /\S/.test( child.data ) ) ) ) {
|
|
node.removeChild( child );
|
|
i -= 1;
|
|
l -= 1;
|
|
continue;
|
|
}
|
|
if ( nodeType === ELEMENT_NODE ) {
|
|
if ( !allowStyles && child.style.cssText ) {
|
|
child.removeAttribute( 'style' );
|
|
}
|
|
if ( child.childNodes.length ) {
|
|
cleanTree( child, allowStyles );
|
|
}
|
|
}
|
|
}
|
|
return node;
|
|
};
|
|
|
|
var wrapTopLevelInline = function ( root, tag ) {
|
|
var children = root.childNodes,
|
|
wrapper = null,
|
|
i, l, child, isBR;
|
|
for ( i = 0, l = children.length; i < l; i += 1 ) {
|
|
child = children[i];
|
|
isBR = child.nodeName === 'BR';
|
|
if ( !isBR && child.isInline() ) {
|
|
if ( !wrapper ) { wrapper = createElement( tag ); }
|
|
wrapper.appendChild( child );
|
|
i -= 1;
|
|
l -= 1;
|
|
} else if ( isBR || wrapper ) {
|
|
if ( !wrapper ) { wrapper = createElement( tag ); }
|
|
wrapper.fixCursor();
|
|
if ( isBR ) {
|
|
root.replaceChild( wrapper, child );
|
|
} else {
|
|
root.insertBefore( wrapper, child );
|
|
i += 1;
|
|
l += 1;
|
|
}
|
|
wrapper = null;
|
|
}
|
|
}
|
|
if ( wrapper ) {
|
|
root.appendChild( wrapper.fixCursor() );
|
|
}
|
|
return root;
|
|
};
|
|
|
|
var cleanupBRs = function ( root ) {
|
|
var brs = root.querySelectorAll( 'BR' ),
|
|
l = brs.length,
|
|
br, block, nodeAfterSplit, div, next;
|
|
|
|
while ( l-- ) {
|
|
br = brs[l];
|
|
// Cleanup may have removed it
|
|
block = br.parentNode;
|
|
if ( !block ) { continue; }
|
|
if ( br.nextSibling && br.previousSibling ) {
|
|
while ( block.isInline() ) {
|
|
block = block.parentNode;
|
|
}
|
|
// If this is not inside a block, replace it by wrapping
|
|
// inlines in DIV.
|
|
if ( !block.isBlock() ) {
|
|
wrapTopLevelInline( block, 'DIV' );
|
|
}
|
|
// If in a block we can split, split it instead
|
|
else if ( tagAfterSplit[ block.nodeName ] ) {
|
|
splitBlock( block, br.parentNode, br );
|
|
br.detach();
|
|
}
|
|
// Otherwise leave the br alone.
|
|
} else {
|
|
br.detach();
|
|
}
|
|
}
|
|
};
|
|
|
|
// --- Cut and Paste ---
|
|
|
|
doc.addEventListener( isIE ? 'beforecut' : 'cut', function () {
|
|
// Save undo checkpoint
|
|
var range = getSelection();
|
|
recordUndoState( range );
|
|
getRangeAndRemoveBookmark( range );
|
|
// If all content removed, ensure div at start of body.
|
|
setTimeout( function () {
|
|
body.fixCursor();
|
|
}, 0 );
|
|
}, false );
|
|
|
|
doc.addEventListener( isIE ? 'beforepaste' : 'paste', function () {
|
|
var range = getSelection(),
|
|
startContainer = range.startContainer,
|
|
startOffset = range.startOffset,
|
|
endContainer = range.endContainer,
|
|
endOffset = range.endOffset;
|
|
|
|
var pasteArea = createElement( 'DIV', {
|
|
style: 'position: absolute; overflow: hidden;' +
|
|
'top: -100px; left: -100px; width: 1px; height: 1px;'
|
|
});
|
|
body.appendChild( pasteArea );
|
|
range.selectNodeContents( pasteArea );
|
|
setSelection( range );
|
|
|
|
// A setTimeout of 0 means this is added to the back of the
|
|
// single javascript thread, so it will be executed after the
|
|
// paste event.
|
|
setTimeout( function () {
|
|
// Get the pasted content and clean
|
|
var frag = pasteArea.detach().empty(),
|
|
first = frag.firstChild;
|
|
|
|
// Safari likes putting extra divs around things.
|
|
if ( first &&
|
|
first === frag.lastChild && first.nodeName === 'DIV' ) {
|
|
frag.replaceChild( first.empty(), first );
|
|
}
|
|
|
|
frag.normalize();
|
|
cleanTree( frag, false );
|
|
cleanupBRs( frag );
|
|
|
|
var node = frag;
|
|
while ( node = node.getNextBlock() ) {
|
|
node.fixCursor();
|
|
}
|
|
|
|
// Restore the previous selection
|
|
range.setStart( startContainer, startOffset );
|
|
range.setEnd( endContainer, endOffset );
|
|
|
|
// Insert pasted data
|
|
range.insertTreeFragment( frag );
|
|
docWasChanged();
|
|
|
|
range.collapse( false );
|
|
setSelection( range );
|
|
}, 0 );
|
|
}, false );
|
|
|
|
// --- Keyboard interaction ---
|
|
|
|
var keys = {
|
|
8: 'backspace',
|
|
9: 'tab',
|
|
13: 'enter',
|
|
32: 'space',
|
|
46: 'delete'
|
|
};
|
|
|
|
var mapKeyTo = function ( fn ) {
|
|
return function ( event ) {
|
|
event.preventDefault();
|
|
fn();
|
|
};
|
|
};
|
|
|
|
var mapKeyToFormat = function ( tag ) {
|
|
return function ( event ) {
|
|
event.preventDefault();
|
|
var range = getSelection();
|
|
if ( hasFormat( tag, null, range ) ) {
|
|
changeFormat( null, { tag: tag }, range );
|
|
} else {
|
|
changeFormat( { tag: tag }, null, range );
|
|
}
|
|
};
|
|
};
|
|
|
|
var keyHandlers = {
|
|
tab: function ( event ) {
|
|
event.preventDefault();
|
|
},
|
|
enter: function ( event ) {
|
|
// We handle this ourselves
|
|
event.preventDefault();
|
|
|
|
// Must have some form of selection
|
|
var range = getSelection();
|
|
if ( !range ) { return; }
|
|
|
|
// Save undo checkpoint
|
|
recordUndoState( range );
|
|
getRangeAndRemoveBookmark( range );
|
|
|
|
// Selected text is overwritten, therefore delete the contents
|
|
// to collapse selection.
|
|
if ( !range.collapsed ) {
|
|
range._deleteContents();
|
|
}
|
|
|
|
var block = range.getStartBlock(),
|
|
tag = block ? block.nodeName : 'DIV',
|
|
splitTag = tagAfterSplit[ tag ],
|
|
nodeAfterSplit;
|
|
|
|
// If this is a malformed bit of document, just play it safe
|
|
// and insert a <br>.
|
|
if ( !block ) {
|
|
range._insertNode( createElement( 'BR' ) );
|
|
range.collapse( false );
|
|
setSelection( range );
|
|
docWasChanged();
|
|
return;
|
|
}
|
|
|
|
// We need to wrap the contents in divs.
|
|
var splitNode = range.startContainer,
|
|
splitOffset = range.startOffset,
|
|
replacement;
|
|
if ( !splitTag ) {
|
|
// If the selection point is inside the block, we're going to
|
|
// rewrite it so our saved referece points won't be valid.
|
|
// Pick a node at a deeper point in the tree to avoid this.
|
|
if ( splitNode === block ) {
|
|
splitNode = splitOffset ?
|
|
splitNode.childNodes[ splitOffset - 1 ] : null;
|
|
splitOffset = 0;
|
|
if ( splitNode ) {
|
|
if ( splitNode.nodeName === 'BR' ) {
|
|
splitNode = splitNode.nextSibling;
|
|
} else {
|
|
splitOffset = splitNode.getLength();
|
|
}
|
|
if ( !splitNode || splitNode.nodeName === 'BR' ) {
|
|
replacement = createElement( 'DIV' ).fixCursor();
|
|
if ( splitNode ) {
|
|
block.replaceChild( replacement, splitNode );
|
|
} else {
|
|
block.appendChild( replacement );
|
|
}
|
|
splitNode = replacement;
|
|
}
|
|
}
|
|
}
|
|
wrapTopLevelInline( block, 'DIV' );
|
|
splitTag = 'DIV';
|
|
if ( !splitNode ) {
|
|
splitNode = block.firstChild;
|
|
}
|
|
range.setStart( splitNode, splitOffset );
|
|
range.setEnd( splitNode, splitOffset );
|
|
block = range.getStartBlock();
|
|
}
|
|
|
|
if ( !block.textContent ) {
|
|
// Break list
|
|
if ( block.nearest( 'UL' ) || block.nearest( 'OL' ) ) {
|
|
return modifyBlocks( decreaseListLevel, range );
|
|
}
|
|
// Break blockquote
|
|
else if ( block.nearest( 'BLOCKQUOTE' ) ) {
|
|
return modifyBlocks( decreaseBlockQuoteLevel, range );
|
|
}
|
|
}
|
|
|
|
// Otherwise, split at cursor point.
|
|
nodeAfterSplit = splitBlock( block, splitNode, splitOffset );
|
|
|
|
// Focus cursor
|
|
// If there's a <b>/<i> etc. at the beginning of the split
|
|
// make sure we focus inside it.
|
|
while ( nodeAfterSplit.nodeType === ELEMENT_NODE) {
|
|
var child = nodeAfterSplit.firstChild,
|
|
next;
|
|
|
|
while ( child && child.nodeType === TEXT_NODE && !child.data ) {
|
|
next = child.nextSibling;
|
|
if ( !next || next.nodeName === 'BR' ) {
|
|
break;
|
|
}
|
|
child.detach();
|
|
child = next;
|
|
}
|
|
|
|
// 'BR's essentially don't count; they're a browser hack.
|
|
// If you try to select the contents of a 'BR', FF will not let
|
|
// you type anything!
|
|
if ( !child || child.nodeName === 'BR' ||
|
|
( child.nodeType === TEXT_NODE && !isOpera ) ) {
|
|
break;
|
|
}
|
|
nodeAfterSplit = child;
|
|
}
|
|
setSelection( createRange( nodeAfterSplit, 0 ) );
|
|
|
|
// We're not still in an undo state
|
|
docWasChanged();
|
|
},
|
|
backspace: function ( event ) {
|
|
var range = getSelection();
|
|
// If not collapsed, delete contents
|
|
if ( !range.collapsed ) {
|
|
event.preventDefault();
|
|
range._deleteContents();
|
|
setSelection( range );
|
|
}
|
|
// If at beginning of block, merge with previous
|
|
else if ( range.startsAtBlockBoundary() ) {
|
|
event.preventDefault();
|
|
var current = range.getStartBlock(),
|
|
previous = current.getPreviousBlock();
|
|
// Must not be at the very beginning of the text area.
|
|
if ( previous ) {
|
|
previous.mergeWithBlock( current, range );
|
|
setSelection( range );
|
|
}
|
|
// If at very beginning of text area, allow backspace
|
|
// to break lists/blockquote.
|
|
else {
|
|
// Break list
|
|
if ( current.nearest( 'UL' ) || current.nearest( 'OL' ) ) {
|
|
return modifyBlocks( decreaseListLevel, range );
|
|
}
|
|
// Break blockquote
|
|
else if ( current.nearest( 'BLOCKQUOTE' ) ) {
|
|
return modifyBlocks( decreaseBlockQuoteLevel, range );
|
|
}
|
|
setSelection( range );
|
|
}
|
|
}
|
|
// All other cases can be safely left to the browser (I hope!).
|
|
},
|
|
'delete': function ( event ) {
|
|
var range = getSelection();
|
|
// If not collapsed, delete contents
|
|
if ( !range.collapsed ) {
|
|
event.preventDefault();
|
|
range._deleteContents();
|
|
setSelection( range );
|
|
}
|
|
// If at end of block, merge next into this block
|
|
else if ( range.endsAtBlockBoundary() ) {
|
|
event.preventDefault();
|
|
var current = range.getStartBlock(),
|
|
next = current.getNextBlock();
|
|
// Must not be at the very end of the text area.
|
|
if ( next ) {
|
|
current.mergeWithBlock( next, range );
|
|
setSelection( range );
|
|
}
|
|
}
|
|
// All other cases can be safely left to the browser (I hope!).
|
|
},
|
|
space: function () {
|
|
var range = getSelection();
|
|
recordUndoState( range );
|
|
getRangeAndRemoveBookmark( range );
|
|
setSelection( range );
|
|
},
|
|
'ctrl-b': mapKeyToFormat( 'B' ),
|
|
'ctrl-i': mapKeyToFormat( 'I' ),
|
|
'ctrl-u': mapKeyToFormat( 'U' ),
|
|
'ctrl-y': mapKeyTo( redo ),
|
|
'ctrl-z': mapKeyTo( undo ),
|
|
'ctrl-shift-z': mapKeyTo( redo )
|
|
};
|
|
|
|
addEventListener( 'keydown', function ( event ) {
|
|
// Ref: http://unixpapa.com/js/key.html
|
|
var code = event.keyCode || event.which,
|
|
key = keys[ code ] || String.fromCharCode( code ).toLowerCase(),
|
|
modifiers = '';
|
|
|
|
// Function keys
|
|
if ( 111 < code && code < 124 ) {
|
|
key = 'f' + ( code - 111 );
|
|
}
|
|
|
|
if ( event.altKey ) { modifiers += 'alt-'; }
|
|
if ( event.ctrlKey || event.metaKey ) { modifiers += 'ctrl-'; }
|
|
if ( event.shiftKey ) { modifiers += 'shift-'; }
|
|
|
|
key = modifiers + key;
|
|
|
|
if ( keyHandlers[ key ] ) {
|
|
keyHandlers[ key ]( event );
|
|
} else {
|
|
fireEvent( 'keydown', event );
|
|
}
|
|
});
|
|
|
|
// --- Export ---
|
|
|
|
var styleExtractor = /<style[^>]*>([\s\S]*?)<\/style>/gi;
|
|
|
|
var chain = function ( fn ) {
|
|
return function () {
|
|
fn.apply( null, arguments );
|
|
return this;
|
|
};
|
|
};
|
|
|
|
var command = function ( fn, arg, arg2 ) {
|
|
return function () {
|
|
fn( arg, arg2 );
|
|
focus();
|
|
return this;
|
|
};
|
|
};
|
|
|
|
win.editor = {
|
|
|
|
addEventListener: chain( addEventListener ),
|
|
removeEventListener: chain( removeEventListener ),
|
|
|
|
focus: chain( focus ),
|
|
blur: chain( blur ),
|
|
|
|
getDocument: function () {
|
|
return doc;
|
|
},
|
|
|
|
getHTML: function () {
|
|
var brs = [],
|
|
node, fixer, html, l;
|
|
if ( useTextFixer ) {
|
|
node = body;
|
|
while ( node = node.getNextBlock() ) {
|
|
if ( !node.textContent && !node.querySelector( 'BR' ) ) {
|
|
fixer = createElement( 'BR' );
|
|
node.appendChild( fixer );
|
|
brs.push( fixer );
|
|
}
|
|
}
|
|
}
|
|
html = getHTML();
|
|
if ( useTextFixer ) {
|
|
l = brs.length;
|
|
while ( l-- ) {
|
|
brs[l].detach();
|
|
}
|
|
}
|
|
return html;
|
|
},
|
|
setHTML: function ( html ) {
|
|
var frag = doc.createDocumentFragment(),
|
|
div = createElement( 'DIV' ),
|
|
styles = '',
|
|
child;
|
|
|
|
// Extract styles to insert into <head>
|
|
styleExtractor.lastIndex = 0;
|
|
html = html.replace( styleExtractor,
|
|
function ( _, rules ) {
|
|
styles += rules.replace( /<!--|-->/g, '' );
|
|
return '';
|
|
});
|
|
|
|
// Parse HTML into DOM tree
|
|
div.innerHTML = html;
|
|
frag.appendChild( div.empty() );
|
|
|
|
cleanTree( frag, true );
|
|
cleanupBRs( frag );
|
|
|
|
wrapTopLevelInline( frag, 'DIV' );
|
|
|
|
// Fix cursor
|
|
var node = frag;
|
|
while ( node = node.getNextBlock() ) {
|
|
node.fixCursor();
|
|
}
|
|
|
|
// Remove existing body children
|
|
while ( child = body.lastChild ) {
|
|
body.removeChild( child );
|
|
}
|
|
|
|
// And insert new content
|
|
// Add the styles
|
|
if ( styles ) {
|
|
var head = doc.documentElement.firstChild,
|
|
style = createElement( 'STYLE', {
|
|
type: 'text/css'
|
|
});
|
|
if ( style.styleSheet ) {
|
|
// IE8: must append to document BEFORE adding styles
|
|
// or you get the IE7 CSS parser!
|
|
head.appendChild( style );
|
|
style.styleSheet.cssText = styles;
|
|
} else {
|
|
// Everyone else
|
|
style.appendChild( doc.createTextNode( styles ) );
|
|
head.appendChild( style );
|
|
}
|
|
}
|
|
|
|
body.appendChild( frag );
|
|
body.fixCursor();
|
|
|
|
// Reset the undo stack
|
|
undoIndex = -1;
|
|
undoStack = [];
|
|
undoStackLength = 0;
|
|
isInUndoState = false;
|
|
|
|
// Record undo state
|
|
var range = createRange( body.firstChild, 0 );
|
|
recordUndoState( range );
|
|
setSelection( getRangeAndRemoveBookmark( range ) );
|
|
|
|
return this;
|
|
},
|
|
|
|
insertImage: function ( src ) {
|
|
var img = createElement( 'IMG', {
|
|
src: src
|
|
});
|
|
insertElement( img );
|
|
return img;
|
|
},
|
|
|
|
getPath: function () {
|
|
return path;
|
|
},
|
|
getSelection: getSelection,
|
|
setSelection: chain( setSelection ),
|
|
|
|
undo: chain( undo ),
|
|
redo: chain( redo ),
|
|
|
|
hasFormat: hasFormat,
|
|
changeFormat: chain( changeFormat ),
|
|
|
|
bold: command( changeFormat, { tag: 'B' } ),
|
|
italic: command( changeFormat, { tag: 'I' } ),
|
|
underline: command( changeFormat, { tag: 'U' } ),
|
|
|
|
removeBold: command( changeFormat, null, { tag: 'B' } ),
|
|
removeItalic: command( changeFormat, null, { tag: 'I' } ),
|
|
removeUnderline: command( changeFormat, null, { tag: 'U' } ),
|
|
|
|
makeLink: function ( url ) {
|
|
changeFormat({
|
|
tag: 'A',
|
|
attributes: {
|
|
href: url
|
|
}
|
|
}, {
|
|
tag: 'A'
|
|
});
|
|
focus();
|
|
return this;
|
|
},
|
|
|
|
setFontFace: function ( name ) {
|
|
changeFormat({
|
|
tag: 'SPAN',
|
|
attributes: {
|
|
'class': 'font',
|
|
style: 'font-family: ' + name + ', sans-serif;'
|
|
}
|
|
}, {
|
|
tag: 'SPAN',
|
|
'class': 'font'
|
|
});
|
|
focus();
|
|
return this;
|
|
},
|
|
setFontSize: function ( size ) {
|
|
changeFormat({
|
|
tag: 'SPAN',
|
|
attributes: {
|
|
'class': 'size',
|
|
style: 'font-size: ' +
|
|
( typeof size === 'number' ? size + 'px' : size )
|
|
}
|
|
}, {
|
|
tag: 'SPAN',
|
|
'class': 'size'
|
|
});
|
|
focus();
|
|
return this;
|
|
},
|
|
setTextAlignment: function ( dir ) {
|
|
forEachBlock( function ( block ) {
|
|
block.className = 'align-' + dir;
|
|
block.style.textAlign = dir;
|
|
});
|
|
focus();
|
|
return this;
|
|
},
|
|
|
|
modifyBlocks: chain( modifyBlocks ),
|
|
|
|
incQuoteLevel: command( modifyBlocks, increaseBlockQuoteLevel ),
|
|
decQuoteLevel: command( modifyBlocks, decreaseBlockQuoteLevel ),
|
|
|
|
makeUnorderedList: command( modifyBlocks, makeUnorderedList ),
|
|
makeOrderedList: command( modifyBlocks, makeOrderedList ),
|
|
removeList: command( modifyBlocks, decreaseListLevel )
|
|
};
|
|
|
|
// --- Initialise ---
|
|
|
|
body.setAttribute( 'contenteditable', 'true' );
|
|
win.editor.setHTML( '' );
|
|
|
|
}, false ); |