[IMP] web_editor: allow qweb edition

Add a plugin for the Odoo editor that includes:
- make dynamic variables (eg. `<t t-out="variable"/>`) 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@300da82eb4
Part-of: odoo/odoo#77377
This commit is contained in:
Nicolas Bayet
2021-09-28 23:42:55 +00:00
committed by Thibault Delavallée
parent 810f832703
commit e52bde906f
9 changed files with 529 additions and 59 deletions
@@ -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);
}
}
}
}
@@ -0,0 +1,164 @@
export const qwebSample = /* xml */ `
<h1>Qweb examples</h1>
<div>
<t t-set="foo1" t-value="2 + 1"></t>
<t t-esc="object">foo</t>
<t t-raw="object">foo_raw</t>
<t t-esc="invisible"></t>
</div>
<h2>t-esc in link</h2>
<a href="#"="foo">Link without t-esc</a>
<a href="#" t-esc="foo">Link with t-esc</a>
<a href="#"><strong t-esc="foo">Link with t-esc and strong</strong></a>
<h2>if else part 1</h2>
<div>
<t t-if="record.partner_id.parent_id">
<t t-esc="record.partner_id.name">Brandon Freeman</t> (<t t-esc="record.partner_id.parent_id.name">Azure Interior</t>),
</t>
<t t-else="">
<t t-esc="record.partner_id.name">Brandon Freeman</t>,
</t>
</div>
<h2>if else part 2</h2>
<div>
<t t-if="condition">
<p>if1</p>
<t t-if="condition">
<p>if1.a</p>
</t>
<t t-elif="condition">
<p>elif1.b</p>
</t>
<t t-else="condition">
<p>elif1.c</p>
</t>
</t>
<t t-if="condition">
<p>if2</p>
</t>
<t t-elif="condition">
<p>elif2.1</p>
</t>
<t t-else="condition">
<p>elif2.1</p>
<t t-if="condition">
<p>if2.1.1</p>
</t>
<t t-elif="condition">
<p>elif2.1.2</p>
</t>
<t t-else="condition">
<p>elif2.1.3</p>
</t>
</t>
</div>
<h2>if else part 3</h2>
<div>
<t t-if="condition">
<p>if</p>
<t t-if="condition">
<p>if/if</p>
</t>
<t t-elif="condition">
<p>if/elsif</p>
</t>
<t t-else="condition">
<p>if/else</p>
</t>
</t>
<t t-elif="condition">
<p>elif</p>
<t t-if="condition">
<p>elif/if</p>
</t>
<t t-elif="condition">
<p>elif/elif</p>
</t>
<t t-else="condition">
<p>elif/else</p>
</t>
</t>
<t t-else="condition">
<p>else</p>
<t t-if="condition">
<p>else/if</p>
</t>
<t t-elif="condition">
<p>else/elif</p>
</t>
<t t-else="condition">
<p>else/else</p>
</t>
</t>
</div>
<div>
<p t-esc="value">the value</p>
</div>
<div>
<p t-if="condition">ok</p>
</div>
<div>
<p t-if="user.birthday == today()">Happy birthday!</p>
<p t-elif="user.login == 'root'">Welcome master!</p>
<p t-else="">Welcome!</p>
</div>
<t t-foreach="[1, 2, 3]" t-as="i">
<p><t t-esc="i"></t></p>
</t>
<p t-foreach="[1, 2, 3]" t-as="i">
<t t-esc="i"></t>
</p>
<t t-set="foo2">
<li>ok</li>
</t>
<t t-esc="foo2"></t>
<t t-call="other-template"></t>
<t t-call="other-template">
<t t-set="foo3" t-value="1"></t>
</t>
<div>
This template was called with content:
<t t-raw="0"></t>
</div>
<h2>t-if should be inline</h2>
<div style="text-align: center; margin: 16px 0px 16px 0px;">
<t t-if="not is_online or object.state != 'accepted'">
<a t-attf-href="/calendar/meeting/accept?token={{object.access_token}}&amp;id={{object.event_id.id}}" style="padding: 5px 10px; color: #FFFFFF; text-decoration: none; background-color: #875A7B; border: 1px solid #875A7B; border-radius: 3px">
Accept</a>
<a t-attf-href="/calendar/meeting/decline?token={{object.access_token}}&amp;id={{object.event_id.id}}" style="padding: 5px 10px; color: #FFFFFF; text-decoration: none; background-color: #875A7B; border: 1px solid #875A7B; border-radius: 3px">
Decline</a>
</t>
<a t-attf-href="/calendar/meeting/view?token={{object.access_token}}&amp;id={{object.event_id.id}}" style="padding: 5px 10px; color: #FFFFFF; text-decoration: none; background-color: #875A7B; border: 1px solid #875A7B; border-radius: 3px"><t t-esc="'Reschedule' if is_online and target_customer else 'View'">View</t></a>
</div>
<h2>should see the t-if background color</h2>
<div style="padding-top:5px;">
<ul>
<t t-if="not is_online and object.event_id.description">
<li>Description: <t t-esc="object.event_id.description or ''" data-oe-t-inline="true">Meeting to discuss project plan and hash out the details of implementation.</t></li>
</t>
<t t-elif="is_online and object.event_id.description">
<t t-set="object.event_id.description_to_html_lines()" t-value="splitted_description" data-oe-t-inline="true"></t>
<li>Description:
<ul t-foreach="splitted_description" t-as="description_line">
<li t-out="description_line or ''">Email: my.email@test.example.com</li>
</ul>
</li>
</t>
</ul>
</div>
`;
@@ -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;
}
@@ -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) {
@@ -17,6 +17,12 @@ describe('Autostep', () => {
contentBefore: '<p>a[]</p>',
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: '<p>a[]</p>',
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'));
@@ -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);
}
}
@@ -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 = $('<div class="o_readonly"/>').html(value);
this.$content.appendTo(this.$el);
this._qwebPlugin = new QWebPlugin();
this._qwebPlugin.sanitizeElement(this.$content[0]);
resolver();
}
@@ -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();
},
/**
@@ -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;
}