4 * Copyright, Moxiecode Systems AB
5 * Released under LGPL License.
7 * License: http://www.tinymce.com/license
8 * Contributing: http://www.tinymce.com/contributing
12 * This class enables you to add custom editor commands and it contains
13 * overrides for native browser commands to address various bugs and issues.
15 * @class tinymce.EditorCommands
17 define("tinymce/EditorCommands", [
18 "tinymce/html/Serializer",
21 ], function(Serializer, Env, Tools) {
22 // Added for compression purposes
23 var each = Tools.each, extend = Tools.extend;
24 var map = Tools.map, inArray = Tools.inArray, explode = Tools.explode;
25 var isGecko = Env.gecko, isIE = Env.ie;
26 var TRUE = true, FALSE = false;
28 return function(editor) {
30 selection = editor.selection,
31 commands = {state: {}, exec: {}, value: {}},
32 settings = editor.settings,
33 formatter = editor.formatter,
37 * Executes the specified command.
40 * @param {String} command Command to execute.
41 * @param {Boolean} ui Optional user interface state.
42 * @param {Object} value Optional value for command.
43 * @return {Boolean} true/false if the command was found or not.
45 function execCommand(command, ui, value) {
48 command = command.toLowerCase();
49 if ((func = commands.exec[command])) {
50 func(command, ui, value);
58 * Queries the current state for a command for example if the current selection is "bold".
60 * @method queryCommandState
61 * @param {String} command Command to check the state of.
62 * @return {Boolean/Number} true/false if the selected contents is bold or not, -1 if it's not found.
64 function queryCommandState(command) {
67 command = command.toLowerCase();
68 if ((func = commands.state[command])) {
76 * Queries the command value for example the current fontsize.
78 * @method queryCommandValue
79 * @param {String} command Command to check the value of.
80 * @return {Object} Command value of false if it's not found.
82 function queryCommandValue(command) {
85 command = command.toLowerCase();
86 if ((func = commands.value[command])) {
94 * Adds commands to the command collection.
97 * @param {Object} command_list Name/value collection with commands to add, the names can also be comma separated.
98 * @param {String} type Optional type to add, defaults to exec. Can be value or state as well.
100 function addCommands(command_list, type) {
101 type = type || 'exec';
103 each(command_list, function(callback, command) {
104 each(command.toLowerCase().split(','), function(command) {
105 commands[type][command] = callback;
110 // Expose public methods
112 execCommand: execCommand,
113 queryCommandState: queryCommandState,
114 queryCommandValue: queryCommandValue,
115 addCommands: addCommands
120 function execNativeCommand(command, ui, value) {
121 if (ui === undefined) {
125 if (value === undefined) {
129 return editor.getDoc().execCommand(command, ui, value);
132 function isFormatMatch(name) {
133 return formatter.match(name);
136 function toggleFormat(name, value) {
137 formatter.toggle(name, value ? {value: value} : undefined);
138 editor.nodeChanged();
141 function storeSelection(type) {
142 bookmark = selection.getBookmark(type);
145 function restoreSelection() {
146 selection.moveToBookmark(bookmark);
149 // Add execCommand overrides
151 // Ignore these, added for compatibility
152 'mceResetDesignMode,mceBeginUndoLevel': function() {},
154 // Add undo manager logic
155 'mceEndUndoLevel,mceAddUndoLevel': function() {
156 editor.undoManager.add();
159 'Cut,Copy,Paste': function(command) {
160 var doc = editor.getDoc(), failed;
162 // Try executing the native command
164 execNativeCommand(command);
170 // Present alert message about clipboard access not being available
171 if (failed || !doc.queryCommandSupported(command)) {
172 editor.windowManager.alert(
173 "Your browser doesn't support direct access to the clipboard. " +
174 "Please use the Ctrl+X/C/V keyboard shortcuts instead."
179 // Override unlink command
180 unlink: function(command) {
181 if (selection.isCollapsed()) {
182 selection.select(selection.getNode());
185 execNativeCommand(command);
186 selection.collapse(FALSE);
189 // Override justify commands to use the text formatter engine
190 'JustifyLeft,JustifyCenter,JustifyRight,JustifyFull': function(command) {
191 var align = command.substring(7);
193 if (align == 'full') {
197 // Remove all other alignments first
198 each('left,center,right,justify'.split(','), function(name) {
200 formatter.remove('align' + name);
204 toggleFormat('align' + align);
205 execCommand('mceRepaint');
208 // Override list commands to fix WebKit bug
209 'InsertUnorderedList,InsertOrderedList': function(command) {
210 var listElm, listParent;
212 execNativeCommand(command);
214 // WebKit produces lists within block elements so we need to split them
215 // we will replace the native list creation logic to custom logic later on
216 // TODO: Remove this when the list creation logic is removed
217 listElm = dom.getParent(selection.getNode(), 'ol,ul');
219 listParent = listElm.parentNode;
221 // If list is within a text block then split that block
222 if (/^(H[1-6]|P|ADDRESS|PRE)$/.test(listParent.nodeName)) {
224 dom.split(listParent, listElm);
230 // Override commands to use the text formatter engine
231 'Bold,Italic,Underline,Strikethrough,Superscript,Subscript': function(command) {
232 toggleFormat(command);
235 // Override commands to use the text formatter engine
236 'ForeColor,HiliteColor,FontName': function(command, ui, value) {
237 toggleFormat(command, value);
240 FontSize: function(command, ui, value) {
241 var fontClasses, fontSizes;
243 // Convert font size 1-7 to styles
244 if (value >= 1 && value <= 7) {
245 fontSizes = explode(settings.font_size_style_values);
246 fontClasses = explode(settings.font_size_classes);
249 value = fontClasses[value - 1] || value;
251 value = fontSizes[value - 1] || value;
255 toggleFormat(command, value);
258 RemoveFormat: function(command) {
259 formatter.remove(command);
262 mceBlockQuote: function() {
263 toggleFormat('blockquote');
266 FormatBlock: function(command, ui, value) {
267 return toggleFormat(value || 'p');
270 mceCleanup: function() {
271 var bookmark = selection.getBookmark();
273 editor.setContent(editor.getContent({cleanup: TRUE}), {cleanup: TRUE});
275 selection.moveToBookmark(bookmark);
278 mceRemoveNode: function(command, ui, value) {
279 var node = value || selection.getNode();
281 // Make sure that the body node isn't removed
282 if (node != editor.getBody()) {
284 editor.dom.remove(node, TRUE);
289 mceSelectNodeDepth: function(command, ui, value) {
292 dom.getParent(selection.getNode(), function(node) {
293 if (node.nodeType == 1 && counter++ == value) {
294 selection.select(node);
297 }, editor.getBody());
300 mceSelectNode: function(command, ui, value) {
301 selection.select(value);
304 mceInsertContent: function(command, ui, value) {
305 var parser, serializer, parentNode, rootNode, fragment, args;
306 var marker, nodeRect, viewPortRect, rng, node, node2, bookmarkHtml, viewportBodyElement;
308 function trimOrPaddLeftRight(html) {
309 var rng, container, offset;
311 rng = selection.getRng(true);
312 container = rng.startContainer;
313 offset = rng.startOffset;
315 function hasSiblingText(siblingName) {
316 return container[siblingName] && container[siblingName].nodeType == 3;
319 if (container.nodeType == 3) {
321 html = html.replace(/^ /, ' ');
322 } else if (!hasSiblingText('previousSibling')) {
323 html = html.replace(/^ /, ' ');
326 if (offset < container.length) {
327 html = html.replace(/ (<br>|)$/, ' ');
328 } else if (!hasSiblingText('nextSibling')) {
329 html = html.replace(/( | )(<br>|)$/, ' ');
336 // Check for whitespace before/after value
337 if (/^ | $/.test(value)) {
338 value = trimOrPaddLeftRight(value);
341 // Setup parser and serializer
342 parser = editor.parser;
343 serializer = new Serializer({}, editor.schema);
344 bookmarkHtml = '<span id="mce_marker" data-mce-type="bookmark"></span>';
346 // Run beforeSetContent handlers on the HTML to be inserted
347 args = {content: value, format: 'html', selection: true};
348 editor.fire('BeforeSetContent', args);
349 value = args.content;
351 // Add caret at end of contents if it's missing
352 if (value.indexOf('{$caret}') == -1) {
356 // Replace the caret marker with a span bookmark element
357 value = value.replace(/\{\$caret\}/, bookmarkHtml);
359 // Insert node maker where we will insert the new HTML and get it's parent
360 if (!selection.isCollapsed()) {
361 editor.getDoc().execCommand('Delete', false, null);
364 parentNode = selection.getNode();
366 // Parse the fragment within the context of the parent node
367 args = {context: parentNode.nodeName.toLowerCase()};
368 fragment = parser.parse(value, args);
370 // Move the caret to a more suitable location
371 node = fragment.lastChild;
372 if (node.attr('id') == 'mce_marker') {
375 for (node = node.prev; node; node = node.walk(true)) {
376 if (node.type == 3 || !dom.isBlock(node.name)) {
377 node.parent.insert(marker, node, node.name === 'br');
383 // If parser says valid we can insert the contents into that parent
385 value = serializer.serialize(fragment);
387 // Check if parent is empty or only has one BR element then set the innerHTML of that parent
388 node = parentNode.firstChild;
389 node2 = parentNode.lastChild;
390 if (!node || (node === node2 && node.nodeName === 'BR')) {
391 dom.setHTML(parentNode, value);
393 selection.setContent(value);
396 // If the fragment was invalid within that context then we need
397 // to parse and process the parent it's inserted into
399 // Insert bookmark node and get the parent
400 selection.setContent(bookmarkHtml);
401 parentNode = selection.getNode();
402 rootNode = editor.getBody();
404 // Opera will return the document node when selection is in root
405 if (parentNode.nodeType == 9) {
406 parentNode = node = rootNode;
411 // Find the ancestor just before the root element
412 while (node !== rootNode) {
414 node = node.parentNode;
417 // Get the outer/inner HTML depending on if we are in the root and parser and serialize that
418 value = parentNode == rootNode ? rootNode.innerHTML : dom.getOuterHTML(parentNode);
419 value = serializer.serialize(
421 // Need to replace by using a function since $ in the contents would otherwise be a problem
422 value.replace(/<span (id="mce_marker"|id=mce_marker).+?<\/span>/i, function() {
423 return serializer.serialize(fragment);
428 // Set the inner/outer HTML depending on if we are in the root or not
429 if (parentNode == rootNode) {
430 dom.setHTML(rootNode, value);
432 dom.setOuterHTML(parentNode, value);
436 marker = dom.get('mce_marker');
438 // Scroll range into view scrollIntoView on element can't be used since it will scroll the main view port as well
439 nodeRect = dom.getRect(marker);
440 viewPortRect = dom.getViewPort(editor.getWin());
442 // Check if node is out side the viewport if it is then scroll to it
443 if ((nodeRect.y + nodeRect.h > viewPortRect.y + viewPortRect.h || nodeRect.y < viewPortRect.y) ||
444 (nodeRect.x > viewPortRect.x + viewPortRect.w || nodeRect.x < viewPortRect.x)) {
445 viewportBodyElement = isIE ? editor.getDoc().documentElement : editor.getBody();
446 viewportBodyElement.scrollLeft = nodeRect.x;
447 viewportBodyElement.scrollTop = nodeRect.y - viewPortRect.h + 25;
450 // Move selection before marker and remove it
451 rng = dom.createRng();
453 // If previous sibling is a text node set the selection to the end of that node
454 node = marker.previousSibling;
455 if (node && node.nodeType == 3) {
456 rng.setStart(node, node.nodeValue.length);
458 // TODO: Why can't we normalize on IE
460 node2 = marker.nextSibling;
461 if (node2 && node2.nodeType == 3) {
462 node.appendData(node2.data);
463 node2.parentNode.removeChild(node2);
467 // If the previous sibling isn't a text node or doesn't exist set the selection before the marker node
468 rng.setStartBefore(marker);
469 rng.setEndBefore(marker);
472 // Remove the marker node and set the new range
474 selection.setRng(rng);
476 // Dispatch after event and add any visual elements needed
477 editor.fire('SetContent', args);
481 mceInsertRawHTML: function(command, ui, value) {
482 selection.setContent('tiny_mce_marker');
484 editor.getContent().replace(/tiny_mce_marker/g, function() {
490 mceToggleFormat: function(command, ui, value) {
494 mceSetContent: function(command, ui, value) {
495 editor.setContent(value);
498 'Indent,Outdent': function(command) {
499 var intentValue, indentUnit, value;
501 // Setup indent level
502 intentValue = settings.indentation;
503 indentUnit = /[a-z%]+$/i.exec(intentValue);
504 intentValue = parseInt(intentValue, 10);
506 if (!queryCommandState('InsertUnorderedList') && !queryCommandState('InsertOrderedList')) {
507 // If forced_root_blocks is set to false we don't have a block to indent so lets create a div
508 if (!settings.forced_root_block && !dom.getParent(selection.getNode(), dom.isBlock)) {
509 formatter.apply('div');
512 each(selection.getSelectedBlocks(), function(element) {
515 if (element.nodeName != "LI") {
516 indentStyleName = dom.getStyle(element, 'direction', true) == 'rtl' ? 'paddingRight' : 'paddingLeft';
518 if (command == 'outdent') {
519 value = Math.max(0, parseInt(element.style[indentStyleName] || 0, 10) - intentValue);
520 dom.setStyle(element, indentStyleName, value ? value + indentUnit : '');
522 value = (parseInt(element.style[indentStyleName] || 0, 10) + intentValue) + indentUnit;
523 dom.setStyle(element, indentStyleName, value);
528 execNativeCommand(command);
532 mceRepaint: function() {
535 storeSelection(TRUE);
537 if (selection.getSel()) {
538 selection.getSel().selectAllChildren(editor.getBody());
541 selection.collapse(TRUE);
549 InsertHorizontalRule: function() {
550 editor.execCommand('mceInsertContent', false, '<hr />');
553 mceToggleVisualAid: function() {
554 editor.hasVisual = !editor.hasVisual;
558 mceReplaceContent: function(command, ui, value) {
559 editor.execCommand('mceInsertContent', false, value.replace(/\{\$selection\}/g, selection.getContent({format: 'text'})));
562 mceInsertLink: function(command, ui, value) {
565 if (typeof(value) == 'string') {
566 value = {href: value};
569 anchor = dom.getParent(selection.getNode(), 'a');
571 // Spaces are never valid in URLs and it's a very common mistake for people to make so we fix it here.
572 value.href = value.href.replace(' ', '%20');
574 // Remove existing links if there could be child links or that the href isn't specified
575 if (!anchor || !value.href) {
576 formatter.remove('link');
579 // Apply new link to selection
581 formatter.apply('link', value, anchor);
585 selectAll: function() {
586 var root = dom.getRoot(), rng = dom.createRng();
588 // Old IE does a better job with selectall than new versions
589 if (selection.getRng().setStart) {
590 rng.setStart(root, 0);
591 rng.setEnd(root, root.childNodes.length);
593 selection.setRng(rng);
595 execNativeCommand('SelectAll');
599 mceNewDocument: function() {
600 editor.setContent('');
604 // Add queryCommandState overrides
606 // Override justify commands
607 'JustifyLeft,JustifyCenter,JustifyRight,JustifyFull': function(command) {
608 var name = 'align' + command.substring(7);
609 var nodes = selection.isCollapsed() ? [dom.getParent(selection.getNode(), dom.isBlock)] : selection.getSelectedBlocks();
610 var matches = map(nodes, function(node) {
611 return !!formatter.matchNode(node, name);
613 return inArray(matches, TRUE) !== -1;
616 'Bold,Italic,Underline,Strikethrough,Superscript,Subscript': function(command) {
617 return isFormatMatch(command);
620 mceBlockQuote: function() {
621 return isFormatMatch('blockquote');
624 Outdent: function() {
627 if (settings.inline_styles) {
628 if ((node = dom.getParent(selection.getStart(), dom.isBlock)) && parseInt(node.style.paddingLeft, 10) > 0) {
632 if ((node = dom.getParent(selection.getEnd(), dom.isBlock)) && parseInt(node.style.paddingLeft, 10) > 0) {
638 queryCommandState('InsertUnorderedList') ||
639 queryCommandState('InsertOrderedList') ||
640 (!settings.inline_styles && !!dom.getParent(selection.getNode(), 'BLOCKQUOTE'))
644 'InsertUnorderedList,InsertOrderedList': function(command) {
645 var list = dom.getParent(selection.getNode(), 'ul,ol');
649 command === 'insertunorderedlist' && list.tagName === 'UL' ||
650 command === 'insertorderedlist' && list.tagName === 'OL'
655 // Add queryCommandValue overrides
657 'FontSize,FontName': function(command) {
658 var value = 0, parent;
660 if ((parent = dom.getParent(selection.getNode(), 'span'))) {
661 if (command == 'fontsize') {
662 value = parent.style.fontSize;
664 value = parent.style.fontFamily.replace(/, /g, ',').replace(/[\'\"]/g, '').toLowerCase();
672 // Add undo manager logic
675 editor.undoManager.undo();
679 editor.undoManager.redo();