diff --git a/addons/web_editor/static/lib/odoo-editor/src/OdooEditor.js b/addons/web_editor/static/lib/odoo-editor/src/OdooEditor.js index 358609ba919..6ed764155ce 100644 --- a/addons/web_editor/static/lib/odoo-editor/src/OdooEditor.js +++ b/addons/web_editor/static/lib/odoo-editor/src/OdooEditor.js @@ -153,6 +153,7 @@ export class OdooEditor extends EventTarget { isRootEditable: true, placeholder: false, defaultLinkAttributes: {}, + plugins: [], getContentEditableAreas: () => [], getPowerboxElement: () => { const selection = document.getSelection(); @@ -201,6 +202,12 @@ export class OdooEditor extends EventTarget { // Map that from an node id to the dom node. this._idToNodeMap = new Map(); + // Instanciate plugins. + this._plugins = []; + for (const plugin of this.options.plugins) { + this._pluginAdd(plugin); + } + // ------------------- // Alter the editable // ------------------- @@ -230,6 +237,8 @@ export class OdooEditor extends EventTarget { this._historyStepsActive = true; this.historyReset(); + this._pluginCall('sanitizeElement', [editable]); + this._createCommandBar(); this.toolbarTablePicker = new TablePicker({ document: this.document }); @@ -329,6 +338,7 @@ export class OdooEditor extends EventTarget { this._collabSelectionsContainer.remove(); this._resizeObserver.disconnect(); clearInterval(this._snapshotInterval); + this._pluginCall('destroy', []); } sanitize() { @@ -347,6 +357,7 @@ export class OdooEditor extends EventTarget { // sanitize and mark current position as sanitized sanitize(commonAncestor); + this._pluginCall('sanitizeElement', [commonAncestor]); } addDomListener(element, eventName, callback) { @@ -408,9 +419,11 @@ export class OdooEditor extends EventTarget { } observerUnactive(label) { this._observerUnactiveLabels.add(label); - clearTimeout(this.observerTimeout); - this.observer.disconnect(); - this.observerFlush(); + if (this.observer) { + clearTimeout(this.observerTimeout); + this.observer.disconnect(); + this.observerFlush(); + } } observerFlush() { this.observerApply(this.observer.takeRecords()); @@ -2237,8 +2250,12 @@ export class OdooEditor extends EventTarget { hint.classList.remove('oe-hint', 'oe-command-temporary-hint'); hint.removeAttribute('placeholder'); } + this.cleanForSave(); this.observerActive(); } + cleanForSave(element = this.editable) { + this._pluginCall('cleanForSave', [element]); + } /** * Handle the hint preview for the commandbar. * @private @@ -2816,4 +2833,14 @@ export class OdooEditor extends EventTarget { ); } } + _pluginAdd(Plugin) { + this._plugins.push(new Plugin(this)); + } + _pluginCall(method, args) { + for (const plugin of this._plugins) { + if (plugin[method]) { + plugin[method](...args); + } + } + } } diff --git a/addons/web_editor/static/lib/odoo-editor/src/qweb_sample.js b/addons/web_editor/static/lib/odoo-editor/src/qweb_sample.js new file mode 100644 index 00000000000..7b6ddec23f8 --- /dev/null +++ b/addons/web_editor/static/lib/odoo-editor/src/qweb_sample.js @@ -0,0 +1,164 @@ +export const qwebSample = /* xml */ ` +
if1
+if1.a
+elif1.b
+elif1.c
+if2
+elif2.1
+elif2.1
+ +if2.1.1
+elif2.1.2
+elif2.1.3
+if
+if/if
+if/elsif
+if/else
+elif
+elif/if
+elif/elif
+elif/else
+else
+else/if
+else/elif
+else/else
+the value
+ok
+Happy birthday!
+Welcome master!
+Welcome!
+
+
a[]
', stepFunction: async editor => { const originalHistoryLength = editor._historySteps.length; + + // Wait for some plugin (e.g. QWEB) that currently use a + // setTimeout in order to wait for the editable to be inserted + // in the DOM to perform logic that reset the autostep timeout. + await timeoutPromise(20); + appendSpan(editor.editable.querySelector('p')); await timeoutPromise(20).then(() => { window.chai.assert.strictEqual( @@ -66,6 +72,12 @@ describe('Autostep', () => { contentBefore: 'a[]
', stepFunction: async editor => { const originalHistoryLength = editor._historySteps.length; + + // Wait for some plugin (e.g. QWEB) that currently use a + // setTimeout in order to wait for the editable to be inserted + // in the DOM to perform logic that reset the autostep timeout. + await timeoutPromise(20); + editor.automaticStepUnactive('test'); appendSpan(editor.editable.querySelector('p')); editor.automaticStepActive('test'); @@ -114,6 +126,11 @@ describe('Autostep', () => { stepFunction: async editor => { const originalHistoryLength = editor._historySteps.length; + // Wait for some plugin (e.g. QWEB) that currently use a + // setTimeout in order to wait for the editable to be inserted + // in the DOM to perform logic that reset the autostep timeout. + await timeoutPromise(20); + editor.automaticStepUnactive('test'); editor.automaticStepUnactive('test2'); appendSpan(editor.editable.querySelector('p')); diff --git a/addons/web_editor/static/src/js/backend/QWebPlugin.js b/addons/web_editor/static/src/js/backend/QWebPlugin.js new file mode 100644 index 00000000000..f282c990e45 --- /dev/null +++ b/addons/web_editor/static/src/js/backend/QWebPlugin.js @@ -0,0 +1,230 @@ +/** @odoo-module **/ + +/** + * Returns a list of all the ancestors nodes of the provided node. + * + * @param {Node} node + * @param {Node} [stopElement] include to prevent bubbling up further than the stopElement. + * @returns {HTMLElement[]} + */ +function ancestors(node, stopElement) { + if (!node || !node.parentElement || node === stopElement) return []; + return [node.parentElement, ...ancestors(node.parentElement, stopElement)]; +} + +export class QWebPlugin { + constructor(options = {}) { + this._options = options; + this._tGroupCount = 0; + this._hideBranchingSelection = this._hideBranchingSelection.bind(this); + this._makeBranchingSelection(); + this._clickListeners = []; + } + destroy() { + this._selectElWrapper.remove(); + for (const listener of this._clickListeners) { + document.removeEventListener('click', listener); + } + } + cleanForSave(editable) { + for (const node of editable.querySelectorAll('[data-oe-t-group]')) { + node.removeAttribute('data-oe-t-group'); + node.removeAttribute('data-oe-t-inline'); + node.removeAttribute('data-oe-t-selectable'); + } + } + sanitizeElement(subRoot) { + if (subRoot.nodeType !== Node.ELEMENT_NODE) { + return; + } + if (this._editor) { + this._editor.observerUnactive('qweb-plugin-sanitize'); + } + + this._fixInlines(subRoot); + + const demoElements = subRoot.querySelectorAll('[t-esc], [t-raw], [t-out]'); + for (const element of demoElements) { + element.setAttribute('contenteditable', 'false'); + } + + this._groupQwebBranching(subRoot); + if (this._editor) { + this._editor.observerActive('qweb-plugin-sanitize'); + } + } + _groupQwebBranching(subRoot) { + const tNodes = subRoot.querySelectorAll('[t-if], [t-elif], [t-else]'); + const groupsEncounter = new Set(); + for (const node of tNodes) { + const parentTNode = [...node.parentElement.children]; + const index = parentTNode.indexOf(node); + const prevNode = parentTNode[index - 1]; + + let groupId; + if ( + prevNode && + node.previousElementSibling === prevNode && + !node.hasAttribute('t-if') + ) { + // Make the first t-if selectable, if prevNode is not a t-if, + // it's already data-oe-t-selectable. + prevNode.setAttribute('data-oe-t-selectable', 'true'); + groupId = parseInt(prevNode.getAttribute('data-oe-t-group')); + node.setAttribute('data-oe-t-selectable', 'true'); + } else { + groupId = this._tGroupCount++; + } + groupsEncounter.add(groupId); + node.setAttribute('data-oe-t-group', groupId); + + const clickListener = e => { + e.stopImmediatePropagation(); + this._showBranchingSelection(node); + }; + this._clickListeners.push(clickListener); + node.addEventListener('click', clickListener); + } + for (const groupId of groupsEncounter) { + const isOneElementActive = subRoot.querySelector( + `[data-oe-t-group='${groupId}'][data-oe-t-group-active]`, + ); + // If there is no element in groupId activated, activate the first + // one. + if (!isOneElementActive) { + subRoot + .querySelector(`[data-oe-t-group='${groupId}']`) + .setAttribute('data-oe-t-group-active', 'true'); + } + } + } + _fixInlines(subRoot) { + const checkAllInline = el => { + return [...el.children].every(child => { + if (child.tagName === 'T') { + return checkAllInline(child); + } else { + return ( + child.nodeType !== Node.ELEMENT_NODE || + window.getComputedStyle(child).display === 'inline' + ); + } + }); + }; + const tElements = subRoot.querySelectorAll('t'); + // Wait for the content to be on the dom to check checkAllInline + // otherwise the getComputedStyle will be wrong. + // todo: remove the setTimeout when the editor will provide a signal + // that the editable is on the dom. + setTimeout(() => { + if (this._editor) { + this._editor.observerUnactive('qweb-plugin-checkAllInline'); + } + for (const tElement of tElements) { + if (checkAllInline(tElement)) { + tElement.setAttribute('data-oe-t-inline', 'true'); + } + } + if (this._editor) { + this._editor.observerActive('qweb-plugin-checkAllInline'); + } + }); + } + _makeBranchingSelection() { + const document = this._options.document || window.document; + this._selectElWrapper = document.createElement('div'); + this._selectElWrapper.classList.add('oe-qweb-select'); + this._selectElWrapper.innerHTML = ''; + document.body.append(this._selectElWrapper); + this._hideBranchingSelection(); + } + _showBranchingSelection(target) { + this._hideBranchingSelection(); + + const branchingHierarchyElements = [target, ...ancestors(target, this._options.editable)] + .filter(element => element.getAttribute('data-oe-t-group-active') === 'true') + .filter(element => { + const itemGroupId = element.getAttribute('data-oe-t-group'); + + const groupItemsNodes = element.parentElement.querySelectorAll( + `[data-oe-t-group='${itemGroupId}']`, + ); + return groupItemsNodes.length > 1; + }); + + if (!branchingHierarchyElements.length) return; + + const groupsActive = branchingHierarchyElements.map(node => + node.getAttribute('data-oe-t-group'), + ); + for (const branchingElement of branchingHierarchyElements) { + this._selectElWrapper.prepend(this._renderBranchingSelection(branchingElement)); + } + const closeSelectHandler = event => { + const path = [event.target, ...ancestors(event.target)]; + const shouldClose = !path.find( + element => + element === this._selectElWrapper || + groupsActive.includes(element.getAttribute('data-oe-t-group')), + ); + if (shouldClose) { + this._hideBranchingSelection(); + document.removeEventListener('mousedown', closeSelectHandler); + } + }; + document.addEventListener('mousedown', closeSelectHandler); + this._selectElWrapper.style.display = 'flex'; + this._updateBranchingSelectionPosition( + branchingHierarchyElements[branchingHierarchyElements.length - 1], + ); + } + _updateBranchingSelectionPosition(target) { + window.addEventListener('mousewheel', this._hideBranchingSelection); + + const box = target.getBoundingClientRect(); + const selBox = this._selectElWrapper.getBoundingClientRect(); + + this._selectElWrapper.style.left = `${window.scrollX + box.left}px`; + this._selectElWrapper.style.top = `${window.scrollY + box.top - selBox.height}px`; + } + _renderBranchingSelection(target) { + const selectEl = document.createElement('select'); + const groupId = parseInt(target.getAttribute('data-oe-t-group')); + const groupElements = target.parentElement.querySelectorAll( + `[data-oe-t-group='${groupId}']`, + ); + for (const element of groupElements) { + const optionElement = document.createElement('option'); + if (element.hasAttribute('t-if')) { + optionElement.innerText = 'if'; + } else if (element.hasAttribute('t-elif')) { + optionElement.innerText = 'elif'; + } else if (element.hasAttribute('t-else')) { + optionElement.innerText = 'else'; + } + if (element.hasAttribute('data-oe-t-group-active')) { + optionElement.selected = true; + } + selectEl.appendChild(optionElement); + } + + selectEl.onchange = () => { + let activeElement; + for (let i = 0; i < groupElements.length; i++) { + if (i === selectEl.selectedIndex) { + activeElement = groupElements[i]; + groupElements[i].setAttribute('data-oe-t-group-active', 'true'); + } else { + groupElements[i].removeAttribute('data-oe-t-group-active'); + } + } + this._showBranchingSelection(activeElement); + }; + return selectEl; + } + _hideBranchingSelection() { + this._selectElWrapper.style.display = 'none'; + this._selectElWrapper.innerHTML = ``; + window.removeEventListener('mousewheel', this._hideBranchingSelection); + } +} diff --git a/addons/web_editor/static/src/js/backend/field_html.js b/addons/web_editor/static/src/js/backend/field_html.js index 4d63d107e9e..c7f7c5e1394 100644 --- a/addons/web_editor/static/src/js/backend/field_html.js +++ b/addons/web_editor/static/src/js/backend/field_html.js @@ -6,6 +6,7 @@ var basic_fields = require('web.basic_fields'); var core = require('web.core'); var wysiwygLoader = require('web_editor.loader'); var field_registry = require('web.field_registry'); +const {QWebPlugin} = require('@web_editor/js/backend/QWebPlugin'); // must wait for web/ to add the default html widget, otherwise it would override the web_editor one require('web._field_registry'); @@ -48,6 +49,7 @@ var FieldHtml = basic_fields.DebouncedField.extend(TranslatableFieldMixin, { * @override */ willStart: async function () { + console.log("this:", this); this.isRendered = false; this._onUpdateIframeId = 'onLoad_' + _.uniqueId('FieldHtml'); await this._super(); @@ -66,6 +68,9 @@ var FieldHtml = basic_fields.DebouncedField.extend(TranslatableFieldMixin, { if (this.$iframe) { this.$iframe.remove(); } + if (this._qwebPlugin) { + this._qwebPlugin.destroy(); + } this._super(); }, @@ -207,6 +212,7 @@ var FieldHtml = basic_fields.DebouncedField.extend(TranslatableFieldMixin, { tabsize: 0, height: this.nodeOptions.height || 110, resizable: 'resizable' in this.nodeOptions ? this.nodeOptions.resizable : true, + editorPlugins: [QWebPlugin], }); }, /** @@ -366,6 +372,8 @@ var FieldHtml = basic_fields.DebouncedField.extend(TranslatableFieldMixin, { } else { this.$content = $('').html(value); this.$content.appendTo(this.$el); + this._qwebPlugin = new QWebPlugin(); + this._qwebPlugin.sanitizeElement(this.$content[0]); resolver(); } diff --git a/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js b/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js index ad0208c01c5..b5212e94081 100644 --- a/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js +++ b/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js @@ -108,6 +108,7 @@ const Wysiwyg = Widget.extend({ }, noScrollSelector: 'body, .note-editable, .o_content, #wrapwrap', commands: commands, + plugins: options.editorPlugins, }, editorCollaborationOptions)); const $wrapwrap = $('#wrapwrap'); @@ -686,7 +687,6 @@ const Wysiwyg = Widget.extend({ */ setValue: function (value) { this.$editable.html(value); - // todo: check if it's still usefull this.odooEditor.sanitize(); }, /** diff --git a/addons/web_editor/static/src/scss/web_editor.common.scss b/addons/web_editor/static/src/scss/web_editor.common.scss index 7a16047d4fb..594d2b89981 100644 --- a/addons/web_editor/static/src/scss/web_editor.common.scss +++ b/addons/web_editor/static/src/scss/web_editor.common.scss @@ -816,3 +816,59 @@ section, .oe_img_bg, [data-oe-shape-data] { -webkit-text-fill-color: inherit; } } + +/* QWEB */ + +.odoo-editor, .o_readonly { + t, + [t-if], + [t-elif], + [t-else], + [t-foreach] { + background-color: rgba(0, 0, 102, 0.1) !important; + } + t, + [t-esc], + [t-out], + [t-raw] { + border-radius: 2px; + } + [t-esc], + [t-out], + [t-raw] { + background-color: rgba(36, 154, 255, 0.16) !important; + } + [t-esc]:empty::before { + content: attr(t-esc); + } + [t-raw]:empty::before { + content: attr(t-raw); + } + [t-out]:empty::before { + content: attr(t-out); + } + t[t-set] { + display: none; + } + t[data-oe-t-inline] { + display: inline; + } + t:not([data-oe-t-inline]) { + display: block; + } + t[data-oe-t-inline]:not([data-oe-t-group-active]) { + display: unset; + } + [data-oe-t-group]:not([data-oe-t-group-active]) { + display: none !important; + } + [data-oe-t-group][data-oe-t-selectable] { + outline: 1px dashed rgba(0, 0, 102, 0.4) !important; + } +} + +.oe-qweb-select { + position: absolute; + z-index: 1051; + background-color: white; +} \ No newline at end of file