diff --git a/addons/knowledge/__manifest__.py b/addons/knowledge/__manifest__.py index e7a8af562bc..1426b720998 100644 --- a/addons/knowledge/__manifest__.py +++ b/addons/knowledge/__manifest__.py @@ -51,6 +51,15 @@ 'knowledge/static/src/js/widgets/knowledge_emoji_picker.js', 'knowledge/static/src/webclient/commands/*.js', 'knowledge/static/src/models/*/*.js', + 'knowledge/static/src/js/form_controller.js', + 'knowledge/static/src/js/form_renderer.js', + 'knowledge/static/src/js/knowledge_macros.js', + 'knowledge/static/src/js/knowledge_behaviors.js', + 'knowledge/static/src/js/knowledge_toolbars.js', + 'knowledge/static/src/js/knowledge_field_html_injector.js', + 'knowledge/static/src/js/knowledge_plugin.js', + 'knowledge/static/src/js/field_html.js', + 'knowledge/static/src/js/knowledge_service.js', ], 'web.assets_frontend': [ 'knowledge/static/src/scss/knowledge_frontend.scss', @@ -58,17 +67,27 @@ 'knowledge/static/src/js/knowledge_frontend.js', 'knowledge/static/src/js/tools/tree_panel_mixin.js', ], + 'web_editor.assets_wysiwyg': [ + 'knowledge/static/src/js/wysiwyg.js', + 'knowledge/static/src/js/knowledge_toolbars_edit.js', + 'knowledge/static/src/js/knowledge_clipboard_whitelist.js' + ], 'web.assets_qweb': [ 'knowledge/static/src/components/*/*.xml', + 'knowledge/static/src/xml/knowledge_editor.xml', 'knowledge/static/src/xml/knowledge_templates.xml', 'knowledge/static/src/xml/chatter_topbar.xml', 'knowledge/static/src/xml/knowledge_command_palette.xml', + 'knowledge/static/src/xml/knowledge_toolbars.xml', ], 'web.assets_tests': [ 'knowledge/static/tests/tours/*.js', ], 'web.qunit_suite_tests': [ - 'knowledge/static/src/tests/test_services.js', + 'knowledge/static/tests/test_services.js', + ], + 'web.qunit_mobile_suite_tests': [ + 'knowledge/static/tests/test_services.js', ], }, } diff --git a/addons/knowledge/static/src/js/field_html.js b/addons/knowledge/static/src/js/field_html.js new file mode 100644 index 00000000000..c91b3fe4531 --- /dev/null +++ b/addons/knowledge/static/src/js/field_html.js @@ -0,0 +1,85 @@ +/** @odoo-module */ + +import FieldHtml from 'web_editor.field.html'; +import {FieldHtmlInjector} from './knowledge_field_html_injector'; +import {KnowledgePlugin} from './knowledge_plugin'; + +FieldHtml.include({ + /** + * @override + */ + _renderReadonly: function () { + const prom = this._super.apply(this, arguments); + if (prom) { + return prom.then(function () { + return this._addFieldHtmlInjector(); + }.bind(this)); + } else { + return this._addFieldHtmlInjector(); + } + }, + /** + * Add a @see FieldHtmlInjector to this field_html, which will add temporary + * @see KnowledgeBehavior and/or @see KnowledgeToolbar to @see OdooEditor + * blocks. Delegate control of the editor to the injector, mainly to access + * history methods. i.e.: @see KnowledgeToolbar insertion in the dom should + * not be registered in the user history + * + * @returns {Promise} + */ + _addFieldHtmlInjector: function () { + if (this.$content && this.$content.length) { + const editor = (this.mode === 'edit' && this.wysiwyg) ? this.wysiwyg.odooEditor : null; + const fieldHtmlInjector = new FieldHtmlInjector(this, this.mode, this.$content[0], editor); + return fieldHtmlInjector.appendTo(this.el); + } + }, + /** + * Some events can cause the insertion of new elements which may need a + * @see KnowledgeBehavior and/or @see KnowledgeToolbar , so the + * @see FieldHtmlInjector needs to be notified. + * + * @override + */ + _onLoadWysiwyg: function () { + this._super.apply(this, arguments); + this._addFieldHtmlInjector(); + /** + * setTimeout is called so that the refresh_injector event is dispatched + * at the start of the next event cycle. This is to ensure that other + * handlers for those events have execution priority over the refresh. + * In particular, the content to drop in the editor would be inserted + * after the refreshInjector without setTimeout. + */ + const refreshInjector = () => setTimeout(this.refreshInjector.bind(this)); + this.wysiwyg.odooEditor.addEventListener('historyUndo', refreshInjector); + this.wysiwyg.odooEditor.addEventListener('historyRedo', refreshInjector); + this.$content[0].addEventListener('paste', refreshInjector); + this.$content[0].addEventListener('drop', refreshInjector); + }, + /** + * Add a plugin (@see KnowledgePlugin ) for the @see OdooEditor which + * implements @see cleanForSave in order to remove @see KnowledgeToolbar + * before saving a record. + * + * @override + */ + _getWysiwygOptions: function () { + const options = this._super.apply(this, arguments); + if (Array.isArray(options.editorPlugins)) { + options.editorPlugins.push(KnowledgePlugin); + } else { + options.editorPlugins = [KnowledgePlugin]; + } + return options; + }, + /** + * Notify the @see FieldHtmlInjector to render and apply KnowledgeToolbar + * and KnowledgeBehavior where it is needed. + */ + refreshInjector: function () { + if (this.$content) { + this.$content.trigger('refresh_injector'); + } + }, +}); diff --git a/addons/knowledge/static/src/js/form_controller.js b/addons/knowledge/static/src/js/form_controller.js new file mode 100644 index 00000000000..bde245e2e8b --- /dev/null +++ b/addons/knowledge/static/src/js/form_controller.js @@ -0,0 +1,192 @@ +/** @odoo-module **/ + +import FormController from 'web.FormController'; +import { Domain } from '@web/core/domain'; + +FormController.include({ + /** + * Knowledge articles can interact with some records with the help of the + * @see KnowledgeService. Those records need to have a field whose name is + * in @see knowledgeTriggerFieldNames. This list is ordered and the first + * match found in a record will take precedence. Once a match is found, it + * is stored in the KnowledgeService to be accessed later by an article. + * + * @override + */ + init: function (parent, model, renderer, params) { + this._super.apply(this, arguments); + this.knowledgeRecordFieldNames = [ + 'note', 'memo', 'description', 'comment', 'narration', 'additional_note', 'internal_notes', 'notes' + ]; + if (this._isPotentialKnowledgeRecord()) { + this._searchKnowledgeRecord(); + } + if (this.ignoreKnowledgeRecordSearch) { + this._unregisterObsoleteKnowledgeRecords(this.renderer.breadcrumbs); + } + }, + /** + * Since Knowledge toolbars are cleaned before saving the record + * @see KnowledgePlugin , the renderer needs to render them again once it is + * done. + * + * @override + * @param {string} recordID + */ + saveRecord: async function (recordID) { + const changedFields = this._super(...arguments); + recordID = recordID || this.handle; + if (recordID) { + changedFields.then(() => { + this.renderer.refreshFieldHtmlInjectors(recordID); + }); + } + return changedFields; + }, + /** + * Notify the @see KnowledgeService that the record was updated + * + * @override + */ + update: async function (params, options) { + await this._super(...arguments); + if (this._isPotentialKnowledgeRecord()) { + this._searchKnowledgeRecord(); + } + if (this.ignoreKnowledgeRecordSearch) { + this._unregisterObsoleteKnowledgeRecords(this.renderer.breadcrumbs); + } + }, + /** + * Evaluate the provided breadcrumbs sequence against currently recorded + * records in @see KnowledgeService to remove them if they are obsolete. + * Breadcrumb information does not contain the res_id of the record for a + * form view, and the actionService does not allow to access the breadcrumb + * controller externally. This is why the full breadcrumb string sequence is + * used here as an identifier. + * + * @param {Array} breadcrumbs + * @param {boolean} revoke whether the record is deemed obsolete if a match + * is found or not. + */ + _unregisterObsoleteKnowledgeRecords: function (breadcrumbs, revoke = false) { + const records = this.call('knowledgeService', 'getRecords'); + let isObsolete = revoke; + for (let record of records) { + if (record.breadcrumbs.length > breadcrumbs.length) { + isObsolete = !revoke; + } else { + const slicedBreadcrumbs = breadcrumbs.slice(0, record.breadcrumbs.length); + if (_.isEqual(slicedBreadcrumbs, record.breadcrumbs)) { + isObsolete = revoke; + } else { + isObsolete = !revoke; + } + } + if (isObsolete) { + this.call('knowledgeService', 'unregisterRecord', record); + } + } + }, + /** + * Conditions for a record to be usable by a Knowledge article: + * - The current view is not a Knowledge form + * - has a ControlPanel which uses breadcrumbs + * - is the result of an action + * - displays an existing record (already stored in DB) + * (saved new records will be registered upon @see update ) + */ + _isPotentialKnowledgeRecord: function () { + return !this.ignoreKnowledgeRecordSearch && this.withControlPanel && this.controlPanelProps.withBreadcrumbs && + this.controlPanelProps.action && this.controlPanelProps.action.controllers && + this.handle && !this.model.localData[this.handle].isNew(); + }, + /** + * @param {Object} record raw record + * @param {string} modifier modifier as registered in the view (xml) + * @returns {boolean} whether the domain includes this record + */ + _readModifier: function (record, modifier) { + if (!modifier) { + // falsy modifier + return false; + } + let value = false; + try { + // unaware of context + const preDomain = new Domain(modifier); + // aware of context + const domain = new Domain(preDomain.toList(record.context)); + value = domain.contains(record.data); + } catch (_error) { + // truthy modifier + return true; + } + return value; + }, + /** + * Evaluate the current record and notify @see KnowledgeService if it can or + * could be used in a Knowledge article. + * + * @private + */ + _searchKnowledgeRecord: function () { + const record = this.model.get(this.handle, {raw: true}); + const controller = this.controlPanelProps.action.controllers.form; + const breadcrumbs = this.controlPanelProps.breadcrumbs.slice(); + // add the current breadcrumbs information + breadcrumbs.push({ + title: record.data.display_name, + controllerID: controller.jsId, + }); + /** + * If the current potential record has exactly the same breadcrumbs + * sequence as another record registered in the @see KnowledgeService, + * the previous record should be unregistered here because this problem + * will not be caught later, as the Knowledge form view only checks + * whether its breadcrumbs sequence contains a record's breadcrumbs + * sequence, regardless of the fact that the current potential record + * may not have been registered in the service. + * + * This call could be omitted if the breadcrumbs would also store the + * related res_id if any, but currently two records with the same + * display name and model will have exactly the same breadcrumbs + * information (controllerID and title). + */ + this._unregisterObsoleteKnowledgeRecords(breadcrumbs, true); + const hasMessageIds = this.renderer.chatterFields && this.renderer.chatterFields.hasMessageIds; + const view = this.controlPanelProps.view; + const fields = view.viewFields; + const formFields = view.fieldsInfo.form; + const knowledgeRecord = { + res_id: record.res_id, + res_model: view.model, + breadcrumbs: breadcrumbs, + fieldNames: [], + }; + if (hasMessageIds) { + knowledgeRecord.withChatter = true; + this.call('knowledgeService', 'registerRecord', knowledgeRecord); + } + for (let fieldName of this.knowledgeRecordFieldNames) { + if (fieldName in formFields && fields[fieldName].type === 'html' && !fields[fieldName].readonly) { + const readonlyModifier = formFields[fieldName].modifiers.readonly; + const invisibleModifier = formFields[fieldName].modifiers.invisible; + if (this._readModifier(record, readonlyModifier) || this._readModifier(record, invisibleModifier)) { + continue; + } + knowledgeRecord.fieldNames.push({ + name: fieldName, + string: fields[fieldName].string, + }); + } + } + /** + * While a field by itself can be visible and not readonly, its + * group / notebook pane could be invisible. Another check will be done + * after rendering to determine if any of the catched fields are in fact + * usable by the user. @see FormRenderer + */ + this.call('knowledgeService', 'pushToValidateWithHtmlField', knowledgeRecord); + }, +}); diff --git a/addons/knowledge/static/src/js/form_renderer.js b/addons/knowledge/static/src/js/form_renderer.js new file mode 100644 index 00000000000..2770b71ecdd --- /dev/null +++ b/addons/knowledge/static/src/js/form_renderer.js @@ -0,0 +1,77 @@ +/** @odoo-module **/ + +import FormRenderer from 'web.FormRenderer'; +import core from 'web.core'; + +FormRenderer.include({ + /** + * Refresh the injector of html fields (to update toolbars and behaviors) + * @see FieldHtmlInjector + * + * @param {string} recordID + */ + refreshFieldHtmlInjectors: function (recordID) { + const recordWidgets = this.allFieldWidgets[recordID]; + if (!recordWidgets) { + return; + } + recordWidgets.filter(widget => widget.field.type === 'html' && typeof widget.refreshInjector === 'function') + .forEach(widget => { + widget.refreshInjector(); + }); + }, + /** + * Once the view is rendered, check whether a record's field registered in + * @see KnowledgeService is available to the user directly or through + * an inactive pane of the notebook. + * + * @override + */ + _render: async function () { + await this._super(...arguments); + core.bus.once('DOM_updated', this, function () { + let record = this.call('knowledgeService', 'popToValidateWithHtmlField'); + while (record) { + for (let fieldName = record.fieldNames.shift(); fieldName; fieldName = record.fieldNames.shift()) { + if (this._checkAvailability(fieldName.name)) { + record.fieldNames = [fieldName]; + record.withHtmlField = true; + break; + } + } + if (record.withHtmlField) { + this.call('knowledgeService', 'registerRecord', record); + break; + } + record = this.call('knowledgeService', 'popToValidateWithHtmlField'); + } + }); + }, + /** + * Inspect the view to check whether the field matching "name" is accessible + * in the view at least once, directly or through a notebook pane switch. + * + * @param {string} name of a field of the record + * @returns {boolean} + */ + _checkAvailability: function (name) { + const selector = `.oe_form_field_html[name="${name}"]`; + const $sel = $(selector); + for (let i = 0; i < $sel.length; i++) { + if ($sel.eq(i).is(':visible:hasVisibility')) { + return true; + } else if ($sel[i].closest('.o_invisible_modifier')) { + continue; + } else { + let pane = $sel[i].closest('.tab-pane:not(.active)'); + if (pane) { + let $paneSwitch = $(`[data-toggle="tab"][href*="${pane.id}"]`); + if ($paneSwitch.is(':visible:hasVisibility')) { + return true; + } + } + } + } + return false; + } +}); diff --git a/addons/knowledge/static/src/js/knowledge_behaviors.js b/addons/knowledge/static/src/js/knowledge_behaviors.js new file mode 100644 index 00000000000..28d08c09acc --- /dev/null +++ b/addons/knowledge/static/src/js/knowledge_behaviors.js @@ -0,0 +1,88 @@ +/** @odoo-module */ + +import Class from 'web.Class'; + +/** + * Behavior to be injected through @see FieldHtmlInjector to @see OdooEditor + * blocks which have specific classes calling for such behaviors. + * + * A typical usage could be the following: + * - An @see OdooEditor block like /template has the generic class: + * @see o_knowledge_behavior_anchor to signify that it needs to have a + * behavior injected. + * - This block also has the specific class: + * @see o_knowledge_behavior_type_[behaviorType] which specifies the type of + * behavior that needs to be injected. @see FieldHtmlInjector has a dictionary + * mapping those classes to the correct behavior class. + * + * The @see KnowledgeBehavior is a basic behavior intended to be overriden for + * more complex implementations + */ +const KnowledgeBehavior = Class.extend({ + /** + * @param {Widget} handler @see FieldHtmlInjector which has access to + * widget specific functions + * @param {Element} anchor dom node to apply the behavior to + * @param {string} mode edit/readonly + */ + init: function (handler, anchor, mode) { + this.handler = handler; + this.anchor = anchor; + this.mode = mode; + if (this.handler.editor) { + this.handler.editor.observerUnactive('knowledge_attributes'); + } + this.applyAttributes(); + if (this.handler.editor) { + this.handler.editor.observerActive('knowledge_attributes'); + } + this.applyListeners(); + }, + /** + * Add specific attributes related to this behavior to this.anchor + */ + applyAttributes: function () {}, + /** + * Add specific listeners related to this behavior to this.anchor + */ + applyListeners: function () {}, + /** + * Disable the listeners added in @see applyListeners + */ + disableListeners: function () {}, + /** + * Used by @see KnowledgePlugin to remove behaviors when the field_html is + * saved. Also used by @see FieldHtmlInjector to manage injected behaviors + */ + removeBehavior: function () { + this.handler.trigger_up('behavior_removed', { + anchor: this.anchor, + }); + this.disableListeners(); + delete this.anchor.oKnowledgeBehavior; + }, +}); + +/** + * A behavior to set a block as uneditable. Such a block can have children + * marked as @see o_knowledge_content which are set as editable + */ +const ContentsContainerBehavior = KnowledgeBehavior.extend({ + /** + * @override + */ + applyAttributes: function () { + this._super.apply(this, arguments); + if (this.mode === 'edit') { + this.anchor.querySelectorAll('.o_knowledge_content').forEach(element => { + element.setAttribute('contenteditable', 'true'); + }); + this.anchor.setAttribute('contenteditable', 'false'); + } + }, +}); + +export { + KnowledgeBehavior, + ContentsContainerBehavior, +}; diff --git a/addons/knowledge/static/src/js/knowledge_clipboard_whitelist.js b/addons/knowledge/static/src/js/knowledge_clipboard_whitelist.js new file mode 100644 index 00000000000..eecb70e1a47 --- /dev/null +++ b/addons/knowledge/static/src/js/knowledge_clipboard_whitelist.js @@ -0,0 +1,8 @@ +/** @odoo-module */ + +import { CLIPBOARD_WHITELISTS } from '@web_editor/../lib/odoo-editor/src/OdooEditor'; + +/** + * Allow @see o_knowledge_ classes to be preserved in the editor on paste. + */ +CLIPBOARD_WHITELISTS.classes.push(/^o_knowledge_/); diff --git a/addons/knowledge/static/src/js/knowledge_controller.js b/addons/knowledge/static/src/js/knowledge_controller.js index 69d8da01f74..09a49da2b02 100644 --- a/addons/knowledge/static/src/js/knowledge_controller.js +++ b/addons/knowledge/static/src/js/knowledge_controller.js @@ -28,7 +28,23 @@ const KnowledgeArticleFormController = FormController.extend({ emoji_click: '_onEmojiClick', }), + /** + * Register the fact that the current @see FormController is one from + * Knowledge in order not to try and search for a matching record for + * @see KnowledgeService . + * + * @override + */ init: function (parent, model, renderer, params) { + /** + * This property is used to specify that the current form view will + * get/use records stored in the @see KnowledgeService instead of + * replacing them (default). TODO ABD: maybe set this as an option of + * the form view (XML) so that other form views can easily specify that + * they will get/use records from the KnowledgeService instead of + * providing them. + */ + this.ignoreKnowledgeRecordSearch = true; this.renderer = renderer; this._super.apply(this, arguments); this.onFieldSavedListeners = new Map(); diff --git a/addons/knowledge/static/src/js/knowledge_field_html_injector.js b/addons/knowledge/static/src/js/knowledge_field_html_injector.js new file mode 100644 index 00000000000..f1668508e03 --- /dev/null +++ b/addons/knowledge/static/src/js/knowledge_field_html_injector.js @@ -0,0 +1,314 @@ +/** @odoo-module */ +import Widget from 'web.Widget'; +import { ComponentWrapper, WidgetAdapterMixin } from 'web.OwlCompatibility'; +import { useService } from "@web/core/utils/hooks"; +import { TemplateToolbar, FileToolbar } from './knowledge_toolbars'; +import { ContentsContainerBehavior } from './knowledge_behaviors'; +const { Component } = owl; + +/** + * Component used to access the @see uiService + */ +class FieldHtmlInjectorComponent extends Component { + setup() { + this.uiService = useService('ui'); + } +} +FieldHtmlInjectorComponent.template = 'knowledge.injector_component'; +/** + * Widget used along @see FieldHtml to inject specific @see KnowledgeBehavior + * and/or @see KnowledgeToolbar for specific blocks elements. These behaviors + * can be used in both edit and readonly modes, and will contribute to a more + * interactive @see FieldHtml . Toolbars added through this injector will be + * cleaned before save through the @see KnowledgePlugin and won't be part of + * the field final value. + */ +const FieldHtmlInjector = Widget.extend(WidgetAdapterMixin, { + custom_events: { + toolbar_removed: '_onToolbarRemoved', + behavior_removed: '_onBehaviorRemoved', + }, + /** + * Map classes used in @see OdooEditor blocks to their corresponding + * @see KnowledgeToolbar and template. + */ + toolbar_types: { + o_knowledge_toolbar_type_template: { + template: 'knowledge.template_toolbar', + Toolbar: TemplateToolbar, + }, + o_knowledge_toolbar_type_file: { + template: 'knowledge.file_toolbar', + Toolbar: FileToolbar, + }, + }, + /** + * Map classes used in @see OdooEditor blocks to their corresponding + * @see KnowledgeBehavior + */ + behavior_types: { + o_knowledge_behavior_type_contents_container: { + Behavior: ContentsContainerBehavior, + }, + }, + /** + * @override + * @param {Widget} parent + * @param {string} mode edit/readonly + * @param {Element} field content of the @see FieldHtml + * @param {Object} editor @see OdooEditor + */ + init: function (parent, mode, field, editor) { + this._super.apply(this, arguments); + // store every toolbar anchor elements + this.toolbar_anchors = new Set(); + // store every behavior anchor elements + this.behavior_anchors = new Set(); + this.mode = mode; + this.field = field; + this.editor = editor; + }, + /** + * Start the injection process and setup injection event listeners. + * The component is used to access uiService. This widget should be + * converted to a component. TODO ABD + * @see owl_compatibility + * + * @override + */ + start: function () { + const prom = this._super.apply(this, arguments); + this.component = new ComponentWrapper(this, FieldHtmlInjectorComponent, {}); + const componentPromise = this.component.mount(this.el); + $(this.field).on('refresh_injector', this._onRefreshInjector.bind(this)); + return Promise.all([prom, componentPromise]).then(function() { + this.manageBehaviors(); + return this.manageToolbars(); + }.bind(this)); + }, + /** + * @see owl_compatibility + */ + update: function () { + return this.component.update({}); + }, + /** + * Setup the behavior injection process + */ + manageBehaviors: function() { + $(this.field).on('refresh_behaviors', this._onRefreshBehaviors.bind(this)); + this.updateBehaviors(); + }, + /** + * Setup the toolbar injection process + * + * @returns {Promise} promise to append the initial batch of + * @see KnowledgeToolbar + */ + manageToolbars: function() { + $(this.field).on('refresh_toolbars', this._onRefreshToolbars.bind(this)); + return this.updateToolbars(); + }, + /** + * Scan this.field for every Toolbar container and anchor. Remove and + * destroy currently registered obsolete toolbars. + * + * @param {Array[Object]} toolbarsData array to be filled with data + * currently present in this.field + */ + _scanFieldForToolbars: function (toolbarsData) { + const anchors = new Set(); + const types = new Set(Object.getOwnPropertyNames(this.toolbar_types)); + this.field.querySelectorAll('.o_knowledge_toolbars_container').forEach(function (types, container) { + container.querySelectorAll('.o_knowledge_toolbar_anchor').forEach(function (types, container, anchor) { + const type = Array.from(anchor.classList).find(className => types.has(className)); + if (type) { + toolbarsData.push({ + container: container, + anchor: anchor, + type: type, + }); + anchors.add(anchor); + } + }.bind(this, types, container)); + }.bind(this, types)); + // difference between the stored set and the computed one + const differenceAnchors = new Set([...this.toolbar_anchors].filter(anchor => !anchors.has(anchor))); + // remove obsolete toolbars + differenceAnchors.forEach(anchor => { + if (anchor.oKnowledgeToolbar) { + anchor.oKnowledgeToolbar.removeToolbar(); + } + }); + }, + /** + * If toolbarsData is set, update only those toolbars, if not, recompute + * every Toolbar of this.field + * + * @param {Array[Object]} toolbarsData + * @param {Element} [container] element which has a toolbar anchor + * @param {Element} [anchor] element in which to inject the toolbar + * @param {string} [type] html class representing the toolbar type + * (i.e.: @see o_knowledge_toolbar_type_... ) + * @returns {Promise} promise to append toolbars + */ + updateToolbars: function (toolbarsData = []) { + if (this.editor) { + this.editor.observerUnactive(); + } + if (!toolbarsData.length) { + // no toolbarsData, recreate the array from the field value + this._scanFieldForToolbars(toolbarsData); + } + + // inject new toolbars + const promises = []; + toolbarsData.forEach(toolbarData => { + const anchor = toolbarData.anchor; + if (!anchor.oKnowledgeToolbar) { + promises.push(this._createToolbar(toolbarData)); + } else { + this.toolbar_anchors.add(anchor); + } + }); + + return Promise.all(promises).then(() => { + if (this.editor) { + this.editor.observerActive(); + } + }); + }, + /** + * Scan this.field for every Behavior anchor. delete currently registered + * obsolete behaviors. + * + * @param {Array[Object]} behaviorsData array to be filled with data + * currently present in this.field + */ + _scanFieldForBehaviors: function (behaviorsData) { + const anchors = new Set(); + const types = new Set(Object.getOwnPropertyNames(this.behavior_types)); + this.field.querySelectorAll('.o_knowledge_behavior_anchor').forEach(function (types, anchor) { + const type = Array.from(anchor.classList).find(className => types.has(className)); + if (type) { + behaviorsData.push({ + anchor: anchor, + type: type, + }); + anchors.add(anchor); + } + }.bind(this, types)); + // difference between the stored set and the computed one + const differenceAnchors = new Set([...this.behavior_anchors].filter(anchor => !anchors.has(anchor))); + // remove obsolete behaviors + differenceAnchors.forEach(anchor => { + if (anchor.oKnowledgeBehavior) { + anchor.oKnowledgeBehavior.removeBehavior(); + } + }); + }, + /** + * If behaviorsData is set, update only those behaviors, if not, recompute + * every behavior of this.field + * + * @param {Array[Object]} behaviorsData + * @param {Element} [anchor] element to which to inject the behavior + * @param {string} [type] html class representing the behavior type + * (i.e.: @see o_knowledge_behavior_type_... ) + */ + updateBehaviors: function (behaviorsData = []) { + if (!behaviorsData.lenth) { + // no behaviorsData, recreate the array from the field value. + this._scanFieldForBehaviors(behaviorsData); + } + + // inject new behaviors + behaviorsData.forEach(behaviorData => { + const anchor = behaviorData.anchor; + const {Behavior} = this.behavior_types[behaviorData.type] || {}; + if (!Behavior) { + return; + } + if (!anchor.oKnowledgeBehavior) { + anchor.oKnowledgeBehavior = new Behavior(this, anchor, this.mode); + } + this.behavior_anchors.add(anchor); + }); + }, + /** + * @param {Object} + * @param {Element} [container] element which has a toolbar anchor + * @param {Element} [anchor] element in which to inject the toolbar + * @param {string} [type] html class representing the toolbar type + * (i.e.: @see o_knowledge_toolbar_type_... ) + * @returns {Promise} promise to append this toolbar + */ + _createToolbar: function ({container, anchor, type}) { + const {Toolbar, template} = this.toolbar_types[type] || {}; + if (!Toolbar) { + return; + } + const toolbar = new Toolbar(this, container, anchor, template, this.editor, this.component.componentRef.comp.uiService); + anchor.oKnowledgeToolbar = toolbar; + this.toolbar_anchors.add(anchor); + const firstElementChild = anchor.firstElementChild; + if (firstElementChild) { + return toolbar.replace(firstElementChild); + } + return toolbar.appendTo(anchor); + }, + /** + * Clean @see behavior_anchors set when a behavior is removed from the dom + * + * @param {Event} event + */ + _onBehaviorRemoved: function (event) { + event.stopPropagation(); + this.behavior_anchors.delete(event.data.anchor); + }, + /** + * Clean @see toolbar_anchors set when a toolbar is removed from the dom + * + * @param {Event} event + */ + _onToolbarRemoved: function (event) { + event.stopPropagation(); + this.toolbar_anchors.delete(event.data.anchor); + }, + /** + * @param {Event} e + * @param {Object} data + * @param {Array} [toolbarsDatas] + */ + _onRefreshToolbars: function (e, data = {}) { + if (this.field) { + this.updateToolbars("toolbarsData" in data ? data.toolbarsData : []); + } + }, + /** + * @param {Event} e + * @param {Object} data + * @param {Array} [behaviorsData] + */ + _onRefreshBehaviors: function (e, data = {}) { + if (this.field) { + this.updateBehaviors("behaviorsData" in data ? data.behaviorsData : []); + } + }, + /** + * @param {Event} e + * @param {Object} data + * @param {Array} [behaviorsData] + * @param {Array} [toolbarsDatas] + */ + _onRefreshInjector: function (e, data = {}) { + if (this.field) { + this._onRefreshBehaviors(e, data); + this._onRefreshToolbars(e, data); + } + } +}); + +export { + FieldHtmlInjector, +}; diff --git a/addons/knowledge/static/src/js/knowledge_macros.js b/addons/knowledge/static/src/js/knowledge_macros.js new file mode 100644 index 00000000000..eb0478250c1 --- /dev/null +++ b/addons/knowledge/static/src/js/knowledge_macros.js @@ -0,0 +1,321 @@ +/** @odoo-module */ + +import { MacroEngine } from "@web/core/macro"; +import Dialog from "web.Dialog"; +import core from "web.core"; +const _t = core._t; + +/** + * Wrapper for @see Macro destined to be used by @see KnowledgeToolbar buttons. + * These macros will navigate through breadcrumbs to inject information from + * a Knowledge article into a previously browsed record that met the + * requirements specified in @see FormController (Knowledge) + */ +class KnowledgeMacroPageChangeError extends Error {} +export class KnowledgeMacro { + /** + * @param {Object} breadcrumbs + * @param {string} action name of the action to be undertaken + * @param {Object} data data object to be used during the action + * @param {Object} uiService used to cover the screen during the macro + * @param {integer} interval delay between the macro steps + */ + constructor (breadcrumbs, action, data, uiService, interval = 16) { + this.breadcrumbsIndex = breadcrumbs.length - 1; + this.breadcrumbsTitle = breadcrumbs[this.breadcrumbsIndex].title; + this.breadcrumbsSelector = `[role="navigation"] > .breadcrumb-item:contains(${this.breadcrumbsTitle})`; + this.interval = interval; + this.data = data; + this.engine = new MacroEngine(); + this.action = action; + this.uiService = uiService; + this.blockUI = { action: function () { + if (!this.uiService.isBlocked) { + this.uiService.block(); + } + }.bind(this) }; + this.unblockUI = { action: function () { + if (this.uiService.isBlocked) { + this.uiService.unblock(); + } + }.bind(this) }; + } + /** + * Execute the specified macro + */ + start() { + // Build the desired macro action + const macroAction = this._macroActions(this.action); + if (!macroAction) { + return; + } + /** + * Preliminary breadcrumbs macro. It will use the @see breadcrumbsIndex + * to switch back to the view related to the stored record + * (@see KnowledgeService) . Once and if the view of the target record + * is correctly loaded, run the specific macroAction. + */ + const startMacro = { + name: "restore_record", + interval: this.interval, + onError: this.onError.bind(this), + steps: [ + this.blockUI, { + trigger: function () { + const $breadcrumbs = $(`.breadcrumb-item:not(.active)`); + if ($breadcrumbs.length > this.breadcrumbsIndex) { + const breadcrumb = $breadcrumbs[this.breadcrumbsIndex]; + if (breadcrumb.textContent.includes(this.breadcrumbsTitle)) { + return this.getElement(breadcrumb.querySelector('a')); + } + } + return null; + }.bind(this), + action: 'click', + }, { + trigger: this.getElement.bind(this, `${this.breadcrumbsSelector}.active`), + action: this.engine.activate.bind(this.engine, macroAction), + }], + }; + this.engine.activate(startMacro); + } + /** + * @see Macro + * + * @param {Error} error + * @param {Object} step + * @param {integer} index + */ + onError(error, step, index) { + this.unblockUI.action(); + if (error instanceof KnowledgeMacroPageChangeError) { + Dialog.alert(this, + _t('The operation was interrupted because the page or the record changed. Please try again later.'), { + title: _t('Error'), + }); + } else { + console.error(error); + } + } + /** + * Return the first (or the last) matching element from selector only if it + * is currently visible for the user in the document. + * + * @param {string} selector + * @param {boolean} reverse whether to search from the end to the start + * @returns {Element} + */ + getElement(selector, reverse=false) { + const $sel = $(selector); + for (let i = 0; i < $sel.length; i++) { + i = reverse ? $sel.length - 1 - i : i; + if ($sel.eq(i).is(':visible:hasVisibility')) { + return $sel[i]; + } + } + return null; + } + /** + * Validate the page during the macro action. The page is valid if the + * targeted breadcrumb is currently active. Abort a macro in case of failure + * @see Macro + */ + validatePage() { + if (!this.getElement(`${this.breadcrumbsSelector}.active`)) { + throw new KnowledgeMacroPageChangeError(); + } + } + /** + * Build the desired action description from this.properties. Every macro + * will validate the page at every step and abort upon failure to do so. + * + * @param {string} action name of a handled macro action + * @returns {Object} valid descrption @see Macro + */ + _macroActions(action) { + switch (action) { + /** + * @see TemplateToolbar + * Copy the contents of a /template block and paste into the + * targeted field stored in @see data . This macro is able to switch + * form notebook panes if the field is available in one of them. + */ + case "use_as_description": return { + name: action, + interval: this.interval, + onError: this.onError.bind(this), + steps: [{ + trigger: function () { + this.validatePage(); + const selector = `.oe_form_field_html[name="${this.data.fieldName}"]`; + const el = this.getElement(selector); + if (el) { + return el; + } + // Handle the case where the field is hidden in a tab of the form view notebook + const $sel = $(selector); + for (let i = 0; i < $sel.length; i++) { + const pane = $sel[i].closest('.tab-pane:not(.active)'); + if (pane) { + const paneSwitch = this.getElement(`[data-toggle="tab"][href*="${pane.id}"]`); + if (paneSwitch) { + paneSwitch.click(); + break; + } + } + } + return null; + }.bind(this), + action: 'click', + }, { + trigger: function () { + this.validatePage(); + return this.getElement(`.oe_form_field_html[name="${this.data.fieldName}"] > .odoo-editor-editable`); + }.bind(this), + action: this._pasteTemplate.bind(this), + }, this.unblockUI], + }; + /** + * @see TemplateToolbar + * Copy the contents of a /template block and paste into a new + * chatter message in the targeted record context. Open the full + * composer to access the editor. + */ + case "send_as_message": return { + name: action, + interval: this.interval, + onError: this.onError.bind(this), + steps: [{ + trigger: function() { + this.validatePage(); + return this.getElement('.o_ChatterTopbar_buttonSendMessage'); + }.bind(this), + action: (el) => { + if (!el.classList.contains('o-active')) { + el.click(); + } + }, + }, { + trigger: function() { + this.validatePage(); + return this.getElement('.o_Composer_buttonFullComposer'); + }.bind(this), + action: 'click', + }, { + trigger: function () { + this.validatePage(); + const dialog = this.getElement('.o_dialog_container.modal-open'); + if (dialog) { + return this.getElement(dialog.querySelector('.oe_form_field_html[name="body"] > .odoo-editor-editable')); + } else { + return null; + } + }.bind(this), + action: this._pasteTemplate.bind(this), + }, this.unblockUI], + }; + /** + * @see FileToolbar + * Attach a file to a new chatter message in the targeted record + * context. + */ + case "attach_to_message": return { + name: action, + interval: this.interval, + onError: this.onError.bind(this), + steps: [{ + trigger: function() { + this.validatePage(); + return this.getElement('.o_ChatterTopbar_buttonSendMessage'); + }.bind(this), + action: (el) => { + el.scrollIntoView(); + if (!el.classList.contains('o-active')) { + el.click(); + } + }, + }, { + trigger: function() { + this.validatePage(); + return this.getElement('.o_Composer_buttonAttachment'); + }.bind(this), + action: this._dragAndDrop.bind(this, 'dragenter'), + }, { + trigger: function () { + this.validatePage(); + return this.getElement('.o_Composer_dropZone'); + }.bind(this), + action: this._dragAndDrop.bind(this, 'drop'), + }, this.unblockUI], + }; + /** + * @see FileToolbar + * Attach a file to the targeted record. + */ + case "use_as_attachment": return { + name: action, + interval: this.interval, + onError: this.onError.bind(this), + steps: [{ + trigger: function() { + this.validatePage(); + return this.getElement('.o_ChatterTopbar_buttonAttachments'); + }.bind(this), + action: function(el) { + if (!this.getElement('.o_AttachmentBox_content')) { + el.click(); + } + }.bind(this), + }, { + trigger: function() { + this.validatePage(); + return this.getElement('.o_AttachmentBox_content'); + }.bind(this), + action: (el) => el.scrollIntoView(), + }, this.unblockUI], + }; + } + } + /** + * Handle drag&drop events with the dataTransfer object stored in @see data + * + * @param {string} type event type (related to drag&drop) + * @param {Element} el target element + */ + _dragAndDrop(type, el) { + const fakeDragAndDrop = new Event(type, { + bubbles: true, + cancelable: true, + composed: true, + }); + fakeDragAndDrop.dataTransfer = this.data.dataTransfer; + el.dispatchEvent(fakeDragAndDrop); + } + /** + * Handle paste event with the dataTransfer object stored in @see data + * + * @param {Element} el target element + */ + _pasteTemplate(el) { + const fakePaste = new Event('paste', { + bubbles: true, + cancelable: true, + composed: true, + }); + fakePaste.clipboardData = this.data.dataTransfer; + + const sel = document.getSelection(); + sel.removeAllRanges(); + const range = document.createRange(); + const firstChild = el.firstChild; + if (!firstChild) { + range.setStart(el, 0); + range.setEnd(el, 0); + } else { + range.setStartBefore(firstChild); + range.setEndBefore(firstChild); + } + sel.addRange(range); + el.dispatchEvent(fakePaste); + } +} diff --git a/addons/knowledge/static/src/js/knowledge_plugin.js b/addons/knowledge/static/src/js/knowledge_plugin.js new file mode 100644 index 00000000000..c3b9e5c0d27 --- /dev/null +++ b/addons/knowledge/static/src/js/knowledge_plugin.js @@ -0,0 +1,26 @@ +/** @odoo-module */ + +/** + * Plugin for OdooEditor. Allow to remove temporary toolbars content which are + * not destined to be stored in the field_html + */ +export class KnowledgePlugin { + /** + * @param {Element} editable + */ + cleanForSave(editable) { + for (const node of editable.querySelectorAll('.o_knowledge_toolbar_anchor')) { + if (node.oKnowledgeToolbar) { + node.oKnowledgeToolbar.removeToolbar(); + } + while (node.firstChild) { + node.removeChild(node.lastChild); + } + } + for (const node of editable.querySelectorAll('.o_knowledge_behavior_anchor')) { + if (node.oKnowledgeBehavior) { + node.oKnowledgeBehavior.removeBehavior(); + } + } + } +} diff --git a/addons/knowledge/static/src/js/knowledge_service.js b/addons/knowledge/static/src/js/knowledge_service.js new file mode 100644 index 00000000000..4cbe2a8008a --- /dev/null +++ b/addons/knowledge/static/src/js/knowledge_service.js @@ -0,0 +1,151 @@ +/** @odoo-module */ +import AbstractService from 'web.AbstractService'; +import core from 'web.core'; + +/** + * This service store data from non-knowledge form view records that can be used + * by a Knowledge form view. + * + * A typical usage could be the following: + * - A form view is loaded and one field of the current record is a match for + * Knowledge @see FormController @see FormRenderer + * - Information about this record and how to access its form view is stored + * in this @see KnowledgeService . + * - A knowledge Article is opened and it contains a @see TemplateToolbar . + * - When the toolbar is injected (@see FieldHtmlInjector ) in the view, it + * asks this @see KnowledgeService if the record can be interacted with. + * - if there is one such record, the related buttons are displayed in the + * toolbar. + * - When one such button is used, the form view of the record is reloaded + * @see KnowledgeMacro and the button action is executed through a @see Macro + * - an exemple of macro action would be copying the template contents as the + * value of a field_html of the record, such as "description" + * + * Scope of the service: + * It is meant to be called on 3 occasions: + * 1) by @see FormController : + * It will only be called if the viewed record can be used within the + * Knowledge module. Such a record should have a chatter in its form + * view, or have at least one field in a whitelist specified in the + * controller. + * 2) by @see FormRenderer : + * It will only be called if the viewed record has a field match in the + * whitelist of the controller. The renderer has to verify that the + * field's container is visible to the user (so that the field can be + * interacted with). + * 3) by @see KnowledgeToolbar : + * It will be called by a toolbar to check whether it has a record that + * can be interacted with in the context of the toolbar (withChatter or + * withHtmlField). + */ +const KnowledgeService = AbstractService.extend({ + /** + * @override + */ + start() { + this._super.apply(...arguments); + this._records = new Set(); + this._lastVisitedRecordWithChatter = null; + this._lastVisitedRecordWithHtmlField = null; + /** + * Some records are added by the controller but need to be validated by + * the renderer. They are stored in this stack until the rendering is + * done. + */ + this._toValidateStackWithHtmlField = []; + }, + /** + * Called by the controller to indicate that a record need to be validated + * by the renderer @see FormController + * + * @param {Object} record + */ + pushToValidateWithHtmlField(record) { + if (record && record.fieldNames.length) { + this._toValidateStackWithHtmlField.push(record); + } + }, + /** + * Called by the renderer to validate a record @see FormRenderer + * + * @returns {Object} + */ + popToValidateWithHtmlField() { + return this._toValidateStackWithHtmlField.pop(); + }, + /** + * Called when a record may be used by a Knowledge form view. + * + * @param {Object} record + */ + registerRecord(record) { + if (!record) { + return; + } + this._records.add(record); + if (record.withChatter) { + /** + * if the record is flagged "withChatter", overwrite the previously + * registered record + */ + this._lastVisitedRecordWithChatter = record; + } + if (record.withHtmlField) { + /** + * if the record is flagged "withHtmlField", reset the stack and + * the previously registered record + */ + this._toValidateStackWithHtmlField = []; + this._lastVisitedRecordWithHtmlField = record; + } + }, + /** + * Remove a record to signify that it can not be used by a Knowledge form + * view anymore. + * + * @param {Object} record + */ + unregisterRecord(record) { + this._records.delete(record); + }, + /** + * Recover a record that is able to interact with the chatter + * + * @returns {Object} + */ + getAvailableRecordWithChatter() { + if (this._lastVisitedRecordWithChatter && !this._lastVisitedRecordWithChatter.withChatter) { + this._records.delete(this._lastVisitedRecordWithChatter); + } + if (!this._records.has(this._lastVisitedRecordWithChatter)) { + this._lastVisitedRecordWithChatter = null; + } + return this._lastVisitedRecordWithChatter; + }, + /** + * Recover a record that has an available field_html + * + * @returns {Object} + */ + getAvailableRecordWithHtmlField() { + if (this._lastVisitedRecordWithHtmlField && !this._lastVisitedRecordWithHtmlField.withHtmlField) { + this._records.delete(this._lastVisitedRecordWithHtmlField); + } + if (!this._records.has(this._lastVisitedRecordWithHtmlField)) { + this._lastVisitedRecordWithHtmlField = null; + } + return this._lastVisitedRecordWithHtmlField; + }, + /** + * Recover a copy of the set of the currently registered records + * + * @returns {Set} + */ + getRecords() { + return new Set(this._records); + }, +}); + +core.serviceRegistry.add('knowledgeService', KnowledgeService); + +export default KnowledgeService; diff --git a/addons/knowledge/static/src/js/knowledge_toolbars.js b/addons/knowledge/static/src/js/knowledge_toolbars.js new file mode 100644 index 00000000000..09c8824aefa --- /dev/null +++ b/addons/knowledge/static/src/js/knowledge_toolbars.js @@ -0,0 +1,339 @@ +/** @odoo-module */ + +import core from 'web.core'; +import Widget from 'web.Widget'; +import Dialog from "web.Dialog"; +import utils from 'web.utils'; +import { KnowledgeMacro } from './knowledge_macros'; +import { sprintf } from '@web/core/utils/strings'; +const _t = core._t; + +/** + * Toolbar to be injected through @see FieldHtmlInjector to @see OdooEditor + * blocks which have specific classes calling for such toolbars. + * + * A typical usage could be the following: + * - An @see OdooEditor block like /template has the generic class: + * @see o_knowledge_toolbars_container to signify that some of its children need + * to have toolbars injected. + * - At least one of the children has the generic class: + * @see o_knowledge_toolbar_anchor that is a container in which the toolbar + * will be appended + * - The same child also as the specific class: + * @see o_knowledge_toolbar_type_[toolbarType] which specifies the type of the + * toolbar that needs to be injected. @see FieldHtmlInjector has a dictionary + * mapping those classes to the correct toolbar class. + * + * The @see KnowledgeToolbar is a basic toolbar intended to be overriden for + * more complex implementations + * + * This widget should be converted to a component. + */ +const KnowledgeToolbar = Widget.extend({ + /** + * @override + * @param {Widget} parent + * @param {Element} container root node of the @see OdooEditor block, i.e.: + * /template or /file root container + * @param {Element} anchor sub-child of @see container container of the + * toolbar + * @param {string} template + * @param {Object} editor @see OdooEditor + * @param {Object} uiService when this widget is converted to a component, + * uiService won't be needed as a parameter + */ + init: function (parent, container, anchor, template, editor, uiService) { + this._super.apply(this, [parent]); + this.container = container; + this.anchor = anchor; + this.template = template; + this.mode = parent.mode; + this.field = parent.field; + this.editor = editor; + this.uiService = uiService; + }, + /** + * Used by @see KnowledgePlugin to remove toolbars when the field_html is + * saved. Also used by @see FieldHtmlInjector to manage injected toolbars + */ + removeToolbar: function () { + this.trigger_up('toolbar_removed', { + anchor: this.anchor, + }); + delete this.anchor.oKnowledgeToolbar; + this.destroy(); + }, + /** + * @override + */ + start: function () { + const prom = this._super.apply(this, arguments); + return prom.then(function () { + this._setupButtons(); + }.bind(this)); + }, + /** + * Setup the toolbar buttons + */ + _setupButtons: function () { + const buttons = this.el.querySelectorAll('button'); + buttons.forEach(this._setupButton.bind(this)); + }, + // FUNCTIONS TO OVERRIDE \\ + /** + * Called for each button of the toolbar. Each button should have a + * data-call attribute which is used as a unique key for differentiation. + * A common implementation would be a switch case on "button.dataset.call". + * Intercept dblclick events to avoid @see OdooEditor interference + * + * @param {Element} button + */ + _setupButton: function (button) { + button.addEventListener("dblclick", function (ev) { + ev.stopPropagation(); + ev.preventDefault(); + }); + return; + }, +}); + +/** + * Toolbar for the /file command + */ +const FileToolbar = KnowledgeToolbar.extend({ + /** + * Recover the eventual related record from @see KnowledgeService + * + * @override + */ + init: function () { + this._super.apply(this, arguments); + this.recordWithChatter = this.call('knowledgeService', 'getAvailableRecordWithChatter'); + }, + /** + * Get data from the file related to this toolbar + * + * @param {Element} fileLink element to extract file data from + */ + _fetchData: async function (fileLink) { + if (!fileLink || !fileLink.href) { + return null; + } + return await fetch(fileLink.href).then(async function (response) { + if (response.ok) { + return await response.blob(); + } else { + return null; + } + }); + }, + /** + * @override + */ + _setupButton: function (button) { + this._super.apply(this, arguments); + switch (button.dataset.call) { + /** + * Create a @see KnowledgeMacro to add the file to a new message + * in the context of the related record. + */ + case 'attach_to_message': + button.addEventListener("click", async function(ev) { + ev.stopPropagation(); + ev.preventDefault(); + const record = this.recordWithChatter; + if (record) { + const fileLink = this.container.querySelector('.o_knowledge_file_image > a'); + const data = await this._fetchData(fileLink); + if (!data) { + return; + } + const file = new File([data], fileLink.getAttribute('title'), { type: data.type }); + /** + * dataTransfer will be used to mimic a drag and drop of + * the file in the target record chatter. + * @see KnowledgeMacro + */ + const dataTransfer = new DataTransfer(); + dataTransfer.items.add(file); + let macro = new KnowledgeMacro(record.breadcrumbs, button.dataset.call, { + dataTransfer: dataTransfer, + }, this.uiService); + macro.start(); + } + }.bind(this)); + break; + /** + * Create a @see KnowledgeMacro to add the file as an attachment of + * the related record. + */ + case 'use_as_attachment': + button.addEventListener("click", async function(ev) { + ev.stopPropagation(); + ev.preventDefault(); + const record = this.recordWithChatter; + if (record) { + const fileLink = this.container.querySelector('.o_knowledge_file_image > a'); + const data = await this._fetchData(fileLink); + if (!data) { + return; + } + const dataURL = await utils.getDataURLFromFile(data); + const attachment = await this._rpc({ + route: '/web_editor/attachment/add_data', + params: { + 'name': fileLink.getAttribute('title'), + 'data': dataURL.split(',')[1], + 'is_image': false, + 'res_id': this.recordWithChatter.res_id, + 'res_model': this.recordWithChatter.res_model, + } + }); + if (!attachment) { + return; + } + let macro = new KnowledgeMacro(record.breadcrumbs, button.dataset.call, {}, this.uiService); + macro.start(); + } + }.bind(this)); + break; + /** + * Add the file download behavior to the button and the image link + */ + case 'download': + const download = async function (ev) { + ev.stopPropagation(); + ev.preventDefault(); + // Roundabout way to click on the link, to avoid OdooEditor interference with the event + const downloadLink = document.createElement('a'); + const originalLink = this.container.querySelector('.o_knowledge_file_image > a'); + const title = originalLink.getAttribute('title') ? `"${originalLink.getAttribute('title')}" `: ""; + const href = originalLink.getAttribute('href'); + const response = await fetch(href).then((response) => response); + if (response.ok) { + downloadLink.setAttribute('href', href); + downloadLink.setAttribute('download', ''); + downloadLink.setAttribute('target', '_blank'); + downloadLink.click(); + } else { + Dialog.alert(this, + sprintf(_t('Oops, the file %s could not be found. Please replace this file box by a new one to re-upload the file.'), title), { + title: _t('Missing File'), + buttons: [{ + text: _t('Close'), + close: true, + }] + }); + } + }.bind(this); + button.addEventListener("click", download); + const imageElement = this.container.querySelector('.o_knowledge_file_image'); + if (!imageElement.oKnowledgeClickListener) { + imageElement.addEventListener("click", download); + imageElement.oKnowledgeClickListener = true; + } + break; + } + }, +}); + +/** + * Toolbar for the /template command + */ +const TemplateToolbar = KnowledgeToolbar.extend({ + /** + * Recover the eventual related records from @see KnowledgeService + * + * @override + */ + init: function () { + this._super.apply(this, arguments); + this.recordWithChatter = this.call('knowledgeService', 'getAvailableRecordWithChatter'); + this.recordWithHtmlField = this.call('knowledgeService', 'getAvailableRecordWithHtmlField'); + }, + /** + * Create a dataTransfer object with the editable content of the template + * block, to be used for a paste event in the editor + */ + _createHtmlDataTransfer: function () { + const dataTransfer = new DataTransfer(); + const content = this.container.querySelector('.o_knowledge_content'); + dataTransfer.setData('text/html', content.outerHTML); + return dataTransfer; + }, + /** + * @override + */ + _setupButton: function (button) { + this._super.apply(this, arguments); + switch (button.dataset.call) { + /** + * Create a @see KnowledgeMacro to copy the content of the /template + * block and paste it as the content of a new message in the context + * of the related record, in a fullComposer form dialog + */ + case 'send_as_message': + button.addEventListener("click", function (ev) { + ev.stopPropagation(); + ev.preventDefault(); + const record = this.recordWithChatter; + if (record) { + const dataTransfer = this._createHtmlDataTransfer(); + let macro = new KnowledgeMacro(record.breadcrumbs, button.dataset.call, { + dataTransfer: dataTransfer, + }, this.uiService); + macro.start(); + } + }.bind(this)); + break; + /** + * Create a @see KnowledgeMacro to copy the content of the /template + * block and paste it as the content (prepend) of the field_html + * value of the related record + */ + case 'use_as_description': + button.addEventListener("click", function(ev) { + ev.stopPropagation(); + ev.preventDefault(); + const record = this.recordWithHtmlField; + if (record) { + const dataTransfer = this._createHtmlDataTransfer(); + let macro = new KnowledgeMacro(record.breadcrumbs, button.dataset.call, { + fieldName: record.fieldNames[0].name, + dataTransfer: dataTransfer, + }, this.uiService); + macro.start(); + } + }.bind(this)); + break; + /** + * Copy the content of the /template block to the clipboard, and + * prevent @see OdooEditor interference + */ + case 'copy_to_clipboard': + button.addEventListener("click", function (ev) { + ev.stopPropagation(); + ev.preventDefault(); + }); + const content = this.container.querySelector('.o_knowledge_content'); + const clipboard = new ClipboardJS( + button, + {target: () => content} + ); + clipboard.on('success', (e) => { + e.clearSelection(); + this.displayNotification({ + type: 'success', + message: _t("Template copied to clipboard."), + }); + }); + break; + } + }, +}); + +export { + TemplateToolbar, + FileToolbar, + KnowledgeToolbar, +}; diff --git a/addons/knowledge/static/src/js/knowledge_toolbars_edit.js b/addons/knowledge/static/src/js/knowledge_toolbars_edit.js new file mode 100644 index 00000000000..7eedf3648db --- /dev/null +++ b/addons/knowledge/static/src/js/knowledge_toolbars_edit.js @@ -0,0 +1,36 @@ +/** @odoo-module */ + +import { KnowledgeToolbar } from './knowledge_toolbars'; + +/** + * @see KnowledgeToolbar + * This override is loaded as an asset of web_editor to access editor methods + * and utils. + */ +KnowledgeToolbar.include({ + /** + * set the toolbar anchor as non-editable + * + * @override + */ + init: function () { + this._super.apply(this, arguments); + if (this.mode === 'edit') { + this.editor.observerUnactive('knowledge_toolbar_uneditable'); + this.anchor.setAttribute('contenteditable', 'false'); + this.editor.observerActive('knowledge_toolbar_uneditable'); + } + }, + /** + * @override + */ + removeToolbar: function () { + if (this.mode === 'edit') { + this.editor.observerUnactive(); + } + this._super.apply(this, arguments); + if (this.mode === 'edit') { + this.editor.observerActive(); + } + }, +}); diff --git a/addons/knowledge/static/src/js/wysiwyg.js b/addons/knowledge/static/src/js/wysiwyg.js new file mode 100644 index 00000000000..bbe2153c2f7 --- /dev/null +++ b/addons/knowledge/static/src/js/wysiwyg.js @@ -0,0 +1,212 @@ +odoo.define('knowledge.wysiwyg', function (require) { +'use strict'; + +const core = require('web.core'); +const QWeb = core.qweb; + +const { DocumentWidget } = require('wysiwyg.widgets.media'); +const MediaDialog = require('wysiwyg.widgets.MediaDialog'); +const Wysiwyg = require('web_editor.wysiwyg'); +const { setCursorStart } = require('@web_editor/../lib/odoo-editor/src/OdooEditor'); + +/** + * Override the @see DocumentWidget to manage files in a @see MediaDialog used + * by the /file command. The purpose of this override is to redefine the + * rendering of the media (/file block), and to merge images in the documents + * tab of the MediaDialog, since the /file block displays a default mimetype for + * every files. + */ +const KnowledgeDocumentWidget = DocumentWidget.extend({ + /** + * Filter files for the documents tab of the MediaDialog. Any file with a + * mimetype is valid. (images and documents are displayed together) + * + * @override + */ + init: function (parent, media, options) { + options = _.extend({ + accept: '*/*', + mimetypeDomain: [['mimetype', '!=', false]], + }, options || {}); + this._super(parent, media, options); + }, + /** + * Custom rendering for the /file command + * + * @override + * @param {Object} img + * @returns {Element} + */ + _renderMedia: function (img) { + if (img.image_src) { + delete img.image_src; + } + const file = this._super(...arguments); + const extension = (img.name && img.name.split('.').pop()) || img.mimetype; + this.$media = $(QWeb.render('knowledge.file_block', { + img: { + name: img.name, + extension: extension, + }, + })); + this.media = this.$media[0]; + this.media.querySelector('.o_knowledge_file_image').replaceChildren(file); + return this.media; + } +}); + +/** + * Override the @see MediaDialog to manage documents with the + * @see KnowledgeDocumentWidget for the /file command + */ +const KnowledgeMediaDialog = MediaDialog.extend({ + /** + * @override + * @param {Object} media + * @param {Object} options + * @returns {Widget} + */ + getDocumentWidget: function (media, options) { + return new KnowledgeDocumentWidget(this, media, options); + } +}); + +Wysiwyg.include({ + /** + * @override + */ + init: function (parent, options) { + if (options.knowledge_commands) { + /** + * knowledge_commands is a view option from a field_html that + * indicates that knowledge-specific commands should be loaded. + * powerboxFilters is an array of functions used to filter commands + * displayed in the powerbox. + */ + options.powerboxFilters = options.powerboxFilters ? options.powerboxFilters : []; + options.powerboxFilters.push(this._filterKnowledgeCommandGroupInTemplate); + } + this._super.apply(this, arguments); + }, + /** + * Prevent usage of commands from the group "Knowledge" inside the block + * inserted by the /template Knowledge command. The content of a /template + * block is destined to be used in @see OdooEditor in modules other than + * Knowledge, where knowledge-specific commands may not be available. + * i.e.: prevent usage /template in a /template block + * + * @param {Array} commands commands available in this wysiwyg + * @returns {Array} commands which can be used after the filter was applied + */ + _filterKnowledgeCommandGroupInTemplate: function (commands) { + let anchor = document.getSelection().anchorNode; + if (anchor.nodeType !== Node.ELEMENT_NODE) { + anchor = anchor.parentElement; + } + if (anchor && anchor.closest('.o_knowledge_template')) { + commands = commands.filter(command => command.groupName != 'Knowledge'); + } + return commands; + }, + /** + * @override + * @returns {Array[Object]} + */ + _getCommands: function () { + const commands = this._super(); + if (this.options.knowledge_commands) { + commands.push({ + groupName: 'Knowledge', + title: 'File', + description: 'Embed a file.', + fontawesome: 'fa-file', + callback: () => { + this.openMediaDialog({ + noVideos: true, + noImages: true, + noIcons: true, + noDocuments: false + }, KnowledgeMediaDialog); + } + }, { + groupName: 'Knowledge', + title: "Template", + description: "Add a template section.", + fontawesome: 'fa-pencil-square', + callback: () => { + this._insertTemplate(); + }, + }); + } + return commands; + }, + + /** + * Notify @see FieldHtmlInjector that toolbars need to be injected + * @see KnowledgeToolbar + * + * @param {Element} container + */ + _notifyNewToolbars(container) { + const toolbarsData = []; + container.querySelectorAll('.o_knowledge_toolbar_anchor').forEach(function (container, anchor) { + const type = Array.from(anchor.classList).find(className => className.startsWith('o_knowledge_toolbar_type_')); + if (type) { + toolbarsData.push({ + container: container, + anchor: anchor, + type: type, + }); + } + }.bind(this, container)); + this.$editable.trigger('refresh_toolbars', { toolbarsData: toolbarsData }); + }, + /** + * Notify @see FieldHtmlInjector that behaviors need to be injected + * @see KnowledgeBehavior + * + * @param {Element} anchor + */ + _notifyNewBehaviors(anchor) { + const behaviorsData = []; + const type = Array.from(anchor.classList).find(className => className.startsWith('o_knowledge_behavior_type_')); + if (type) { + behaviorsData.push({ + anchor: anchor, + type: type, + }); + } + this.$editable.trigger('refresh_behaviors', { behaviorsData: behaviorsData}); + }, + /** + * Insert a /template block + */ + _insertTemplate() { + const templateFragment = new DocumentFragment(); + const templateBlock = $(QWeb.render('knowledge.template_block', {}))[0]; + templateFragment.append(templateBlock); + const [container] = this.odooEditor.execCommand('insertFragment', templateFragment); + setCursorStart(container.querySelector('.o_knowledge_content > p')); + this._notifyNewToolbars(container); + this._notifyNewBehaviors(container); + }, + /** + * Notify the @see FieldHtmlInjector when a /file block is inserted + * + * @override + */ + _onMediaDialogSave(params, element) { + const result = this._super(...arguments); + if (!result) { + return; + } + const [container] = result; + if (container.classList.contains('o_knowledge_file')) { + setCursorStart(container.nextElementSibling); + this._notifyNewToolbars(container); + this._notifyNewBehaviors(container); + } + return result; + }, +}); +}); diff --git a/addons/knowledge/static/src/scss/knowledge_views.scss b/addons/knowledge/static/src/scss/knowledge_views.scss index 03ba2c9f257..0b99e8a6a77 100644 --- a/addons/knowledge/static/src/scss/knowledge_views.scss +++ b/addons/knowledge/static/src/scss/knowledge_views.scss @@ -271,3 +271,21 @@ } } } + +// Knowledge Toolbars + +.o_knowledge_toolbar { + display: flex; + justify-content: flex-end; + white-space: nowrap; + button { + font-family: $font-family-base !important; + font-weight: $font-weight-bold; + } +} + +@media screen and (max-width: 1024px) { + .o_knowledge_toolbar_button_text { + display: none; + } +} diff --git a/addons/knowledge/static/src/xml/knowledge_editor.xml b/addons/knowledge/static/src/xml/knowledge_editor.xml index e78dc95d10d..99bd8478568 100644 --- a/addons/knowledge/static/src/xml/knowledge_editor.xml +++ b/addons/knowledge/static/src/xml/knowledge_editor.xml @@ -19,17 +19,4 @@ - - - - - - -
-
- -
-
-
diff --git a/addons/knowledge/static/src/xml/knowledge_toolbars.xml b/addons/knowledge/static/src/xml/knowledge_toolbars.xml new file mode 100644 index 00000000000..76f14958b97 --- /dev/null +++ b/addons/knowledge/static/src/xml/knowledge_toolbars.xml @@ -0,0 +1,33 @@ + + + +
+
+ + + +
+
+
+ +
+
+ + + +
+
+
+
diff --git a/addons/knowledge/static/tests/test_services.js b/addons/knowledge/static/tests/test_services.js new file mode 100644 index 00000000000..50b95dd377c --- /dev/null +++ b/addons/knowledge/static/tests/test_services.js @@ -0,0 +1,15 @@ +/** @odoo-module */ + +import { testEnvServices } from '@web/../tests/legacy/helpers/test_services'; + +Object.assign(testEnvServices, { + knowledgeService: { + pushToValidateWithHtmlField: () => {}, + popToValidateWithHtmlField: () => {}, + registerRecord: () => {}, + unregisterRecord: () => {}, + getAvailableRecordWithChatter: () => {}, + getAvailableRecordWithHtmlField: () => {}, + getRecords: () => new Set(), + } +}); diff --git a/addons/knowledge/views/knowledge_article_views.xml b/addons/knowledge/views/knowledge_article_views.xml index a8dbddd091e..9c4eecdbe3b 100644 --- a/addons/knowledge/views/knowledge_article_views.xml +++ b/addons/knowledge/views/knowledge_article_views.xml @@ -195,7 +195,7 @@
diff --git a/addons/web/static/src/legacy/js/core/utils.js b/addons/web/static/src/legacy/js/core/utils.js index 1de9b50426e..94e9a25a85b 100644 --- a/addons/web/static/src/legacy/js/core/utils.js +++ b/addons/web/static/src/legacy/js/core/utils.js @@ -1140,9 +1140,11 @@ var utils = Object.assign({ ['res_model', '=', 'ir.ui.view'], '|', '|', + '|', ['name', '=like', '%.assets\_%.css'], ['name', '=like', '%.assets\_%.js'], ['name', '=like', '%.report_assets\_%.css'], + ['name', '=like', '%.assets\_%.map'], ]; }, }, cookieUtils); diff --git a/addons/web/static/tests/legacy/helpers/test_env.js b/addons/web/static/tests/legacy/helpers/test_env.js index 664a234c8ff..5bf0d0d5400 100644 --- a/addons/web/static/tests/legacy/helpers/test_env.js +++ b/addons/web/static/tests/legacy/helpers/test_env.js @@ -2,8 +2,8 @@ odoo.define('web.test_env', async function (require) { "use strict"; const Bus = require('web.Bus'); - const { buildQuery } = require('web.rpc'); const session = require('web.session'); + const { makeTestEnvServices } = require('@web/../tests/legacy/helpers/test_services'); const { renderToString } = require('@web/core/utils/render'); const { App, Component } = owl; @@ -61,25 +61,7 @@ odoo.define('web.test_env', async function (require) { SIZES: { XS: 0, VSM: 1, SM: 2, MD: 3, LG: 4, XL: 5, XXL: 6 }, }, env.device), isDebug: env.isDebug || (() => false), - services: Object.assign({ - ajax: { - rpc() { - return env.session.rpc(...arguments); // Compatibility Legacy Widgets - }, - loadLibs() {} - }, - getCookie() {}, - httpRequest(/* route, params = {}, readMethod = 'json' */) { - return Promise.resolve(''); - }, - rpc(params, options) { - const query = buildQuery(params); - return env.session.rpc(query.route, query.params, options); - }, - notification: { notify() { } }, - hotkey: { add: () => () => {} }, // fake service - ui: { activeElement: document }, // fake service - }, env.services), + services: makeTestEnvServices(env), session: Object.assign({ rpc(route, params, options) { if (providedRPC) { diff --git a/addons/web/static/tests/legacy/helpers/test_services.js b/addons/web/static/tests/legacy/helpers/test_services.js new file mode 100644 index 00000000000..e180c0c27bf --- /dev/null +++ b/addons/web/static/tests/legacy/helpers/test_services.js @@ -0,0 +1,39 @@ +/** @odoo-module */ + +import { buildQuery } from 'web.rpc'; + +const testEnvServices = { + getCookie() {}, + httpRequest(/* route, params = {}, readMethod = 'json' */) { + return Promise.resolve(''); + }, + hotkey: { add: () => () => {} }, // fake service + notification: { notify() {} }, +}; + +/** + * Creates services for the test environment. object + * + * @param {Object} [env] + * @returns {Object} + */ +function makeTestEnvServices(env) { + return Object.assign({}, testEnvServices, { + ajax: { + rpc() { + return env.session.rpc(...arguments); // Compatibility Legacy Widgets + }, + loadLibs() {} + }, + rpc(params, options) { + const query = buildQuery(params); + return env.session.rpc(query.route, query.params, options); + }, + ui: { activeElement: document }, // fake service + }, env.services); +} + +export { + makeTestEnvServices, + testEnvServices, +}; 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 da08af32367..1f3907eac62 100644 --- a/addons/web_editor/static/lib/odoo-editor/src/OdooEditor.js +++ b/addons/web_editor/static/lib/odoo-editor/src/OdooEditor.js @@ -1906,6 +1906,7 @@ export class OdooEditor extends EventTarget { editable: this.editable, document: this.document, getContextFromParentRect: this.options.getContextFromParentRect, + commandFilters: this.options.powerboxFilters, _t: this.options._t, onShow: () => { this.commandbarTablePicker.hide(); diff --git a/addons/web_editor/static/lib/odoo-editor/src/commands/commands.js b/addons/web_editor/static/lib/odoo-editor/src/commands/commands.js index b33bb0ab746..d2e92c7fcf9 100644 --- a/addons/web_editor/static/lib/odoo-editor/src/commands/commands.js +++ b/addons/web_editor/static/lib/odoo-editor/src/commands/commands.js @@ -45,7 +45,18 @@ import { const TEXT_CLASSES_REGEX = /\btext-[^\s]*\b/g; const BG_CLASSES_REGEX = /\bbg-[^\s]*\b/g; -function insert(editor, data, isText = true) { +/** + * @param {OdooEditor} editor insertion target + * @param {string|DocumentFragment} data insertion contents + * - MODE - | - DATA TYPE - + * text | string + * html | string + * fragment | DocumentFragment + * @param {Object} options + * @param {string} [options.mode='text'] text | html | fragment + * @returns {Array} inserted nodes + */ +function insert(editor, data, {mode = "text"}) { if (!data) { return; } @@ -72,30 +83,37 @@ function insert(editor, data, isText = true) { } } - const fakeEl = document.createElement('fake-element'); - const fakeElFirstChild = document.createElement('fake-element-fc'); - const fakeElLastChild = document.createElement('fake-element-lc'); - if (isText) { - fakeEl.innerText = data; - } else { - fakeEl.innerHTML = data; + const container = document.createElement('fake-element'); + const containerFirstChild = document.createElement('fake-element-fc'); + const containerLastChild = document.createElement('fake-element-lc'); + + switch (mode) { + case 'text': + container.innerText = data; + break; + case 'html': + container.innerHTML = data; + break; + case 'fragment': + container.append(...data.childNodes); + break; } // In case the html inserted is all contained in a single root

tag, // we take the all content of the

and avoid inserting the

. - if (fakeEl.childElementCount === 1 && fakeEl.firstChild.nodeName === 'P') { - const p = fakeEl.firstElementChild; - fakeEl.replaceChildren(...p.childNodes); - } else if (fakeEl.childElementCount > 1) { + if (container.childElementCount === 1 && container.firstChild.nodeName === 'P') { + const p = container.firstElementChild; + container.replaceChildren(...p.childNodes); + } else if (container.childElementCount > 1) { // Grab the content of the first child block and isolate it. - if (isBlock(fakeEl.firstChild)) { - fakeElFirstChild.replaceChildren(...fakeEl.firstElementChild.childNodes); - fakeEl.firstElementChild.remove(); + if (isBlock(container.firstChild)) { + containerFirstChild.replaceChildren(...container.firstElementChild.childNodes); + container.firstElementChild.remove(); } // Grab the content of the last child block and isolate it. - if (isBlock(fakeEl.lastChild)) { - fakeElLastChild.replaceChildren(...fakeEl.lastElementChild.childNodes); - fakeEl.lastElementChild.remove(); + if (isBlock(container.lastChild)) { + containerLastChild.replaceChildren(...container.lastElementChild.childNodes); + container.lastElementChild.remove(); } } @@ -103,17 +121,17 @@ function insert(editor, data, isText = true) { // first we split the current focus element if it's a block // then we insert the content in the right places let lastChildNode = false; - if (fakeElLastChild.hasChildNodes()) { + if (containerLastChild.hasChildNodes()) { let tempCurrentNode = currentNode; - for (const n of [...fakeElLastChild.childNodes]) { + for (const n of [...containerLastChild.childNodes]) { tempCurrentNode.after(n); tempCurrentNode = n; } lastChildNode = tempCurrentNode; } - if (fakeElFirstChild.hasChildNodes()) { - for (const n of [...fakeElFirstChild.childNodes]) { + if (containerFirstChild.hasChildNodes()) { + for (const n of [...containerFirstChild.childNodes]) { currentNode.after(n); currentNode = n; } @@ -121,7 +139,7 @@ function insert(editor, data, isText = true) { // If all the Html have been isolated, We force a split of the parent element // to have the need new line in the final result - if (!fakeEl.hasChildNodes()) { + if (!container.hasChildNodes()) { if (isUnbreakable(closestBlock(currentNode.nextSibling))) { currentNode.nextSibling.oShiftEnter(0); } else { @@ -133,8 +151,8 @@ function insert(editor, data, isText = true) { } let nodeToInsert; - const insertedNodes = [...fakeEl.childNodes]; - while ((nodeToInsert = fakeEl.childNodes[0])) { + const insertedNodes = [...container.childNodes]; + while ((nodeToInsert = container.childNodes[0])) { if (isBlock(nodeToInsert) && !allowsParagraphRelatedElements(currentNode)) { // Split blocks at the edges if inserting new blocks (preventing //

text

scenarios). @@ -143,8 +161,8 @@ function insert(editor, data, isText = true) { !allowsParagraphRelatedElements(currentNode.parentElement) ) { if (isUnbreakable(currentNode.parentElement)) { - makeContentsInline(fakeEl); - nodeToInsert = fakeEl.childNodes[0]; + makeContentsInline(container); + nodeToInsert = container.childNodes[0]; break; } let offset = childNodeIndex(currentNode); @@ -533,11 +551,14 @@ function getTextNodes(editor) { // absence of a strong test suite, so the whitelist stays for now. export const editorCommands = { // Insertion - insertHTML: (editor, data) => { - return insert(editor, data, false); + insertFragment: (editor, fragment) => { + return insert(editor, fragment, {mode: 'fragment'}); }, - insertText: (editor, data) => { - return insert(editor, data); + insertHTML: (editor, html) => { + return insert(editor, html, {mode: 'html'}); + }, + insertText: (editor, text) => { + return insert(editor, text, {mode: 'text'}); }, insertFontAwesome: (editor, faClass = 'fa fa-star') => { const insertedNode = editorCommands.insertHTML(editor, '')[0]; diff --git a/addons/web_editor/static/lib/odoo-editor/src/powerbox/Powerbox.js b/addons/web_editor/static/lib/odoo-editor/src/powerbox/Powerbox.js index 7c5c0989138..812f7af3ef8 100644 --- a/addons/web_editor/static/lib/odoo-editor/src/powerbox/Powerbox.js +++ b/addons/web_editor/static/lib/odoo-editor/src/powerbox/Powerbox.js @@ -39,6 +39,19 @@ export class Powerbox { } render(commands) { + if (this.options.commandFilters && this.options.commandFilters.length) { + /** + * Some available commands may need to be disabled in certain + * situations. i.e.: + * in a knowledge article, prevent the usage of the /template + * command inside a /template block. + * @see commandFilters is an array of functions which returns + * commands that can be used. + */ + for (let filter of this.options.commandFilters) { + commands = filter(commands); + } + } this._mainWrapperElement.innerHTML = ''; clearTimeout(this._renderingTimeout); this._hoverActive = false; diff --git a/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js b/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js index 10b8b95f5ed..92e09ab2e72 100644 --- a/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js +++ b/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js @@ -137,6 +137,7 @@ const Wysiwyg = Widget.extend({ autohideToolbar: !!this.options.autohideToolbar, isRootEditable: this.options.isRootEditable, placeholder: this.options.placeholder, + powerboxFilters: this.options.powerboxFilters || [], controlHistoryFromDocument: this.options.controlHistoryFromDocument, getContentEditableAreas: this.options.getContentEditableAreas, defaultLinkAttributes: this.options.userGeneratedContent ? {rel: 'ugc' } : {}, @@ -1157,8 +1158,9 @@ const Wysiwyg = Widget.extend({ * @param {object} params * @param {Node} [params.node] Optionnal * @param {Node} [params.htmlClass] Optionnal + * @param {Class} MediaDialog Optionnal */ - openMediaDialog(params = {}) { + openMediaDialog(params = {}, MediaDialog = weWidgets.MediaDialog) { const sel = this.odooEditor.document.getSelection(); const fontawesomeIcon = getInSelection(this.odooEditor.document, '.fa'); if (fontawesomeIcon && sel.toString().trim() === "") { @@ -1190,26 +1192,15 @@ const Wysiwyg = Widget.extend({ domain: $editable.data('oe-media-domain'), useMediaLibrary: field && (model === 'ir.ui.view' && field === 'arch' || type === 'html'), }, this.options.mediaModalParams, params); - const mediaDialog = new weWidgets.MediaDialog(this, mediaParams, $node); + const mediaDialog = new MediaDialog(this, mediaParams, $node); mediaDialog.open(); - mediaDialog.on('save', this, function (element) { - if (!element) { - return; - } - // restore saved html classes - if (params.htmlClass) { - element.className += " " + params.htmlClass; - } - restoreSelection(); - if (wysiwygUtils.isImg($node[0]) || wasFontAwesome) { - $node.replaceWith(element); - this.odooEditor.unbreakableStepUnactive(); - this.odooEditor.historyStep(); - } else if (element) { - this.odooEditor.execCommand('insertHTML', element.outerHTML); - } - }); + mediaDialog.on('save', this, this._onMediaDialogSave.bind(this, { + $node: $node, + wasFontAwesome: wasFontAwesome, + htmlClass: params.htmlClass, + restoreSelection: restoreSelection, + })); mediaDialog.on('closed', this, function () { // if the mediaDialog content has been saved // the previous selection in not relevant anymore @@ -1218,6 +1209,31 @@ const Wysiwyg = Widget.extend({ } }); }, + /** + * Detached function to allow overriding. + * + * @param {Object} params binded @see openMediaDialog + * @param {Element} element provided by the dialog + */ + _onMediaDialogSave: function (params, element) { + if (!element) { + return; + } + // restore saved html classes + if (params.htmlClass) { + element.className += " " + params.htmlClass; + } + params.restoreSelection(); + if (wysiwygUtils.isImg(params.$node[0]) || params.wasFontAwesome) { + params.$node.replaceWith(element); + this.odooEditor.unbreakableStepUnactive(); + this.odooEditor.historyStep(); + } else if (element) { + const fragment = new DocumentFragment(); + fragment.append(element); + return this.odooEditor.execCommand('insertFragment', fragment); + } + }, //-------------------------------------------------------------------------- // Private