From e52bde906f1bb5466344fd7873c94f988cdb1eb2 Mon Sep 17 00:00:00 2001 From: Nicolas Bayet Date: Wed, 16 Jun 2021 10:28:58 +0000 Subject: [PATCH] [IMP] web_editor: allow qweb edition Add a plugin for the Odoo editor that includes: - make dynamic variables (eg. ``) not editable (for preventing the user to shoot himself in the foot) - group and hide related logical branching (ie. t-if, t-elif and t-else) in order to see only one at once - a floating select input to switch visibility of a particular logical branching - style t-tags to make them stand out Task-27033 X-original-commit: odoo/odoo@300da82eb405f8c661b69051dc25b78a272f59c4 Part-of: odoo/odoo#77377 --- .../static/lib/odoo-editor/src/OdooEditor.js | 33 ++- .../static/lib/odoo-editor/src/qweb_sample.js | 164 +++++++++++++ .../static/lib/odoo-editor/src/style.css | 53 ---- .../static/lib/odoo-editor/src/utils/utils.js | 25 +- .../odoo-editor/test/spec/autostep.test.js | 17 ++ .../static/src/js/backend/QWebPlugin.js | 230 ++++++++++++++++++ .../static/src/js/backend/field_html.js | 8 + .../static/src/js/wysiwyg/wysiwyg.js | 2 +- .../static/src/scss/web_editor.common.scss | 56 +++++ 9 files changed, 529 insertions(+), 59 deletions(-) create mode 100644 addons/web_editor/static/lib/odoo-editor/src/qweb_sample.js create mode 100644 addons/web_editor/static/src/js/backend/QWebPlugin.js 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 */ ` +

Qweb examples

+
+ + foo + foo_raw + +
+ +

t-esc in link

+Link without t-esc +Link with t-esc +Link with t-esc and strong + +

if else part 1

+
+ + Brandon Freeman (Azure Interior), + + + Brandon Freeman, + +
+ +

if else part 2

+
+ +

if1

+ +

if1.a

+
+ +

elif1.b

+
+ +

elif1.c

+
+
+ +

if2

+
+ +

elif2.1

+
+ +

elif2.1

+ + +

if2.1.1

+
+ +

elif2.1.2

+
+ +

elif2.1.3

+
+
+
+ +

if else part 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!

+
+ + +

+
+ +

+ +

+ + +
  • ok
  • +
    + + + + + + + + +
    + This template was called with content: + +
    + +

    t-if should be inline

    +
    + + + Accept + + Decline + + View +
    + +

    should see the t-if background color

    +
    +
      + +
    • Description: Meeting to discuss project plan and hash out the details of implementation.
    • +
      + + +
    • Description: +
        +
      • Email: my.email@test.example.com
      • +
      +
    • +
      +
    +
    +`; diff --git a/addons/web_editor/static/lib/odoo-editor/src/style.css b/addons/web_editor/static/lib/odoo-editor/src/style.css index 0e8376cab24..f54edeb88c0 100644 --- a/addons/web_editor/static/lib/odoo-editor/src/style.css +++ b/addons/web_editor/static/lib/odoo-editor/src/style.css @@ -328,56 +328,3 @@ i.oe-commandbar-commandImg { border-radius: 2px; padding: 0.3em 0.6em; } - -/* QWEB */ - -.odoo-editor t, -.odoo-editor [t-if], -.odoo-editor [t-elif], -.odoo-editor [t-else], -.odoo-editor [t-foreach] { - background-color: rgba(0, 0, 102, 0.1) !important; -} -.odoo-editor t, -.odoo-editor [t-esc], -.odoo-editor [t-out], -.odoo-editor [t-raw] { - border-radius: 2px; -} -.odoo-editor [t-esc], -.odoo-editor [t-out], -.odoo-editor [t-raw] { - background-color: rgb(36 154 255 / 16%) !important; -} -.odoo-editor [t-esc]:empty::before { - content: attr(t-esc); -} -.odoo-editor [t-raw]:empty::before { - content: attr(t-raw); -} -.odoo-editor [t-out]:empty::before { - content: attr(t-out); -} -.odoo-editor t[t-set] { - display: none; -} -.odoo-editor t[data-oe-t-inline] { - display: inline; -} -.odoo-editor t:not([data-oe-t-inline]) { - display: block; -} -.odoo-editor t[data-oe-t-inline]:not([data-oe-t-group-active]) { - display: unset; -} -.odoo-editor [data-oe-t-group]:not([data-oe-t-group-active]) { - display: none !important; -} -.odoo-editor [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; -} diff --git a/addons/web_editor/static/lib/odoo-editor/src/utils/utils.js b/addons/web_editor/static/lib/odoo-editor/src/utils/utils.js index f1d64d34747..dbefdc357c7 100644 --- a/addons/web_editor/static/lib/odoo-editor/src/utils/utils.js +++ b/addons/web_editor/static/lib/odoo-editor/src/utils/utils.js @@ -851,7 +851,13 @@ export function isBlock(node) { } const tagName = node.nodeName.toUpperCase(); // Every custom jw-* node will be considered as blocks. - if (tagName.startsWith('JW-') || tagName === 'T') { + if ( + tagName.startsWith('JW-') || + (tagName === 'T' && + node.getAttribute('t-esc') === null && + node.getAttribute('t-out') === null && + node.getAttribute('t-raw') === null) + ) { return true; } if (tagName === 'BR') { @@ -903,6 +909,16 @@ export function isUnbreakable(node) { isUnremovable(node) || // An unremovable node is always unbreakable. ['THEAD', 'TBODY', 'TFOOT', 'TR', 'TH', 'TD', 'SECTION', 'DIV'].includes(node.tagName) || node.hasAttribute('t') || + (node.nodeType === Node.ELEMENT_NODE && + (node.nodeName === 'T' || + node.getAttribute('t-if') || + node.getAttribute('t-esc') || + node.getAttribute('t-elif') || + node.getAttribute('t-else') || + node.getAttribute('t-foreach') || + node.getAttribute('t-value') || + node.getAttribute('t-out') || + node.getAttribute('t-raw'))) || node.classList.contains('oe_unbreakable') ); } @@ -916,7 +932,12 @@ export function isUnremovable(node) { node.parentElement && !node.parentElement.isContentEditable && node.nodeName !== 'A'; // links can be their own contenteditable but should be removable by default. - return isEditableRoot || (node.classList && node.classList.contains('oe_unremovable')); + return ( + isEditableRoot || + (node.nodeType === Node.ELEMENT_NODE && + (node.getAttribute('t-set') || node.getAttribute('t-call'))) || + (node.classList && node.classList.contains('oe_unremovable')) + ); } export function containsUnbreakable(node) { diff --git a/addons/web_editor/static/lib/odoo-editor/test/spec/autostep.test.js b/addons/web_editor/static/lib/odoo-editor/test/spec/autostep.test.js index 969bda95985..c9e6cad8f32 100644 --- a/addons/web_editor/static/lib/odoo-editor/test/spec/autostep.test.js +++ b/addons/web_editor/static/lib/odoo-editor/test/spec/autostep.test.js @@ -17,6 +17,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); + 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