diff options
| author | Robot Odoo <xmo+roboto@odoo.com> | 2019-01-17 19:17:22 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2019-01-17 19:17:22 +0100 |
| commit | 22efdfbecc1e8c688000be98903a5a355d1ece8a (patch) | |
| tree | e097625171b2b67ed5f5288ced67446608b9f633 /addons/web_editor/static/tests/test_utils.js | |
| parent | f50fbc6d168f0d17fa1e932b4ecc968a92b02b65 (diff) | |
| parent | eb0ea8efbe932c140302e9f8da33a839bcd73146 (diff) | |
[IMP] web_editor,*: Refactoring the wysiwyg editor and 'html' field
_Task #1878502_
- The refactoring allows for the update of the _Summernote_ library.
- A new structure was created by transforming all the plugins in the library using the odoo inheritance system. Plugins are easier to implement with the `AbstractPlugin` to add Odoo behaviors.
- The cleanup allowed us to remove some of the code that was in `web_editor` but only used in `website` or in `mass_mailing`. Some parts are still in `web_editor` but will be moved at a later time.
- The refactoring also had to be done in order to create a set of unit tests. Each behavior can be tested, including the behaviors performed as a consequence of keyboard interactions (for instance: Enter, Tab...).
- From now on, the methods of the library (in this case _Summernote_) can no longer be called by other modules or files. Only the `wysiwyg` widgets can access it, to simplify the updating process. The `wysiwyg` object serves as an interface. A lot of tests are done, ensuring the consistency of outside behavior.
- Depending on the options the snippets will be loaded or not, the editor will be in an iframe or not... all of this is transparent from the outside.
- Regarding iframes, all controllers related to editing have been removed: the new API no longer needs them. This speeds up loading, eases testing and removes complexity for the same features.
**PUBLIC FEATURES**
There are several public methods on the `Wysiwyg` class:
- `Wysiwyg.prepare (WidgetParent)` which returns a deferred resolved when the library (xml, lazy, assets...) is loaded.
- `Wysiwyg.getRange (DOM)` returns the range (selection in the dom)
- `Wysiwyg.setRange (startNode, startOffset, endNode, endOffset)` which creates a range (selection in the dom)
- `Wysiwyg.setRangeFromNode (DOM, options)` that creates a range from an element (option available to select all, start or end)
A jQuery selector was added: `:o_editable`, which indicates whether the current element is editable. That is, if it is contained in a tag with the attribute `contentEditable = "true"` or in a tag with the class `o_editable`.
Several methods are also present:
- `focusIn`: makes a focus and places the cursor at the beginning of the element
- `focusInEnd`: makes a focus and places the cursor at the end of the element
- `selectContent`: makes a focus and selects the content
**HTML FIELD**
The HTML field can receive different options:
- `style-inline:` {boolean} transforms a class into an inline style when saving and vice versa when reading.
- `no-attachment`: {boolean} prevents the use of attachments (in media dialog)
- `cssEdit`: {xml_id} to use a template containing the css to loaded in an iframe when editing
- `cssReadonly`: {xml_id} to use a template containing the css to load into an iframe when viewing in readonly
- `snippets`: {xml_id} snippets template (can be used with or without `cssEdit`)
- `wrapper`: {template} qweb static template (containing a tag: `id = "wrapper"`) that will include the content during editing (removed on save)
**MASS MAILING**
A widget was created for `mass mailing`. There are now two fields: `body_html` and `body_arch`.
`body_arch` contains the code with the class without conversion into inline style, useful when editing and one with the inline style that is visible in readonly mode and sent by email.
Advantage: no spreading errors, able to update css/theme, able to do more changes when converting to inline style so that a maximum of mail clients have an impeccable rendering.
**REMAINING KNOW ISSUES**
These will be fixed in a subsequent commit.
- A few committed changes made during the development of this refactoring were lost in the process and should be reapplied.
- Some minor (mostly range) issues remain with Firefox.
--
I confirm I have signed the CLA and read the PR guidelines at www.odoo.com/submit-pr
closes odoo/odoo#29775
Diffstat (limited to 'addons/web_editor/static/tests/test_utils.js')
| -rw-r--r-- | addons/web_editor/static/tests/test_utils.js | 674 |
1 files changed, 674 insertions, 0 deletions
diff --git a/addons/web_editor/static/tests/test_utils.js b/addons/web_editor/static/tests/test_utils.js new file mode 100644 index 00000000000..ff740e52f36 --- /dev/null +++ b/addons/web_editor/static/tests/test_utils.js @@ -0,0 +1,674 @@ +odoo.define('web_editor.test_utils', function (require) { +"use strict"; + +var ajax = require('web.ajax'); +var testUtils = require('web.test_utils'); +var Widget = require('web.Widget'); +var Wysiwyg = require('web_editor.wysiwyg'); +var options = require('web_editor.snippets.options'); + + +/** + * 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'); + }, +}); + + +/** + * Constructor WysiwygTest why editable and unbreakable node used in test. + */ +var WysiwygTest = Wysiwyg.extend({ + _parentToDestroyForTest: null, + /** + * Override 'destroy' of discuss so that it calls 'destroy' on the parent. + * + * @override + */ + destroy: function () { + unpatch(); + this._super(); + this.$target.remove(); + this._parentToDestroyForTest.destroy(); + }, + /** + * @override + */ + isUnbreakableNode: function (node) { + var Node = (node.tagName ? node : node.parentNode); + return (!this.options.useOnlyTestUnbreakable && this._super(node)) || + !this.isEditableNode(node) || + Node.tagName === "UNBREAKABLE" || + (Node.className + '').indexOf('unbreakable') !== -1; + }, + /** + * @override + */ + isEditableNode: function (node) { + if (!$(node).closest(this.$el).length) { + return false; + } + while (node) { + if (node.tagName === "EDITABLE") { + return true; + } + if (node.tagName === "NOTEDITABLE") { + return false; + } + if ((node.className + '').indexOf('editable') !== -1) { + return true; + } + if (this.$el[0] === node) { + return true; + } + node = node.parentNode; + } + return false; + }, +}); + + +function patch() { + testUtils.mock.patch(ajax, { + loadAsset: function (xmlId) { + if (xmlId === 'template.assets') { + return $.when({ + cssLibs: [], + cssContents: ['body {background-color: red;}'] + }); + } + if (xmlId === 'template.assets_all_style') { + return $.when({ + cssLibs: $('head link[href]:not([type="image/x-icon"])').map(function () { + return $(this).attr('href'); + }).get(), + cssContents: ['body {background-color: red;}'] + }); + } + throw 'Wrong template'; + }, + }); +} + +function unpatch() { + testUtils.mock.unpatch(ajax); +} + +/** + * @param {object} data + * @returns {object} + */ +function wysiwygData(data) { + return _.defaults({}, data, { + 'ir.ui.view': { + fields: { + display_name: { + string: "Displayed name", + type: "char", + }, + }, + records: [], + read_template: function (args) { + if (args[0] === 'web_editor.colorpicker') { + return '<templates><t t-name="web_editor.colorpicker">' + + '<colorpicker>' + + ' <div class="o_colorpicker_section" data-name="theme" data-display="Theme Colors" data-icon-class="fa fa-flask">' + + ' <button data-color="alpha"></button>' + + ' <button data-color="beta"></button>' + + ' <button data-color="gamma"></button>' + + ' <button data-color="delta"></button>' + + ' <button data-color="epsilon"></button>' + + ' </div>' + + ' <div class="o_colorpicker_section" data-name="transparent_grayscale" data-display="Transparent Colors" data-icon-class="fa fa-eye-slash">' + + ' <button class="o_btn_transparent"></button>' + + ' <button data-color="black-25"></button>' + + ' <button data-color="black-50"></button>' + + ' <button data-color="black-75"></button>' + + ' <button data-color="white-25"></button>' + + ' <button data-color="white-50"></button>' + + ' <button data-color="white-75"></button>' + + ' </div>' + + ' <div class="o_colorpicker_section" data-name="common" data-display="Common Colors" data-icon-class="fa fa-paint-brush"></div>' + + '</colorpicker>' + + '</t></templates>'; + } + }, + render_template: function (args) { + if (args[0] === 'web_editor.snippets') { + return '<h2 id="snippets_menu">Add blocks</h2>' + + '<div id="o_scroll">' + + ' <div id="snippet_structure" class="o_panel">' + + ' <div class="o_panel_header">' + + ' <i class="fa fa-th-large"></i> First Panel' + + ' </div>' + + ' <div class="o_panel_body">' + + ' <div name="Separator" data-oe-type="snippet" data-oe-thumbnail="/website/static/src/img/snippets_thumbs/s_separator.png">' + + ' <div class="s_hr pt32 pb32">' + + ' <hr class="s_hr_1px s_hr_solid w-100 mx-auto"/>' + + ' </div>' + + ' </div>' + + ' <div name="Content" data-oe-type="snippet" data-oe-thumbnail="/website/static/src/img/snippets_thumbs/s_text_block.png">' + + ' <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">' + + ' <div class="container">' + + ' <div class="row">' + + ' <div class="col-lg-10 offset-lg-1 pt32 pb32">' + + ' <h2>Title</h2>' + + ' <p class="lead o_default_snippet_text">Content</p>' + + ' </div>' + + ' </div>' + + ' </div>' + + ' </section>' + + ' </div>' + + ' </div>' + + ' </div>' + + '</div>' + + '<div id="snippet_options" class="d-none">' + + ' <div data-js="many2one" data-selector="[data-oe-many2one-model]:not([data-oe-readonly])" data-no-check="true"></div>' + + ' <div data-js="content" data-selector=".s_hr, .test_option_all" ' + + 'data-drop-in=".note-editable" data-drop-near="p, h1, h2, h3, blockquote, .s_hr"></div>' + + ' <div data-js="sizing_y" data-selector=".s_hr, .test_option_all"></div>' + + ' <div data-js="colorpicker" data-selector=".test_option_all">' + + ' <div class="dropdown-submenu">' + + ' <a tabindex="-1" href="#" class="dropdown-item"><i class="fa fa-eyedropper"/>Background Color</a>' + + ' <div class="dropdown-menu" role="menu"/>' + + ' </div>' + + ' </div>' + + ' <div data-js="background" data-selector=".test_option_all">' + + ' <a tabindex="-1" href="#" class="dropdown-item" data-choose-image="true" data-no-preview="true"><i class="fa fa-picture-o"/>Background Image</a>' + + ' </div>' + + ' <div data-js="option_test" data-selector=".s_hr">' + + ' <div class="dropdown-submenu">' + + ' <a tabindex="-2" href="#" class="dropdown-item"><i class="fa fa-arrows-v"/>Alignment</a>' + + ' <div class="dropdown-menu" role="menu">' + + ' <a href="#" class="dropdown-item" data-select-class="align-items-start">Top</a>' + + ' <a href="#" class="dropdown-item" data-select-class="align-items-center">Middle</a>' + + ' <a href="#" class="dropdown-item" data-select-class="align-items-end">Bottom</a>' + + ' <div class="dropdown-divider"/>' + + ' <a href="#" class="dropdown-item" data-select-class="align-items-stretch">Equal height</a>' + + ' </div>' + + ' </div>' + + ' </div>' + + '</div>'; + } + }, + }, + 'ir.attachment': { + fields: { + display_name: { + string: "display_name", + type: 'char', + }, + datas_fname: { + string: "datas_fname", + 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', + }, + access_token: { + string: "access_token", + type: 'char', + }, + }, + records: [{ + id: 1, + public: true, + name: 'image', + datas_fname: 'image.png', + mimetype: 'image/png', + checksum: false, + url: '/web_editor/static/src/img/transparent.png', + type: 'url', + res_id: 0, + res_model: false, + access_token: false + }], + generate_access_token: function () { + return; + }, + }, + }); +} + +/** + * Create the wysiwyg instance for test (contains patch, usefull ir.ui.view, snippets). + * + * @param {object} params + */ +function createWysiwyg(params) { + patch(); + params.data = wysiwygData(params.data); + + var parent = new Widget(); + testUtils.mock.addMockEnvironment(parent, params); + + var wysiwygOptions = _.extend({}, params.wysiwygOptions, { + recordInfo: { + context: {}, + res_model: 'module.test', + res_id: 1, + }, + useOnlyTestUnbreakable: params.useOnlyTestUnbreakable, + }); + + var wysiwyg = new WysiwygTest(parent, wysiwygOptions); + wysiwyg._parentToDestroyForTest = parent; + + var $textarea = $('<textarea/>'); + if (wysiwygOptions.value) { + $textarea.val(wysiwygOptions.value); + } + var selector = params.debug ? 'body' : '#qunit-fixture'; + $textarea.prependTo($(selector)); + if (params.debug) { + $('body').addClass('debug'); + } + return wysiwyg.attachTo($textarea).then(function () { + if (wysiwygOptions.snippets) { + var defSnippets = $.Deferred(); + testUtils.mock.intercept(wysiwyg, "snippets_loaded", function () { + defSnippets.resolve(wysiwyg); + }); + return defSnippets; + } + return wysiwyg; + }); +} + + +/** + * Char codes. + */ +var dom = $.summernote.dom; +var keyboardMap = { + "8": "BACKSPACE", + "9": "TAB", + "13": "ENTER", + "16": "SHIFT", + "17": "CONTROL", + "18": "ALT", + "19": "PAUSE", + "20": "CAPS_LOCK", + "27": "ESCAPE", + "32": "SPACE", + "33": "PAGE_UP", + "34": "PAGE_DOWN", + "35": "END", + "36": "HOME", + "37": "LEFT", + "38": "UP", + "39": "RIGHT", + "40": "DOWN", + "45": "INSERT", + "46": "DELETE", + "91": "OS_KEY", // 'left command': Windows Key (Windows) or Command Key (Mac) + "93": "CONTEXT_MENU", // 'right command' +}; +_.each(_.range(40, 127), function (keyCode) { + if (!keyboardMap[keyCode]) { + keyboardMap[keyCode] = String.fromCharCode(keyCode); + } +}); + +/** + * 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 = _.compact(_.pluck(keyboardTests, 'test')); + var testNumber = _.compact(_.pluck(tests, 'start')).length + + _.compact(_.pluck(tests, 'content')).length + + _.compact(_.pluck(tests, 'check')).length + + (addTests | 0); + assert.expect(testNumber); + + function keydown(target, keypress) { + var $target = $(target.tagName ? target : target.parentNode); + if (!keypress.keyCode) { + keypress.keyCode = +_.findKey(keyboardMap, function (key) { + return key === keypress.key; + }); + } else { + keypress.key = keyboardMap[keypress.keyCode] || String.fromCharCode(keypress.keyCode); + } + keypress.keyCode = keypress.keyCode; + var event = $.Event("keydown", keypress); + $target.trigger(event); + if (!event.isDefaultPrevented()) { + if (keypress.key.length === 1) { + document.execCommand("insertText", 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 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 = dom.nextPoint(dom.nextPoint(point)); + while (point.node.tagName && point.node.textContent.length) { + point = dom.nextPoint(point); + } + } + return point; + } + + var defPollTest = $.when(); + + function pollTest(test) { + var def = $.when(); + $editable.data('wysiwyg').setValue(test.content); + + function poll(step) { + var def = $.Deferred(); + if (step.start) { + selectText(step.start, step.end); + if (!Wysiwyg.getRange($editable[0])) { + throw 'Wrong range! \n' + + 'Test: ' + test.name + '\n' + + 'Selection: ' + step.start + '" to "' + step.end + '"\n' + + 'DOM: ' + $editable.html(); + } + } + setTimeout(function () { + if (step.keyCode || step.key) { + var target = Wysiwyg.getRange($editable[0]).ec; + if (window.location.search.indexOf('notrycatch') !== -1) { + keydown(target, { + key: step.key, + keyCode: step.keyCode, + ctrlKey: !!step.ctrlKey, + shiftKey: !!step.shiftKey, + altKey: !!step.altKey, + metaKey: !!step.metaKey, + }); + } else { + try { + keydown(target, { + key: step.key, + keyCode: step.keyCode, + 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.keyCode || step.key) { + var $target = $(target.tagName ? target : target.parentNode); + $target.trigger($.Event('keyup', { + key: step.key, + keyCode: step.keyCode, + 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($editable[0]); + 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($editable[0]); + 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 = {}; + if (typeof key === 'string') { + keyPress.key = key; + keyPress.keyCode = +_.findKey(keyboardMap, function (k) { + return k === key; + }); + } else { + keyPress.key = keyboardMap[key] || String.fromCharCode(key); + keyPress.keyCode = key; + } + var range = Wysiwyg.getRange($editable[0]); + 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); +}; + +return { + wysiwygData: wysiwygData, + createWysiwyg: createWysiwyg, + testKeyboard: testKeyboard, + select: select, + keydown: keydown, +}; + + +}); |
