/** @odoo-module **/
import { MockServer } from "@web/../tests/helpers/mock_server";
import testUtils from "@web/../tests/legacy/helpers/test_utils";
import { patch } from "@web/core/utils/patch";
import * as OdooEditorLib from "@web_editor/js/editor/odoo-editor/src/OdooEditor";
import { Wysiwyg } from '@web_editor/js/wysiwyg/wysiwyg';
import options from "@web_editor/js/editor/snippets.options";
import { TABLE_ATTRIBUTES, TABLE_STYLES } from '@web_editor/js/backend/convert_inline';
export const COLOR_PICKER_TEMPLATE = `
`;
const SNIPPETS_TEMPLATE = `
Background Image
Top
Middle
Bottom
Equal height
`;
patch(MockServer.prototype, {
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* @override
* @private
* @returns {Promise}
*/
async _performRPC(route, args) {
if (args.model === "ir.ui.view" && args.method === 'render_public_asset') {
if (args.args[0] === "web_editor.colorpicker") {
return COLOR_PICKER_TEMPLATE;
}
if (args.args[0] === "web_editor.snippets") {
return SNIPPETS_TEMPLATE;
}
}
return super._performRPC(...arguments);
},
});
/**
* Options with animation and edition for test.
*/
options.registry.option_test = options.Class.extend({
cleanForSave: function () {
this.$target.addClass('cleanForSave');
},
onBuilt: function () {
this.$target.addClass('built');
},
onBlur: function () {
this.$target.removeClass('focus');
},
onClone: function () {
this.$target.addClass('clone');
this.$target.removeClass('focus');
},
onFocus: function () {
this.$target.addClass('focus');
},
onMove: function () {
this.$target.addClass('move');
},
onRemove: function () {
this.$target.closest('.note-editable').addClass('snippet_has_removed');
},
});
/**
* @param {object} data
* @returns {object}
*/
export function wysiwygData(data) {
return Object.assign({
'ir.ui.view': {
fields: {
display_name: {
string: "Displayed name",
type: "char",
},
},
records: [],
render_template(args) {
if (args[0] === 'web_editor.colorpicker') {
return COLOR_PICKER_TEMPLATE;
}
if (args[0] === 'web_editor.snippets') {
return SNIPPETS_TEMPLATE;
}
},
},
'ir.attachment': {
fields: {
display_name: {
string: "display_name",
type: 'char',
},
description: {
string: "description",
type: 'char',
},
mimetype: {
string: "mimetype",
type: 'char',
},
checksum: {
string: "checksum",
type: 'char',
},
url: {
string: "url",
type: 'char',
},
type: {
string: "type",
type: 'char',
},
res_id: {
string: "res_id",
type: 'integer',
},
res_model: {
string: "res_model",
type: 'char',
},
public: {
string: "public",
type: 'boolean',
},
access_token: {
string: "access_token",
type: 'char',
},
image_src: {
string: "image_src",
type: 'char',
},
image_width: {
string: "image_width",
type: 'integer',
},
image_height: {
string: "image_height",
type: 'integer',
},
original_id: {
string: "original_id",
type: 'many2one',
relation: 'ir.attachment',
},
},
records: [{
id: 1,
name: 'image',
description: '',
mimetype: 'image/png',
checksum: false,
url: '/web/image/123/transparent.png',
type: 'url',
res_id: 0,
res_model: false,
public: true,
access_token: false,
image_src: '/web/image/123/transparent.png',
image_width: 256,
image_height: 256,
}],
generate_access_token: function () {
return;
},
},
}, data);
}
/**
* Perform a series of tests (`keyboardTests`) for using keyboard inputs.
*
* @see wysiwyg_keyboard_tests.js
* @see wysiwyg_tests.js
*
* @param {jQuery} $editable
* @param {object} assert
* @param {object[]} keyboardTests
* @param {string} keyboardTests.name
* @param {string} keyboardTests.content
* @param {object[]} keyboardTests.steps
* @param {string} keyboardTests.steps.start
* @param {string} [keyboardTests.steps.end] default: steps.start
* @param {string} keyboardTests.steps.key
* @param {object} keyboardTests.test
* @param {string} [keyboardTests.test.content]
* @param {string} [keyboardTests.test.start]
* @param {string} [keyboardTests.test.end] default: steps.start
* @param {function($editable, assert)} [keyboardTests.test.check]
* @param {Number} addTests
*/
var testKeyboard = function ($editable, assert, keyboardTests, addTests) {
var tests = keyboardTests.map((k) => k.test).map((x) => !!x);
var testNumber =
tests.map((test) => test.start).map((x) => !!x).length +
tests.map((test) => test.content).map((x) => !!x).length +
tests.map((test) => test.check.map((x) => !!x)).length +
(addTests | 0);
assert.expect(testNumber);
function keydown(target, keypress) {
var $target = $(target.tagName ? target : target.parentNode);
var event = $.Event("keydown", keypress);
$target.trigger(event);
if (!event.isDefaultPrevented()) {
if (keypress.key.length === 1) {
textInput($target[0], keypress.key);
} else {
console.warn('Native "' + keypress.key + '" is not supported in test');
}
}
$target.trigger($.Event("keyup", keypress));
return $target;
}
function _select(selector) {
// eg: ".class:contents()[0]->1" selects the first contents of the 'class' class, with an offset of 1
var reDOMSelection = /^(.+?)(:contents(\(\)\[|\()([0-9]+)[\]|\)])?(->([0-9]+))?$/;
var sel = selector.match(reDOMSelection);
var $node = $editable.find(sel[1]);
var point = {
node: sel[3] ? $node.contents()[+sel[4]] : $node[0],
offset: sel[5] ? +sel[6] : 0,
};
if (!point.node || point.offset > (point.node.tagName ? point.node.childNodes : point.node.textContent).length) {
assert.notOk("Node not found: '" + selector + "' " + (point.node ? "(container: '" + (point.node.outerHTML || point.node.textContent) + "')" : ""));
}
return point;
}
function selectText(start, end) {
start = _select(start);
var target = start.node;
$(target.tagName ? target : target.parentNode).trigger("mousedown");
if (end) {
end = _select(end);
Wysiwyg.setRange(start.node, start.offset, end.node, end.offset);
} else {
Wysiwyg.setRange(start.node, start.offset);
}
target = end ? end.node : start.node;
$(target.tagName ? target : target.parentNode).trigger('mouseup');
}
function nextPoint(point) {
var node, offset;
if (OdooEditorLib.nodeSize(point.node) === point.offset) {
node = point.node.parentNode;
offset = OdooEditorLib.childNodeIndex(point.node) + 1;
} else if (point.node.hasChildNodes()) {
node = point.node.childNodes[point.offset];
offset = 0;
} else {
node = point.node;
offset = point.offset + 1;
}
return {
node: node,
offset: offset
};
}
function endOfAreaBetweenTwoNodes(point) {
// move the position because some browser make the caret on the end of the previous area after normalize
if (
!point.node.tagName &&
point.offset === point.node.textContent.length &&
!/\S|\u00A0/.test(point.node.textContent)
) {
point = nextPoint(nextPoint(point));
while (point.node.tagName && point.node.textContent.length) {
point = nextPoint(point);
}
}
return point;
}
var defPollTest = Promise.resolve();
function pollTest(test) {
var def = Promise.resolve();
$editable.data('wysiwyg').setValue(test.content);
function poll(step) {
var def = testUtils.makeTestPromise();
if (step.start) {
selectText(step.start, step.end);
if (!Wysiwyg.getRange()) {
throw 'Wrong range! \n' +
'Test: ' + test.name + '\n' +
'Selection: ' + step.start + '" to "' + step.end + '"\n' +
'DOM: ' + $editable.html();
}
}
setTimeout(function () {
if (step.key) {
var target = Wysiwyg.getRange().ec;
if (window.location.search.indexOf('notrycatch') !== -1) {
keydown(target, {
key: step.key,
ctrlKey: !!step.ctrlKey,
shiftKey: !!step.shiftKey,
altKey: !!step.altKey,
metaKey: !!step.metaKey,
});
} else {
try {
keydown(target, {
key: step.key,
ctrlKey: !!step.ctrlKey,
shiftKey: !!step.shiftKey,
altKey: !!step.altKey,
metaKey: !!step.metaKey,
});
} catch (e) {
assert.notOk(e.name + '\n\n' + e.stack, test.name);
}
}
}
setTimeout(function () {
if (step.key) {
var $target = $(target.tagName ? target : target.parentNode);
$target.trigger($.Event('keyup', {
key: step.key,
ctrlKey: !!step.ctrlKey,
shiftKey: !!step.shiftKey,
altKey: !!step.altKey,
metaKey: !!step.metaKey,
}));
}
setTimeout(def.resolve.bind(def));
});
});
return def;
}
while (test.steps.length) {
def = def.then(poll.bind(null, test.steps.shift()));
}
return def.then(function () {
if (!test.test) {
return;
}
if (test.test.check) {
test.test.check($editable, assert);
}
// test content
if (test.test.content) {
var value = $editable.data('wysiwyg').getValue({
keepPopover: true,
});
var allInvisible = /\u200B/g;
value = value.replace(allInvisible, '');
var result = test.test.content.replace(allInvisible, '');
assert.strictEqual(value, result, test.name);
if (test.test.start && value !== result) {
assert.notOk("Wrong DOM (see previous assert)", test.name + " (carret position)");
return;
}
}
$editable[0].normalize();
// test carret position
if (test.test.start) {
var start = _select(test.test.start);
var range = Wysiwyg.getRange();
if ((range.sc !== range.ec || range.so !== range.eo) && !test.test.end) {
assert.ok(false, test.name + ": the carret is not colapsed and the 'end' selector in test is missing");
return;
}
var end = test.test.end ? _select(test.test.end) : start;
if (start.node && end.node) {
range = Wysiwyg.getRange();
var startPoint = endOfAreaBetweenTwoNodes({
node: range.sc,
offset: range.so,
});
var endPoint = endOfAreaBetweenTwoNodes({
node: range.ec,
offset: range.eo,
});
var sameDOM = (startPoint.node.outerHTML || startPoint.node.textContent) === (start.node.outerHTML || start.node.textContent);
var stringify = function (obj) {
if (!sameDOM) {
delete obj.sameDOMsameNode;
}
return JSON.stringify(obj, null, 2)
.replace(/"([^"\s-]+)":/g, "\$1:")
.replace(/([^\\])"/g, "\$1'")
.replace(/\\"/g, '"');
};
assert.deepEqual(stringify({
startNode: startPoint.node.outerHTML || startPoint.node.textContent,
startOffset: startPoint.offset,
endPoint: endPoint.node.outerHTML || endPoint.node.textContent,
endOffset: endPoint.offset,
sameDOMsameNode: sameDOM && startPoint.node === start.node,
}),
stringify({
startNode: start.node.outerHTML || start.node.textContent,
startOffset: start.offset,
endPoint: end.node.outerHTML || end.node.textContent,
endOffset: end.offset,
sameDOMsameNode: true,
}),
test.name + " (carret position)");
}
}
});
}
while (keyboardTests.length) {
defPollTest = defPollTest.then(pollTest.bind(null, keyboardTests.shift()));
}
return defPollTest;
};
/**
* Select a node in the dom with is offset.
*
* @param {String} startSelector
* @param {String} endSelector
* @param {jQuery} $editable
* @returns {Object} {sc, so, ec, eo}
*/
var select = (function () {
var __select = function (selector, $editable) {
var sel = selector.match(/^(.+?)(:contents\(\)\[([0-9]+)\]|:contents\(([0-9]+)\))?(->([0-9]+))?$/);
var $node = $editable.find(sel[1]);
return {
node: sel[2] ? $node.contents()[sel[3] ? +sel[3] : +sel[4]] : $node[0],
offset: sel[5] ? +sel[6] : 0,
};
};
return function (startSelector, endSelector, $editable) {
var start = __select(startSelector, $editable);
var end = endSelector ? __select(endSelector, $editable) : start;
return {
sc: start.node,
so: start.offset,
ec: end.node,
eo: end.offset,
};
};
})();
/**
* Trigger a keydown event.
*
* @param {String or Number} key (name or code)
* @param {jQuery} $editable
* @param {Object} [options]
* @param {Boolean} [options.firstDeselect] (default: false) true to deselect before pressing
*/
var keydown = function (key, $editable, options) {
var keyPress = {};
keyPress.key = key;
var range = Wysiwyg.getRange();
if (!range) {
console.error("Editor have not any range");
return;
}
if (options && options.firstDeselect) {
range.sc = range.ec;
range.so = range.eo;
Wysiwyg.setRange(range.sc, range.so, range.ec, range.eo);
}
var target = range.ec;
var $target = $(target.tagName ? target : target.parentNode);
var event = $.Event("keydown", keyPress);
$target.trigger(event);
if (!event.isDefaultPrevented()) {
if (keyPress.key.length === 1) {
textInput($target[0], keyPress.key);
} else {
console.warn('Native "' + keyPress.key + '" is not supported in test');
}
}
};
var textInput = function (target, char) {
var ev = new CustomEvent('textInput', {
bubbles: true,
cancelBubble: false,
cancelable: true,
composed: true,
data: char,
defaultPrevented: false,
detail: 0,
eventPhase: 3,
isTrusted: true,
returnValue: true,
sourceCapabilities: null,
type: "textInput",
which: 0,
});
ev.data = char;
target.dispatchEvent(ev);
if (!ev.defaultPrevented) {
document.execCommand("insertText", 0, ev.data);
}
};
//--------------------------------------------------------------------------
// Convert Inline
//--------------------------------------------------------------------------
const tableAttributesString = Object.keys(TABLE_ATTRIBUTES).map(key => `${key}="${TABLE_ATTRIBUTES[key]}"`).join(' ');
const tableStylesString = Object.keys(TABLE_STYLES).map(key => `${key}: ${TABLE_STYLES[key]};`).join(' ');
/**
* Take a matrix representing a grid and return an HTML string of the Bootstrap
* grid. The matrix is an array of rows, with each row being an array of cells.
* Each cell can be represented either by a 0 < number < 13 (col-#) or a falsy
* value (col). Each cell has its coordinates `(row index, column index)` as
* text content.
* Eg: [ //
* [ //
* 1, //
(0, 0)
* 11, //
(0, 1)
* ], //
* [ //
* false, //
(1, 0)
* ], //
* ] //
*
* @param {Array>} matrix
* @returns {string}
*/
export function getGridHtml(matrix) {
return (
`` +
matrix.map((row, iRow) => (
`
` +
row.map((col, iCol) => (
`
(${iRow}, ${iCol})
`
)).join('') +
`
`
)).join('') +
`
`
);
}
export function getTdHtml(colspan, text, containerWidth) {
return (
`` +
text +
` `
);
}
/**
* Take a matrix representing a table and return an HTML string of the table.
* The matrix is an array of rows, with each row being an array of cells. Each
* cell is represented by a tuple of numbers [colspan, width (in percent)]. A
* cell can have a string as third value to represent its text content. The
* default text content of each cell is its coordinates `(row index, column
* index)`. If the cell has a number as third value, it will be used as the
* max-width of the cell (in pixels).
* Eg: [ // (note: extra attrs and styles apply)
* [ //
* [1, 8], // (0, 0)
* [11, 92] // (0, 1)
* ], //
* [ //
* [2, 17, 'A'], // A
* [10, 83], // (1, 1)
* ], //
* ] //
*
* @param {Array>>} matrix
* @param {Number} [containerWidth]
* @returns {string}
*/
export function getTableHtml(matrix, containerWidth) {
return (
`` +
matrix.map((row, iRow) => (
`` +
row.map((col, iCol) => (
getTdHtml(col[0], typeof col[2] === 'string' ? col[2] : `(${iRow}, ${iCol})`, containerWidth)
)).join('') +
` `
)).join('') +
`
`
);
}
/**
* Take a number of rows and a number of columns (or number of columns per
* individual row) and return an HTML string of the corresponding grid. Every
* column is a regular Bootstrap "col" (no col-#).
* Eg: [2, 3] <=> getGridHtml([[false, false, false], [false, false, false]])
* Eg: [2, [2, 1]] <=> getGridHtml([[false, false], [false]])
*
* @see getGridHtml
* @param {Number} nRows
* @param {Number|Number[]} nCols
* @returns {string}
*/
export function getRegularGridHtml(nRows, nCols) {
const matrix = new Array(nRows).fill().map((_, iRow) => (
new Array(Array.isArray(nCols) ? nCols[iRow] : nCols).fill()
));
return getGridHtml(matrix);
};
/**
* Take a number of rows, a number of columns (or number of columns per
* individual row), a colspan (or colspan per individual row) and a width (or
* width per individual row, in percent), and return an HTML string of the
* corresponding table. Every cell in a row has the same colspan/width.
* Eg: [2, 2, 6, 50] <=> getTableHtml([[[6, 50], [6, 50]], [[6, 50], [6, 50]]])
* Eg: [2, [2, 1], [6, 12], [50, 100]] <=> getTableHtml([[[6, 50], [6, 50]], [[12, 100]]])
*
* @see getTableHtml
* @param {Number} nRows
* @param {Number|Number[]} nCols
* @param {Number|Number[]} colspan
* @param {Number|Number[]} width
* @param {Number} containerWidth
* @returns {string}
*/
export function getRegularTableHtml(nRows, nCols, colspan, width, containerWidth) {
const matrix = new Array(nRows).fill().map((_, iRow) => (
new Array(Array.isArray(nCols) ? nCols[iRow] : nCols).fill().map(() => ([
Array.isArray(colspan) ? colspan[iRow] : colspan,
Array.isArray(width) ? width[iRow] : width,
])))
);
return getTableHtml(matrix, containerWidth);
}
/**
* Take an HTML string and returns that string stripped from any HTML comments.
* By default, also removes the mso-hide class which is only there for outlook
* to hide elements when we use mso conditional comments.
*
* @param {string} html
* @param {boolean} [removeMsoHide=true]
* @returns {string}
*/
export function removeComments(html, removeMsoHide=true) {
const cleanHtml = html.replace(//g, '');
if (removeMsoHide) {
return cleanHtml.replaceAll(' class="mso-hide"', '').replace(/\s*mso-hide/g, '').replace(/mso-hide\s*/g, '');
} else {
return cleanHtml;
}
}
export default {
wysiwygData: wysiwygData,
testKeyboard: testKeyboard,
select: select,
keydown: keydown,
getGridHtml: getGridHtml,
getTableHtml: getTableHtml,
getRegularGridHtml: getRegularGridHtml,
getRegularTableHtml: getRegularTableHtml,
getTdHtml: getTdHtml,
removeComments: removeComments,
};