test_utils.js 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900
  1. odoo.define('web_editor.test_utils', function (require) {
  2. "use strict";
  3. var ajax = require('web.ajax');
  4. var MockServer = require('web.MockServer');
  5. var testUtils = require('web.test_utils');
  6. var OdooEditorLib = require('@web_editor/js/editor/odoo-editor/src/OdooEditor');
  7. var Widget = require('web.Widget');
  8. var Wysiwyg = require('web_editor.wysiwyg');
  9. var options = require('web_editor.snippets.options');
  10. const { TABLE_ATTRIBUTES, TABLE_STYLES } = require('@web_editor/js/backend/convert_inline');
  11. const COLOR_PICKER_TEMPLATE = `
  12. <colorpicker>
  13. <div class="o_colorpicker_section" data-name="theme" data-display="Theme Colors" data-icon-class="fa fa-flask">
  14. <button data-color="o-color-1"/>
  15. <button data-color="o-color-2"/>
  16. <button data-color="o-color-3"/>
  17. <button data-color="o-color-4"/>
  18. <button data-color="o-color-5"/>
  19. </div>
  20. <div class="o_colorpicker_section" data-name="transparent_grayscale" data-display="Transparent Colors" data-icon-class="fa fa-eye-slash">
  21. <button class="o_btn_transparent"/>
  22. <button data-color="black-25"/>
  23. <button data-color="black-50"/>
  24. <button data-color="black-75"/>
  25. <button data-color="white-25"/>
  26. <button data-color="white-50"/>
  27. <button data-color="white-75"/>
  28. </div>
  29. <div class="o_colorpicker_section" data-name="common" data-display="Common Colors" data-icon-class="fa fa-paint-brush">
  30. <button data-color="black"></button>
  31. <button data-color="900"></button>
  32. <button data-color="800"></button>
  33. <button data-color="700" class="d-none"></button>
  34. <button data-color="600"></button>
  35. <button data-color="500" class="d-none"></button>
  36. <button data-color="400"></button>
  37. <button data-color="300" class="d-none"></button>
  38. <button data-color="200"></button>
  39. <button data-color="100"></button>
  40. <button data-color="white"></button>
  41. </div>
  42. </colorpicker>
  43. `;
  44. const SNIPPETS_TEMPLATE = `
  45. <h2 id="snippets_menu">Add blocks</h2>
  46. <div id="o_scroll">
  47. <div id="snippet_structure" class="o_panel">
  48. <div class="o_panel_header">First Panel</div>
  49. <div class="o_panel_body">
  50. <div name="Separator" data-oe-type="snippet" data-oe-thumbnail="/web_editor/static/src/img/snippets_thumbs/s_hr.svg">
  51. <div class="s_hr pt32 pb32">
  52. <hr class="s_hr_1px s_hr_solid w-100 mx-auto"/>
  53. </div>
  54. </div>
  55. <div name="Content" data-oe-type="snippet" data-oe-thumbnail="/website/static/src/img/snippets_thumbs/s_text_block.png">
  56. <section name="Content+Options" class="test_option_all pt32 pb32" data-oe-type="snippet" data-oe-thumbnail="/website/static/src/img/snippets_thumbs/s_text_block.png">
  57. <div class="container">
  58. <div class="row">
  59. <div class="col-lg-10 offset-lg-1 pt32 pb32">
  60. <h2>Title</h2>
  61. <p class="lead o_default_snippet_text">Content</p>
  62. </div>
  63. </div>
  64. </div>
  65. </section>
  66. </div>
  67. </div>
  68. </div>
  69. </div>
  70. <div id="snippet_options" class="d-none">
  71. <div data-js="many2one" data-selector="[data-oe-many2one-model]:not([data-oe-readonly])" data-no-check="true"/>
  72. <div data-js="content"
  73. data-selector=".s_hr, .test_option_all"
  74. data-drop-in=".note-editable"
  75. data-drop-near="p, h1, h2, h3, blockquote, .s_hr"/>
  76. <div data-js="sizing_y" data-selector=".s_hr, .test_option_all"/>
  77. <div data-selector=".test_option_all">
  78. <we-colorpicker string="Background Color" data-select-style="true" data-css-property="background-color" data-color-prefix="bg-"/>
  79. </div>
  80. <div data-js="BackgroundImage" data-selector=".test_option_all">
  81. <we-button data-choose-image="true" data-no-preview="true">
  82. <i class="fa fa-picture-o"/> Background Image
  83. </we-button>
  84. </div>
  85. <div data-js="option_test" data-selector=".s_hr">
  86. <we-select string="Alignment">
  87. <we-button data-select-class="align-items-start">Top</we-button>
  88. <we-button data-select-class="align-items-center">Middle</we-button>
  89. <we-button data-select-class="align-items-end">Bottom</we-button>
  90. <we-button data-select-class="align-items-stretch">Equal height</we-button>
  91. </we-select>
  92. </div>
  93. </div>`;
  94. MockServer.include({
  95. //--------------------------------------------------------------------------
  96. // Private
  97. //--------------------------------------------------------------------------
  98. /**
  99. * @override
  100. * @private
  101. * @returns {Promise}
  102. */
  103. async _performRpc(route, args) {
  104. if (args.model === "ir.ui.view" && args.method === 'render_public_asset') {
  105. if (args.args[0] === "web_editor.colorpicker") {
  106. return COLOR_PICKER_TEMPLATE;
  107. }
  108. if (args.args[0] === "web_editor.snippets") {
  109. return SNIPPETS_TEMPLATE;
  110. }
  111. }
  112. return this._super(...arguments);
  113. },
  114. });
  115. /**
  116. * Options with animation and edition for test.
  117. */
  118. options.registry.option_test = options.Class.extend({
  119. cleanForSave: function () {
  120. this.$target.addClass('cleanForSave');
  121. },
  122. onBuilt: function () {
  123. this.$target.addClass('built');
  124. },
  125. onBlur: function () {
  126. this.$target.removeClass('focus');
  127. },
  128. onClone: function () {
  129. this.$target.addClass('clone');
  130. this.$target.removeClass('focus');
  131. },
  132. onFocus: function () {
  133. this.$target.addClass('focus');
  134. },
  135. onMove: function () {
  136. this.$target.addClass('move');
  137. },
  138. onRemove: function () {
  139. this.$target.closest('.note-editable').addClass('snippet_has_removed');
  140. },
  141. });
  142. /**
  143. * Constructor WysiwygTest why editable and unbreakable node used in test.
  144. */
  145. var WysiwygTest = Wysiwyg.extend({
  146. _parentToDestroyForTest: null,
  147. /**
  148. * Override 'destroy' of discuss so that it calls 'destroy' on the parent.
  149. *
  150. * @override
  151. */
  152. destroy: function () {
  153. unpatch();
  154. this._super();
  155. this.$target.remove();
  156. this._parentToDestroyForTest.destroy();
  157. },
  158. });
  159. function patch() {
  160. testUtils.mock.patch(ajax, {
  161. loadAsset: function (xmlId) {
  162. if (xmlId === 'template.assets') {
  163. return Promise.resolve({
  164. cssLibs: [],
  165. cssContents: ['body {background-color: red;}']
  166. });
  167. }
  168. if (xmlId === 'template.assets_all_style') {
  169. return Promise.resolve({
  170. cssLibs: $('link[href]:not([type="image/x-icon"])').map(function () {
  171. return $(this).attr('href');
  172. }).get(),
  173. cssContents: ['body {background-color: red;}']
  174. });
  175. }
  176. throw 'Wrong template';
  177. },
  178. });
  179. }
  180. function unpatch() {
  181. testUtils.mock.unpatch(ajax);
  182. }
  183. /**
  184. * @param {object} data
  185. * @returns {object}
  186. */
  187. function wysiwygData(data) {
  188. return _.defaults({}, data, {
  189. 'ir.ui.view': {
  190. fields: {
  191. display_name: {
  192. string: "Displayed name",
  193. type: "char",
  194. },
  195. },
  196. records: [],
  197. render_template(args) {
  198. if (args[0] === 'web_editor.colorpicker') {
  199. return COLOR_PICKER_TEMPLATE;
  200. }
  201. if (args[0] === 'web_editor.snippets') {
  202. return SNIPPETS_TEMPLATE;
  203. }
  204. },
  205. },
  206. 'ir.attachment': {
  207. fields: {
  208. display_name: {
  209. string: "display_name",
  210. type: 'char',
  211. },
  212. description: {
  213. string: "description",
  214. type: 'char',
  215. },
  216. mimetype: {
  217. string: "mimetype",
  218. type: 'char',
  219. },
  220. checksum: {
  221. string: "checksum",
  222. type: 'char',
  223. },
  224. url: {
  225. string: "url",
  226. type: 'char',
  227. },
  228. type: {
  229. string: "type",
  230. type: 'char',
  231. },
  232. res_id: {
  233. string: "res_id",
  234. type: 'integer',
  235. },
  236. res_model: {
  237. string: "res_model",
  238. type: 'char',
  239. },
  240. public: {
  241. string: "public",
  242. type: 'boolean',
  243. },
  244. access_token: {
  245. string: "access_token",
  246. type: 'char',
  247. },
  248. image_src: {
  249. string: "image_src",
  250. type: 'char',
  251. },
  252. image_width: {
  253. string: "image_width",
  254. type: 'integer',
  255. },
  256. image_height: {
  257. string: "image_height",
  258. type: 'integer',
  259. },
  260. original_id: {
  261. string: "original_id",
  262. type: 'many2one',
  263. relation: 'ir.attachment',
  264. },
  265. },
  266. records: [{
  267. id: 1,
  268. name: 'image',
  269. description: '',
  270. mimetype: 'image/png',
  271. checksum: false,
  272. url: '/web/image/123/transparent.png',
  273. type: 'url',
  274. res_id: 0,
  275. res_model: false,
  276. public: true,
  277. access_token: false,
  278. image_src: '/web/image/123/transparent.png',
  279. image_width: 256,
  280. image_height: 256,
  281. }],
  282. generate_access_token: function () {
  283. return;
  284. },
  285. },
  286. });
  287. }
  288. /**
  289. * Create the wysiwyg instance for test (contains patch, usefull ir.ui.view, snippets).
  290. *
  291. * @param {object} params
  292. */
  293. async function createWysiwyg(params) {
  294. patch();
  295. params.data = wysiwygData(params.data);
  296. var parent = new Widget();
  297. await testUtils.mock.addMockEnvironment(parent, params);
  298. var wysiwygOptions = _.extend({}, params.wysiwygOptions, {
  299. recordInfo: {
  300. context: {},
  301. res_model: 'module.test',
  302. res_id: 1,
  303. },
  304. useOnlyTestUnbreakable: params.useOnlyTestUnbreakable,
  305. });
  306. var wysiwyg = new WysiwygTest(parent, wysiwygOptions);
  307. wysiwyg._parentToDestroyForTest = parent;
  308. var $textarea = $('<textarea/>');
  309. if (wysiwygOptions.value) {
  310. $textarea.val(wysiwygOptions.value);
  311. }
  312. var selector = params.debug ? 'body' : '#qunit-fixture';
  313. $textarea.prependTo($(selector));
  314. if (params.debug) {
  315. $('body').addClass('debug');
  316. }
  317. return wysiwyg.attachTo($textarea).then(function () {
  318. if (wysiwygOptions.snippets) {
  319. var defSnippets = testUtils.makeTestPromise();
  320. testUtils.mock.intercept(wysiwyg, "snippets_loaded", function () {
  321. defSnippets.resolve(wysiwyg);
  322. });
  323. return defSnippets;
  324. }
  325. return wysiwyg;
  326. });
  327. }
  328. /**
  329. * Char codes.
  330. */
  331. var keyboardMap = {
  332. "8": "BACKSPACE",
  333. "9": "TAB",
  334. "13": "ENTER",
  335. "16": "SHIFT",
  336. "17": "CONTROL",
  337. "18": "ALT",
  338. "19": "PAUSE",
  339. "20": "CAPS_LOCK",
  340. "27": "ESCAPE",
  341. "32": "SPACE",
  342. "33": "PAGE_UP",
  343. "34": "PAGE_DOWN",
  344. "35": "END",
  345. "36": "HOME",
  346. "37": "LEFT",
  347. "38": "UP",
  348. "39": "RIGHT",
  349. "40": "DOWN",
  350. "45": "INSERT",
  351. "46": "DELETE",
  352. "91": "OS_KEY", // 'left command': Windows Key (Windows) or Command Key (Mac)
  353. "93": "CONTEXT_MENU", // 'right command'
  354. };
  355. _.each(_.range(40, 127), function (keyCode) {
  356. if (!keyboardMap[keyCode]) {
  357. keyboardMap[keyCode] = String.fromCharCode(keyCode);
  358. }
  359. });
  360. /**
  361. * Perform a series of tests (`keyboardTests`) for using keyboard inputs.
  362. *
  363. * @see wysiwyg_keyboard_tests.js
  364. * @see wysiwyg_tests.js
  365. *
  366. * @param {jQuery} $editable
  367. * @param {object} assert
  368. * @param {object[]} keyboardTests
  369. * @param {string} keyboardTests.name
  370. * @param {string} keyboardTests.content
  371. * @param {object[]} keyboardTests.steps
  372. * @param {string} keyboardTests.steps.start
  373. * @param {string} [keyboardTests.steps.end] default: steps.start
  374. * @param {string} keyboardTests.steps.key
  375. * @param {object} keyboardTests.test
  376. * @param {string} [keyboardTests.test.content]
  377. * @param {string} [keyboardTests.test.start]
  378. * @param {string} [keyboardTests.test.end] default: steps.start
  379. * @param {function($editable, assert)} [keyboardTests.test.check]
  380. * @param {Number} addTests
  381. */
  382. var testKeyboard = function ($editable, assert, keyboardTests, addTests) {
  383. var tests = _.compact(_.pluck(keyboardTests, 'test'));
  384. var testNumber = _.compact(_.pluck(tests, 'start')).length +
  385. _.compact(_.pluck(tests, 'content')).length +
  386. _.compact(_.pluck(tests, 'check')).length +
  387. (addTests | 0);
  388. assert.expect(testNumber);
  389. function keydown(target, keypress) {
  390. var $target = $(target.tagName ? target : target.parentNode);
  391. if (!keypress.keyCode) {
  392. keypress.keyCode = +_.findKey(keyboardMap, function (key) {
  393. return key === keypress.key;
  394. });
  395. } else {
  396. keypress.key = keyboardMap[keypress.keyCode] || String.fromCharCode(keypress.keyCode);
  397. }
  398. var event = $.Event("keydown", keypress);
  399. $target.trigger(event);
  400. if (!event.isDefaultPrevented()) {
  401. if (keypress.key.length === 1) {
  402. textInput($target[0], keypress.key);
  403. } else {
  404. console.warn('Native "' + keypress.key + '" is not supported in test');
  405. }
  406. }
  407. $target.trigger($.Event("keyup", keypress));
  408. return $target;
  409. }
  410. function _select(selector) {
  411. // eg: ".class:contents()[0]->1" selects the first contents of the 'class' class, with an offset of 1
  412. var reDOMSelection = /^(.+?)(:contents(\(\)\[|\()([0-9]+)[\]|\)])?(->([0-9]+))?$/;
  413. var sel = selector.match(reDOMSelection);
  414. var $node = $editable.find(sel[1]);
  415. var point = {
  416. node: sel[3] ? $node.contents()[+sel[4]] : $node[0],
  417. offset: sel[5] ? +sel[6] : 0,
  418. };
  419. if (!point.node || point.offset > (point.node.tagName ? point.node.childNodes : point.node.textContent).length) {
  420. assert.notOk("Node not found: '" + selector + "' " + (point.node ? "(container: '" + (point.node.outerHTML || point.node.textContent) + "')" : ""));
  421. }
  422. return point;
  423. }
  424. function selectText(start, end) {
  425. start = _select(start);
  426. var target = start.node;
  427. $(target.tagName ? target : target.parentNode).trigger("mousedown");
  428. if (end) {
  429. end = _select(end);
  430. Wysiwyg.setRange(start.node, start.offset, end.node, end.offset);
  431. } else {
  432. Wysiwyg.setRange(start.node, start.offset);
  433. }
  434. target = end ? end.node : start.node;
  435. $(target.tagName ? target : target.parentNode).trigger('mouseup');
  436. }
  437. function nextPoint(point) {
  438. var node, offset;
  439. if (OdooEditorLib.nodeSize(point.node) === point.offset) {
  440. node = point.node.parentNode;
  441. offset = OdooEditorLib.childNodeIndex(point.node) + 1;
  442. } else if (point.node.hasChildNodes()) {
  443. node = point.node.childNodes[point.offset];
  444. offset = 0;
  445. } else {
  446. node = point.node;
  447. offset = point.offset + 1;
  448. }
  449. return {
  450. node: node,
  451. offset: offset
  452. };
  453. }
  454. function endOfAreaBetweenTwoNodes(point) {
  455. // move the position because some browser make the caret on the end of the previous area after normalize
  456. if (
  457. !point.node.tagName &&
  458. point.offset === point.node.textContent.length &&
  459. !/\S|\u00A0/.test(point.node.textContent)
  460. ) {
  461. point = nextPoint(nextPoint(point));
  462. while (point.node.tagName && point.node.textContent.length) {
  463. point = nextPoint(point);
  464. }
  465. }
  466. return point;
  467. }
  468. var defPollTest = Promise.resolve();
  469. function pollTest(test) {
  470. var def = Promise.resolve();
  471. $editable.data('wysiwyg').setValue(test.content);
  472. function poll(step) {
  473. var def = testUtils.makeTestPromise();
  474. if (step.start) {
  475. selectText(step.start, step.end);
  476. if (!Wysiwyg.getRange()) {
  477. throw 'Wrong range! \n' +
  478. 'Test: ' + test.name + '\n' +
  479. 'Selection: ' + step.start + '" to "' + step.end + '"\n' +
  480. 'DOM: ' + $editable.html();
  481. }
  482. }
  483. setTimeout(function () {
  484. if (step.keyCode || step.key) {
  485. var target = Wysiwyg.getRange().ec;
  486. if (window.location.search.indexOf('notrycatch') !== -1) {
  487. keydown(target, {
  488. key: step.key,
  489. keyCode: step.keyCode,
  490. ctrlKey: !!step.ctrlKey,
  491. shiftKey: !!step.shiftKey,
  492. altKey: !!step.altKey,
  493. metaKey: !!step.metaKey,
  494. });
  495. } else {
  496. try {
  497. keydown(target, {
  498. key: step.key,
  499. keyCode: step.keyCode,
  500. ctrlKey: !!step.ctrlKey,
  501. shiftKey: !!step.shiftKey,
  502. altKey: !!step.altKey,
  503. metaKey: !!step.metaKey,
  504. });
  505. } catch (e) {
  506. assert.notOk(e.name + '\n\n' + e.stack, test.name);
  507. }
  508. }
  509. }
  510. setTimeout(function () {
  511. if (step.keyCode || step.key) {
  512. var $target = $(target.tagName ? target : target.parentNode);
  513. $target.trigger($.Event('keyup', {
  514. key: step.key,
  515. keyCode: step.keyCode,
  516. ctrlKey: !!step.ctrlKey,
  517. shiftKey: !!step.shiftKey,
  518. altKey: !!step.altKey,
  519. metaKey: !!step.metaKey,
  520. }));
  521. }
  522. setTimeout(def.resolve.bind(def));
  523. });
  524. });
  525. return def;
  526. }
  527. while (test.steps.length) {
  528. def = def.then(poll.bind(null, test.steps.shift()));
  529. }
  530. return def.then(function () {
  531. if (!test.test) {
  532. return;
  533. }
  534. if (test.test.check) {
  535. test.test.check($editable, assert);
  536. }
  537. // test content
  538. if (test.test.content) {
  539. var value = $editable.data('wysiwyg').getValue({
  540. keepPopover: true,
  541. });
  542. var allInvisible = /\u200B/g;
  543. value = value.replace(allInvisible, '&#8203;');
  544. var result = test.test.content.replace(allInvisible, '&#8203;');
  545. assert.strictEqual(value, result, test.name);
  546. if (test.test.start && value !== result) {
  547. assert.notOk("Wrong DOM (see previous assert)", test.name + " (carret position)");
  548. return;
  549. }
  550. }
  551. $editable[0].normalize();
  552. // test carret position
  553. if (test.test.start) {
  554. var start = _select(test.test.start);
  555. var range = Wysiwyg.getRange();
  556. if ((range.sc !== range.ec || range.so !== range.eo) && !test.test.end) {
  557. assert.ok(false, test.name + ": the carret is not colapsed and the 'end' selector in test is missing");
  558. return;
  559. }
  560. var end = test.test.end ? _select(test.test.end) : start;
  561. if (start.node && end.node) {
  562. range = Wysiwyg.getRange();
  563. var startPoint = endOfAreaBetweenTwoNodes({
  564. node: range.sc,
  565. offset: range.so,
  566. });
  567. var endPoint = endOfAreaBetweenTwoNodes({
  568. node: range.ec,
  569. offset: range.eo,
  570. });
  571. var sameDOM = (startPoint.node.outerHTML || startPoint.node.textContent) === (start.node.outerHTML || start.node.textContent);
  572. var stringify = function (obj) {
  573. if (!sameDOM) {
  574. delete obj.sameDOMsameNode;
  575. }
  576. return JSON.stringify(obj, null, 2)
  577. .replace(/"([^"\s-]+)":/g, "\$1:")
  578. .replace(/([^\\])"/g, "\$1'")
  579. .replace(/\\"/g, '"');
  580. };
  581. assert.deepEqual(stringify({
  582. startNode: startPoint.node.outerHTML || startPoint.node.textContent,
  583. startOffset: startPoint.offset,
  584. endPoint: endPoint.node.outerHTML || endPoint.node.textContent,
  585. endOffset: endPoint.offset,
  586. sameDOMsameNode: sameDOM && startPoint.node === start.node,
  587. }),
  588. stringify({
  589. startNode: start.node.outerHTML || start.node.textContent,
  590. startOffset: start.offset,
  591. endPoint: end.node.outerHTML || end.node.textContent,
  592. endOffset: end.offset,
  593. sameDOMsameNode: true,
  594. }),
  595. test.name + " (carret position)");
  596. }
  597. }
  598. });
  599. }
  600. while (keyboardTests.length) {
  601. defPollTest = defPollTest.then(pollTest.bind(null, keyboardTests.shift()));
  602. }
  603. return defPollTest;
  604. };
  605. /**
  606. * Select a node in the dom with is offset.
  607. *
  608. * @param {String} startSelector
  609. * @param {String} endSelector
  610. * @param {jQuery} $editable
  611. * @returns {Object} {sc, so, ec, eo}
  612. */
  613. var select = (function () {
  614. var __select = function (selector, $editable) {
  615. var sel = selector.match(/^(.+?)(:contents\(\)\[([0-9]+)\]|:contents\(([0-9]+)\))?(->([0-9]+))?$/);
  616. var $node = $editable.find(sel[1]);
  617. return {
  618. node: sel[2] ? $node.contents()[sel[3] ? +sel[3] : +sel[4]] : $node[0],
  619. offset: sel[5] ? +sel[6] : 0,
  620. };
  621. };
  622. return function (startSelector, endSelector, $editable) {
  623. var start = __select(startSelector, $editable);
  624. var end = endSelector ? __select(endSelector, $editable) : start;
  625. return {
  626. sc: start.node,
  627. so: start.offset,
  628. ec: end.node,
  629. eo: end.offset,
  630. };
  631. };
  632. })();
  633. /**
  634. * Trigger a keydown event.
  635. *
  636. * @param {String or Number} key (name or code)
  637. * @param {jQuery} $editable
  638. * @param {Object} [options]
  639. * @param {Boolean} [options.firstDeselect] (default: false) true to deselect before pressing
  640. */
  641. var keydown = function (key, $editable, options) {
  642. var keyPress = {};
  643. if (typeof key === 'string') {
  644. keyPress.key = key;
  645. keyPress.keyCode = +_.findKey(keyboardMap, function (k) {
  646. return k === key;
  647. });
  648. } else {
  649. keyPress.key = keyboardMap[key] || String.fromCharCode(key);
  650. keyPress.keyCode = key;
  651. }
  652. var range = Wysiwyg.getRange();
  653. if (!range) {
  654. console.error("Editor have not any range");
  655. return;
  656. }
  657. if (options && options.firstDeselect) {
  658. range.sc = range.ec;
  659. range.so = range.eo;
  660. Wysiwyg.setRange(range.sc, range.so, range.ec, range.eo);
  661. }
  662. var target = range.ec;
  663. var $target = $(target.tagName ? target : target.parentNode);
  664. var event = $.Event("keydown", keyPress);
  665. $target.trigger(event);
  666. if (!event.isDefaultPrevented()) {
  667. if (keyPress.key.length === 1) {
  668. textInput($target[0], keyPress.key);
  669. } else {
  670. console.warn('Native "' + keyPress.key + '" is not supported in test');
  671. }
  672. }
  673. };
  674. var textInput = function (target, char) {
  675. var ev = new CustomEvent('textInput', {
  676. bubbles: true,
  677. cancelBubble: false,
  678. cancelable: true,
  679. composed: true,
  680. data: char,
  681. defaultPrevented: false,
  682. detail: 0,
  683. eventPhase: 3,
  684. isTrusted: true,
  685. returnValue: true,
  686. sourceCapabilities: null,
  687. type: "textInput",
  688. which: 0,
  689. });
  690. ev.data = char;
  691. target.dispatchEvent(ev);
  692. if (!ev.defaultPrevented) {
  693. document.execCommand("insertText", 0, ev.data);
  694. }
  695. };
  696. //--------------------------------------------------------------------------
  697. // Convert Inline
  698. //--------------------------------------------------------------------------
  699. const tableAttributesString = Object.keys(TABLE_ATTRIBUTES).map(key => `${key}="${TABLE_ATTRIBUTES[key]}"`).join(' ');
  700. const tableStylesString = Object.keys(TABLE_STYLES).map(key => `${key}: ${TABLE_STYLES[key]};`).join(' ');
  701. /**
  702. * Take a matrix representing a grid and return an HTML string of the Bootstrap
  703. * grid. The matrix is an array of rows, with each row being an array of cells.
  704. * Each cell can be represented either by a 0 < number < 13 (col-#) or a falsy
  705. * value (col). Each cell has its coordinates `(row index, column index)` as
  706. * text content.
  707. * Eg: [ // <div class="container">
  708. * [ // <div class="row">
  709. * 1, // <div class="col-1">(0, 0)</div>
  710. * 11, // <div class="col-11">(0, 1)</div>
  711. * ], // </div>
  712. * [ // <div class="row">
  713. * false, // <div class="col">(1, 0)</div>
  714. * ], // </div>
  715. * ] // </div>
  716. *
  717. * @param {Array<Array<Number|null>>} matrix
  718. * @returns {string}
  719. */
  720. function getGridHtml(matrix) {
  721. return (
  722. `<div class="container">` +
  723. matrix.map((row, iRow) => (
  724. `<div class="row">` +
  725. row.map((col, iCol) => (
  726. `<div class="${col ? 'col-' + col : 'col'}">(${iRow}, ${iCol})</div>`
  727. )).join('') +
  728. `</div>`
  729. )).join('') +
  730. `</div>`
  731. );
  732. }
  733. function getTdHtml(colspan, text, containerWidth) {
  734. return (
  735. `<td colspan="${colspan}"${
  736. containerWidth ? ' ' + `style="max-width: ${Math.round(containerWidth*colspan/12*100)/100}px;"`
  737. : ''}>` +
  738. text +
  739. `</td>`
  740. );
  741. }
  742. /**
  743. * Take a matrix representing a table and return an HTML string of the table.
  744. * The matrix is an array of rows, with each row being an array of cells. Each
  745. * cell is represented by a tuple of numbers [colspan, width (in percent)]. A
  746. * cell can have a string as third value to represent its text content. The
  747. * default text content of each cell is its coordinates `(row index, column
  748. * index)`. If the cell has a number as third value, it will be used as the
  749. * max-width of the cell (in pixels).
  750. * Eg: [ // <table> (note: extra attrs and styles apply)
  751. * [ // <tr>
  752. * [1, 8], // <td colspan="1" width="8%">(0, 0)</td>
  753. * [11, 92] // <td colspan="11" width="92%">(0, 1)</td>
  754. * ], // </tr>
  755. * [ // <tr>
  756. * [2, 17, 'A'], // <td colspan="2" width="17%">A</td>
  757. * [10, 83], // <td colspan="10" width="83%">(1, 1)</td>
  758. * ], // </tr>
  759. * ] // </table>
  760. *
  761. * @param {Array<Array<Array<[Number, Number, string?, number?]>>>} matrix
  762. * @param {Number} [containerWidth]
  763. * @returns {string}
  764. */
  765. function getTableHtml(matrix, containerWidth) {
  766. return (
  767. `<table ${tableAttributesString} style="width: 100% !important; ${tableStylesString}">` +
  768. matrix.map((row, iRow) => (
  769. `<tr>` +
  770. row.map((col, iCol) => (
  771. getTdHtml(col[0], typeof col[2] === 'string' ? col[2] : `(${iRow}, ${iCol})`, containerWidth)
  772. )).join('') +
  773. `</tr>`
  774. )).join('') +
  775. `</table>`
  776. );
  777. }
  778. /**
  779. * Take a number of rows and a number of columns (or number of columns per
  780. * individual row) and return an HTML string of the corresponding grid. Every
  781. * column is a regular Bootstrap "col" (no col-#).
  782. * Eg: [2, 3] <=> getGridHtml([[false, false, false], [false, false, false]])
  783. * Eg: [2, [2, 1]] <=> getGridHtml([[false, false], [false]])
  784. *
  785. * @see getGridHtml
  786. * @param {Number} nRows
  787. * @param {Number|Number[]} nCols
  788. * @returns {string}
  789. */
  790. function getRegularGridHtml(nRows, nCols) {
  791. const matrix = new Array(nRows).fill().map((_, iRow) => (
  792. new Array(Array.isArray(nCols) ? nCols[iRow] : nCols).fill()
  793. ));
  794. return getGridHtml(matrix);
  795. };
  796. /**
  797. * Take a number of rows, a number of columns (or number of columns per
  798. * individual row), a colspan (or colspan per individual row) and a width (or
  799. * width per individual row, in percent), and return an HTML string of the
  800. * corresponding table. Every cell in a row has the same colspan/width.
  801. * Eg: [2, 2, 6, 50] <=> getTableHtml([[[6, 50], [6, 50]], [[6, 50], [6, 50]]])
  802. * Eg: [2, [2, 1], [6, 12], [50, 100]] <=> getTableHtml([[[6, 50], [6, 50]], [[12, 100]]])
  803. *
  804. * @see getTableHtml
  805. * @param {Number} nRows
  806. * @param {Number|Number[]} nCols
  807. * @param {Number|Number[]} colspan
  808. * @param {Number|Number[]} width
  809. * @param {Number} containerWidth
  810. * @returns {string}
  811. */
  812. function getRegularTableHtml(nRows, nCols, colspan, width, containerWidth) {
  813. const matrix = new Array(nRows).fill().map((_, iRow) => (
  814. new Array(Array.isArray(nCols) ? nCols[iRow] : nCols).fill().map(() => ([
  815. Array.isArray(colspan) ? colspan[iRow] : colspan,
  816. Array.isArray(width) ? width[iRow] : width,
  817. ])))
  818. );
  819. return getTableHtml(matrix, containerWidth);
  820. }
  821. /**
  822. * Take an HTML string and returns that string stripped from any HTML comments.
  823. * By default, also removes the mso-hide class which is only there for outlook
  824. * to hide elements when we use mso conditional comments.
  825. *
  826. * @param {string} html
  827. * @param {boolean} [removeMsoHide=true]
  828. * @returns {string}
  829. */
  830. function removeComments(html, removeMsoHide=true) {
  831. const cleanHtml = html.replace(/<!--(.*?)-->/g, '');
  832. if (removeMsoHide) {
  833. return cleanHtml.replaceAll(' class="mso-hide"', '').replace(/\s*mso-hide/g, '').replace(/mso-hide\s*/g, '');
  834. } else {
  835. return cleanHtml;
  836. }
  837. }
  838. return {
  839. wysiwygData: wysiwygData,
  840. createWysiwyg: createWysiwyg,
  841. testKeyboard: testKeyboard,
  842. select: select,
  843. keydown: keydown,
  844. patch: patch,
  845. unpatch: unpatch,
  846. getGridHtml: getGridHtml,
  847. getTableHtml: getTableHtml,
  848. getRegularGridHtml: getRegularGridHtml,
  849. getRegularTableHtml: getRegularTableHtml,
  850. getTdHtml: getTdHtml,
  851. removeComments: removeComments,
  852. };
  853. });