EditorCommands.js 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721
  1. /**
  2. * EditorCommands.js
  3. *
  4. * Copyright, Moxiecode Systems AB
  5. * Released under LGPL License.
  6. *
  7. * License: http://www.tinymce.com/license
  8. * Contributing: http://www.tinymce.com/contributing
  9. */
  10. /**
  11. * This class enables you to add custom editor commands and it contains
  12. * overrides for native browser commands to address various bugs and issues.
  13. *
  14. * @class tinymce.EditorCommands
  15. */
  16. define("tinymce/EditorCommands", [
  17. "tinymce/html/Serializer",
  18. "tinymce/Env",
  19. "tinymce/util/Tools"
  20. ], function(Serializer, Env, Tools) {
  21. // Added for compression purposes
  22. var each = Tools.each, extend = Tools.extend;
  23. var map = Tools.map, inArray = Tools.inArray, explode = Tools.explode;
  24. var isGecko = Env.gecko, isIE = Env.ie;
  25. var TRUE = true, FALSE = false;
  26. return function(editor) {
  27. var dom = editor.dom,
  28. selection = editor.selection,
  29. commands = {state: {}, exec: {}, value: {}},
  30. settings = editor.settings,
  31. formatter = editor.formatter,
  32. bookmark;
  33. /**
  34. * Executes the specified command.
  35. *
  36. * @method execCommand
  37. * @param {String} command Command to execute.
  38. * @param {Boolean} ui Optional user interface state.
  39. * @param {Object} value Optional value for command.
  40. * @return {Boolean} true/false if the command was found or not.
  41. */
  42. function execCommand(command, ui, value) {
  43. var func;
  44. command = command.toLowerCase();
  45. if ((func = commands.exec[command])) {
  46. func(command, ui, value);
  47. return TRUE;
  48. }
  49. return FALSE;
  50. }
  51. /**
  52. * Queries the current state for a command for example if the current selection is "bold".
  53. *
  54. * @method queryCommandState
  55. * @param {String} command Command to check the state of.
  56. * @return {Boolean/Number} true/false if the selected contents is bold or not, -1 if it's not found.
  57. */
  58. function queryCommandState(command) {
  59. var func;
  60. command = command.toLowerCase();
  61. if ((func = commands.state[command])) {
  62. return func(command);
  63. }
  64. return -1;
  65. }
  66. /**
  67. * Queries the command value for example the current fontsize.
  68. *
  69. * @method queryCommandValue
  70. * @param {String} command Command to check the value of.
  71. * @return {Object} Command value of false if it's not found.
  72. */
  73. function queryCommandValue(command) {
  74. var func;
  75. command = command.toLowerCase();
  76. if ((func = commands.value[command])) {
  77. return func(command);
  78. }
  79. return FALSE;
  80. }
  81. /**
  82. * Adds commands to the command collection.
  83. *
  84. * @method addCommands
  85. * @param {Object} command_list Name/value collection with commands to add, the names can also be comma separated.
  86. * @param {String} type Optional type to add, defaults to exec. Can be value or state as well.
  87. */
  88. function addCommands(command_list, type) {
  89. type = type || 'exec';
  90. each(command_list, function(callback, command) {
  91. each(command.toLowerCase().split(','), function(command) {
  92. commands[type][command] = callback;
  93. });
  94. });
  95. }
  96. // Expose public methods
  97. extend(this, {
  98. execCommand: execCommand,
  99. queryCommandState: queryCommandState,
  100. queryCommandValue: queryCommandValue,
  101. addCommands: addCommands
  102. });
  103. // Private methods
  104. function execNativeCommand(command, ui, value) {
  105. if (ui === undefined) {
  106. ui = FALSE;
  107. }
  108. if (value === undefined) {
  109. value = null;
  110. }
  111. return editor.getDoc().execCommand(command, ui, value);
  112. }
  113. function isFormatMatch(name) {
  114. return formatter.match(name);
  115. }
  116. function toggleFormat(name, value) {
  117. formatter.toggle(name, value ? {value: value} : undefined);
  118. editor.nodeChanged();
  119. }
  120. function storeSelection(type) {
  121. bookmark = selection.getBookmark(type);
  122. }
  123. function restoreSelection() {
  124. selection.moveToBookmark(bookmark);
  125. }
  126. // Add execCommand overrides
  127. addCommands({
  128. // Ignore these, added for compatibility
  129. 'mceResetDesignMode,mceBeginUndoLevel': function() {},
  130. // Add undo manager logic
  131. 'mceEndUndoLevel,mceAddUndoLevel': function() {
  132. editor.undoManager.add();
  133. },
  134. 'Cut,Copy,Paste': function(command) {
  135. var doc = editor.getDoc(), failed;
  136. // Try executing the native command
  137. try {
  138. execNativeCommand(command);
  139. } catch (ex) {
  140. // Command failed
  141. failed = TRUE;
  142. }
  143. // Present alert message about clipboard access not being available
  144. if (failed || !doc.queryCommandSupported(command)) {
  145. var msg = editor.translate(
  146. "Your browser doesn't support direct access to the clipboard. " +
  147. "Please use the Ctrl+X/C/V keyboard shortcuts instead."
  148. );
  149. if (Env.mac) {
  150. msg = msg.replace(/Ctrl\+/g, '\u2318+');
  151. }
  152. editor.windowManager.alert(msg);
  153. }
  154. },
  155. // Override unlink command
  156. unlink: function() {
  157. if (selection.isCollapsed()) {
  158. var elm = selection.getNode();
  159. if (elm.tagName == 'A') {
  160. editor.dom.remove(elm, true);
  161. }
  162. return;
  163. }
  164. formatter.remove("link");
  165. },
  166. // Override justify commands to use the text formatter engine
  167. 'JustifyLeft,JustifyCenter,JustifyRight,JustifyFull': function(command) {
  168. var align = command.substring(7);
  169. if (align == 'full') {
  170. align = 'justify';
  171. }
  172. // Remove all other alignments first
  173. each('left,center,right,justify'.split(','), function(name) {
  174. if (align != name) {
  175. formatter.remove('align' + name);
  176. }
  177. });
  178. toggleFormat('align' + align);
  179. execCommand('mceRepaint');
  180. },
  181. // Override list commands to fix WebKit bug
  182. 'InsertUnorderedList,InsertOrderedList': function(command) {
  183. var listElm, listParent;
  184. execNativeCommand(command);
  185. // WebKit produces lists within block elements so we need to split them
  186. // we will replace the native list creation logic to custom logic later on
  187. // TODO: Remove this when the list creation logic is removed
  188. listElm = dom.getParent(selection.getNode(), 'ol,ul');
  189. if (listElm) {
  190. listParent = listElm.parentNode;
  191. // If list is within a text block then split that block
  192. if (/^(H[1-6]|P|ADDRESS|PRE)$/.test(listParent.nodeName)) {
  193. storeSelection();
  194. dom.split(listParent, listElm);
  195. restoreSelection();
  196. }
  197. }
  198. },
  199. // Override commands to use the text formatter engine
  200. 'Bold,Italic,Underline,Strikethrough,Superscript,Subscript': function(command) {
  201. toggleFormat(command);
  202. },
  203. // Override commands to use the text formatter engine
  204. 'ForeColor,HiliteColor,FontName': function(command, ui, value) {
  205. toggleFormat(command, value);
  206. },
  207. FontSize: function(command, ui, value) {
  208. var fontClasses, fontSizes;
  209. // Convert font size 1-7 to styles
  210. if (value >= 1 && value <= 7) {
  211. fontSizes = explode(settings.font_size_style_values);
  212. fontClasses = explode(settings.font_size_classes);
  213. if (fontClasses) {
  214. value = fontClasses[value - 1] || value;
  215. } else {
  216. value = fontSizes[value - 1] || value;
  217. }
  218. }
  219. toggleFormat(command, value);
  220. },
  221. RemoveFormat: function(command) {
  222. formatter.remove(command);
  223. },
  224. mceBlockQuote: function() {
  225. toggleFormat('blockquote');
  226. },
  227. FormatBlock: function(command, ui, value) {
  228. return toggleFormat(value || 'p');
  229. },
  230. mceCleanup: function() {
  231. var bookmark = selection.getBookmark();
  232. editor.setContent(editor.getContent({cleanup: TRUE}), {cleanup: TRUE});
  233. selection.moveToBookmark(bookmark);
  234. },
  235. mceRemoveNode: function(command, ui, value) {
  236. var node = value || selection.getNode();
  237. // Make sure that the body node isn't removed
  238. if (node != editor.getBody()) {
  239. storeSelection();
  240. editor.dom.remove(node, TRUE);
  241. restoreSelection();
  242. }
  243. },
  244. mceSelectNodeDepth: function(command, ui, value) {
  245. var counter = 0;
  246. dom.getParent(selection.getNode(), function(node) {
  247. if (node.nodeType == 1 && counter++ == value) {
  248. selection.select(node);
  249. return FALSE;
  250. }
  251. }, editor.getBody());
  252. },
  253. mceSelectNode: function(command, ui, value) {
  254. selection.select(value);
  255. },
  256. mceInsertContent: function(command, ui, value) {
  257. var parser, serializer, parentNode, rootNode, fragment, args;
  258. var marker, rng, node, node2, bookmarkHtml;
  259. function trimOrPaddLeftRight(html) {
  260. var rng, container, offset;
  261. rng = selection.getRng(true);
  262. container = rng.startContainer;
  263. offset = rng.startOffset;
  264. function hasSiblingText(siblingName) {
  265. return container[siblingName] && container[siblingName].nodeType == 3;
  266. }
  267. if (container.nodeType == 3) {
  268. if (offset > 0) {
  269. html = html.replace(/^&nbsp;/, ' ');
  270. } else if (!hasSiblingText('previousSibling')) {
  271. html = html.replace(/^ /, '&nbsp;');
  272. }
  273. if (offset < container.length) {
  274. html = html.replace(/&nbsp;(<br>|)$/, ' ');
  275. } else if (!hasSiblingText('nextSibling')) {
  276. html = html.replace(/(&nbsp;| )(<br>|)$/, '&nbsp;');
  277. }
  278. }
  279. return html;
  280. }
  281. // Check for whitespace before/after value
  282. if (/^ | $/.test(value)) {
  283. value = trimOrPaddLeftRight(value);
  284. }
  285. // Setup parser and serializer
  286. parser = editor.parser;
  287. serializer = new Serializer({}, editor.schema);
  288. bookmarkHtml = '<span id="mce_marker" data-mce-type="bookmark">&#xFEFF;&#200B;</span>';
  289. // Run beforeSetContent handlers on the HTML to be inserted
  290. args = {content: value, format: 'html', selection: true};
  291. editor.fire('BeforeSetContent', args);
  292. value = args.content;
  293. // Add caret at end of contents if it's missing
  294. if (value.indexOf('{$caret}') == -1) {
  295. value += '{$caret}';
  296. }
  297. // Replace the caret marker with a span bookmark element
  298. value = value.replace(/\{\$caret\}/, bookmarkHtml);
  299. // If selection is at <body>|<p></p> then move it into <body><p>|</p>
  300. rng = selection.getRng();
  301. var caretElement = rng.startContainer || (rng.parentElement ? rng.parentElement() : null);
  302. var body = editor.getBody();
  303. if (caretElement === body && selection.isCollapsed()) {
  304. if (dom.isBlock(body.firstChild) && dom.isEmpty(body.firstChild)) {
  305. rng = dom.createRng();
  306. rng.setStart(body.firstChild, 0);
  307. rng.setEnd(body.firstChild, 0);
  308. selection.setRng(rng);
  309. }
  310. }
  311. // Insert node maker where we will insert the new HTML and get it's parent
  312. if (!selection.isCollapsed()) {
  313. editor.getDoc().execCommand('Delete', false, null);
  314. }
  315. parentNode = selection.getNode();
  316. // Parse the fragment within the context of the parent node
  317. var parserArgs = {context: parentNode.nodeName.toLowerCase()};
  318. fragment = parser.parse(value, parserArgs);
  319. // Move the caret to a more suitable location
  320. node = fragment.lastChild;
  321. if (node.attr('id') == 'mce_marker') {
  322. marker = node;
  323. for (node = node.prev; node; node = node.walk(true)) {
  324. if (node.type == 3 || !dom.isBlock(node.name)) {
  325. node.parent.insert(marker, node, node.name === 'br');
  326. break;
  327. }
  328. }
  329. }
  330. // If parser says valid we can insert the contents into that parent
  331. if (!parserArgs.invalid) {
  332. value = serializer.serialize(fragment);
  333. // Check if parent is empty or only has one BR element then set the innerHTML of that parent
  334. node = parentNode.firstChild;
  335. node2 = parentNode.lastChild;
  336. if (!node || (node === node2 && node.nodeName === 'BR')) {
  337. dom.setHTML(parentNode, value);
  338. } else {
  339. selection.setContent(value);
  340. }
  341. } else {
  342. // If the fragment was invalid within that context then we need
  343. // to parse and process the parent it's inserted into
  344. // Insert bookmark node and get the parent
  345. selection.setContent(bookmarkHtml);
  346. parentNode = selection.getNode();
  347. rootNode = editor.getBody();
  348. // Opera will return the document node when selection is in root
  349. if (parentNode.nodeType == 9) {
  350. parentNode = node = rootNode;
  351. } else {
  352. node = parentNode;
  353. }
  354. // Find the ancestor just before the root element
  355. while (node !== rootNode) {
  356. parentNode = node;
  357. node = node.parentNode;
  358. }
  359. // Get the outer/inner HTML depending on if we are in the root and parser and serialize that
  360. value = parentNode == rootNode ? rootNode.innerHTML : dom.getOuterHTML(parentNode);
  361. value = serializer.serialize(
  362. parser.parse(
  363. // Need to replace by using a function since $ in the contents would otherwise be a problem
  364. value.replace(/<span (id="mce_marker"|id=mce_marker).+?<\/span>/i, function() {
  365. return serializer.serialize(fragment);
  366. })
  367. )
  368. );
  369. // Set the inner/outer HTML depending on if we are in the root or not
  370. if (parentNode == rootNode) {
  371. dom.setHTML(rootNode, value);
  372. } else {
  373. dom.setOuterHTML(parentNode, value);
  374. }
  375. }
  376. marker = dom.get('mce_marker');
  377. selection.scrollIntoView(marker);
  378. // Move selection before marker and remove it
  379. rng = dom.createRng();
  380. // If previous sibling is a text node set the selection to the end of that node
  381. node = marker.previousSibling;
  382. if (node && node.nodeType == 3) {
  383. rng.setStart(node, node.nodeValue.length);
  384. // TODO: Why can't we normalize on IE
  385. if (!isIE) {
  386. node2 = marker.nextSibling;
  387. if (node2 && node2.nodeType == 3) {
  388. node.appendData(node2.data);
  389. node2.parentNode.removeChild(node2);
  390. }
  391. }
  392. } else {
  393. // If the previous sibling isn't a text node or doesn't exist set the selection before the marker node
  394. rng.setStartBefore(marker);
  395. rng.setEndBefore(marker);
  396. }
  397. // Remove the marker node and set the new range
  398. dom.remove(marker);
  399. selection.setRng(rng);
  400. // Dispatch after event and add any visual elements needed
  401. editor.fire('SetContent', args);
  402. editor.addVisual();
  403. },
  404. mceInsertRawHTML: function(command, ui, value) {
  405. selection.setContent('tiny_mce_marker');
  406. editor.setContent(
  407. editor.getContent().replace(/tiny_mce_marker/g, function() {
  408. return value;
  409. })
  410. );
  411. },
  412. mceToggleFormat: function(command, ui, value) {
  413. toggleFormat(value);
  414. },
  415. mceSetContent: function(command, ui, value) {
  416. editor.setContent(value);
  417. },
  418. 'Indent,Outdent': function(command) {
  419. var intentValue, indentUnit, value;
  420. // Setup indent level
  421. intentValue = settings.indentation;
  422. indentUnit = /[a-z%]+$/i.exec(intentValue);
  423. intentValue = parseInt(intentValue, 10);
  424. if (!queryCommandState('InsertUnorderedList') && !queryCommandState('InsertOrderedList')) {
  425. // If forced_root_blocks is set to false we don't have a block to indent so lets create a div
  426. if (!settings.forced_root_block && !dom.getParent(selection.getNode(), dom.isBlock)) {
  427. formatter.apply('div');
  428. }
  429. each(selection.getSelectedBlocks(), function(element) {
  430. if (element.nodeName != "LI") {
  431. var indentStyleName = editor.getParam('indent_use_margin', false) ? 'margin' : 'padding';
  432. indentStyleName += dom.getStyle(element, 'direction', true) == 'rtl' ? 'Right' : 'Left';
  433. if (command == 'outdent') {
  434. value = Math.max(0, parseInt(element.style[indentStyleName] || 0, 10) - intentValue);
  435. dom.setStyle(element, indentStyleName, value ? value + indentUnit : '');
  436. } else {
  437. value = (parseInt(element.style[indentStyleName] || 0, 10) + intentValue) + indentUnit;
  438. dom.setStyle(element, indentStyleName, value);
  439. }
  440. }
  441. });
  442. } else {
  443. execNativeCommand(command);
  444. }
  445. },
  446. mceRepaint: function() {
  447. if (isGecko) {
  448. try {
  449. storeSelection(TRUE);
  450. if (selection.getSel()) {
  451. selection.getSel().selectAllChildren(editor.getBody());
  452. }
  453. selection.collapse(TRUE);
  454. restoreSelection();
  455. } catch (ex) {
  456. // Ignore
  457. }
  458. }
  459. },
  460. InsertHorizontalRule: function() {
  461. editor.execCommand('mceInsertContent', false, '<hr />');
  462. },
  463. mceToggleVisualAid: function() {
  464. editor.hasVisual = !editor.hasVisual;
  465. editor.addVisual();
  466. },
  467. mceReplaceContent: function(command, ui, value) {
  468. editor.execCommand('mceInsertContent', false, value.replace(/\{\$selection\}/g, selection.getContent({format: 'text'})));
  469. },
  470. mceInsertLink: function(command, ui, value) {
  471. var anchor;
  472. if (typeof(value) == 'string') {
  473. value = {href: value};
  474. }
  475. anchor = dom.getParent(selection.getNode(), 'a');
  476. // Spaces are never valid in URLs and it's a very common mistake for people to make so we fix it here.
  477. value.href = value.href.replace(' ', '%20');
  478. // Remove existing links if there could be child links or that the href isn't specified
  479. if (!anchor || !value.href) {
  480. formatter.remove('link');
  481. }
  482. // Apply new link to selection
  483. if (value.href) {
  484. formatter.apply('link', value, anchor);
  485. }
  486. },
  487. selectAll: function() {
  488. var root = dom.getRoot(), rng;
  489. if (selection.getRng().setStart) {
  490. rng = dom.createRng();
  491. rng.setStart(root, 0);
  492. rng.setEnd(root, root.childNodes.length);
  493. selection.setRng(rng);
  494. } else {
  495. // IE will render it's own root level block elements and sometimes
  496. // even put font elements in them when the user starts typing. So we need to
  497. // move the selection to a more suitable element from this:
  498. // <body>|<p></p></body> to this: <body><p>|</p></body>
  499. rng = selection.getRng();
  500. if (!rng.item) {
  501. rng.moveToElementText(root);
  502. rng.select();
  503. }
  504. }
  505. },
  506. "delete": function() {
  507. execNativeCommand("Delete");
  508. // Check if body is empty after the delete call if so then set the contents
  509. // to an empty string and move the caret to any block produced by that operation
  510. // this fixes the issue with root blocks not being properly produced after a delete call on IE
  511. var body = editor.getBody();
  512. if (dom.isEmpty(body)) {
  513. editor.setContent('');
  514. if (body.firstChild && dom.isBlock(body.firstChild)) {
  515. editor.selection.setCursorLocation(body.firstChild, 0);
  516. } else {
  517. editor.selection.setCursorLocation(body, 0);
  518. }
  519. }
  520. },
  521. mceNewDocument: function() {
  522. editor.setContent('');
  523. }
  524. });
  525. // Add queryCommandState overrides
  526. addCommands({
  527. // Override justify commands
  528. 'JustifyLeft,JustifyCenter,JustifyRight,JustifyFull': function(command) {
  529. var name = 'align' + command.substring(7);
  530. var nodes = selection.isCollapsed() ? [dom.getParent(selection.getNode(), dom.isBlock)] : selection.getSelectedBlocks();
  531. var matches = map(nodes, function(node) {
  532. return !!formatter.matchNode(node, name);
  533. });
  534. return inArray(matches, TRUE) !== -1;
  535. },
  536. 'Bold,Italic,Underline,Strikethrough,Superscript,Subscript': function(command) {
  537. return isFormatMatch(command);
  538. },
  539. mceBlockQuote: function() {
  540. return isFormatMatch('blockquote');
  541. },
  542. Outdent: function() {
  543. var node;
  544. if (settings.inline_styles) {
  545. if ((node = dom.getParent(selection.getStart(), dom.isBlock)) && parseInt(node.style.paddingLeft, 10) > 0) {
  546. return TRUE;
  547. }
  548. if ((node = dom.getParent(selection.getEnd(), dom.isBlock)) && parseInt(node.style.paddingLeft, 10) > 0) {
  549. return TRUE;
  550. }
  551. }
  552. return (
  553. queryCommandState('InsertUnorderedList') ||
  554. queryCommandState('InsertOrderedList') ||
  555. (!settings.inline_styles && !!dom.getParent(selection.getNode(), 'BLOCKQUOTE'))
  556. );
  557. },
  558. 'InsertUnorderedList,InsertOrderedList': function(command) {
  559. var list = dom.getParent(selection.getNode(), 'ul,ol');
  560. return list &&
  561. (
  562. command === 'insertunorderedlist' && list.tagName === 'UL' ||
  563. command === 'insertorderedlist' && list.tagName === 'OL'
  564. );
  565. }
  566. }, 'state');
  567. // Add queryCommandValue overrides
  568. addCommands({
  569. 'FontSize,FontName': function(command) {
  570. var value = 0, parent;
  571. if ((parent = dom.getParent(selection.getNode(), 'span'))) {
  572. if (command == 'fontsize') {
  573. value = parent.style.fontSize;
  574. } else {
  575. value = parent.style.fontFamily.replace(/, /g, ',').replace(/[\'\"]/g, '').toLowerCase();
  576. }
  577. }
  578. return value;
  579. }
  580. }, 'value');
  581. // Add undo manager logic
  582. addCommands({
  583. Undo: function() {
  584. editor.undoManager.undo();
  585. },
  586. Redo: function() {
  587. editor.undoManager.redo();
  588. }
  589. });
  590. };
  591. });