From 7c8323e816062f44f940a0bee2b69a11510b09db Mon Sep 17 00:00:00 2001 From: "Guillaume (gdi)" Date: Tue, 9 Nov 2021 15:02:41 +0000 Subject: [PATCH] [FIX] web_editor, website: permit the user to set link on images This restores a (basic) feature that was lost in 15.0 by mistake. Allow the user to set a link on an image. The user can access this link tool in the editor panel or via the CTRL+K shortcut. This tool is very similar to the existing link tool for texts. This tool uses the suggestion system already existing on the text link tool. task-2666467 closes odoo/odoo#82512 X-original-commit: bfcd25c5d66765b7436114dba3316ac7331a4d97 Signed-off-by: Romain Derie (rde) Co-authored-by: qsm-odoo --- addons/web_editor/i18n/web_editor.pot | 15 ++ .../static/src/js/editor/snippets.options.js | 172 ++++++++++++++++++ .../static/src/js/wysiwyg/wysiwyg.js | 9 +- .../static/src/xml/image_link_tools.xml | 24 +++ addons/website/i18n/website.pot | 2 + .../static/src/js/editor/snippets.options.js | 84 +++++++++ 6 files changed, 305 insertions(+), 1 deletion(-) create mode 100644 addons/web_editor/static/src/xml/image_link_tools.xml diff --git a/addons/web_editor/i18n/web_editor.pot b/addons/web_editor/i18n/web_editor.pot index e4eb59e44f8..503ff052bb2 100644 --- a/addons/web_editor/i18n/web_editor.pot +++ b/addons/web_editor/i18n/web_editor.pot @@ -1676,6 +1676,13 @@ msgstr "" msgid "Readonly field" msgstr "" +#. module: web_editor +#. openerp-web +#: code:addons/web_editor/static/src/xml/image_link_tools.xml:0 +#, python-format +msgid "Redirect the user elsewhere when he clicks on the media." +msgstr "" + #. module: web_editor #. openerp-web #: code:addons/web_editor/static/src/xml/editor.xml:0 @@ -2768,6 +2775,7 @@ msgstr "" #. module: web_editor #. openerp-web +#: code:addons/web_editor/static/src/xml/image_link_tools.xml:0 #: code:addons/web_editor/static/src/xml/wysiwyg.xml:0 #, python-format msgid "⌙ Open in new window" @@ -2811,3 +2819,10 @@ msgstr "" #: model_terms:ir.ui.view,arch_db:web_editor.snippet_options_background_options msgid "⌙ Width" msgstr "" + +#. module: web_editor +#. openerp-web +#: code:addons/web_editor/static/src/xml/image_link_tools.xml:0 +#, python-format +msgid "⌙ Your URL" +msgstr "" diff --git a/addons/web_editor/static/src/js/editor/snippets.options.js b/addons/web_editor/static/src/js/editor/snippets.options.js index 4e0579e1f19..2e953c6d636 100644 --- a/addons/web_editor/static/src/js/editor/snippets.options.js +++ b/addons/web_editor/static/src/js/editor/snippets.options.js @@ -338,6 +338,15 @@ const UserValueWidget = Widget.extend({ } return null; }, + /** + * Focus the main focusable element of the widget. + */ + focus() { + const el = this._getFocusableElement(); + if (el) { + el.focus(); + } + }, /** * Returns the value that the widget would hold if it was active, by default * the internal value it holds. @@ -575,13 +584,32 @@ const UserValueWidget = Widget.extend({ * @param {boolean} show */ toggleVisibility: function (show) { + let doFocus = false; + if (show) { + const wasInvisible = this.el.classList.contains('d-none'); + doFocus = wasInvisible && this.el.dataset.requestFocus === "true"; + } this.el.classList.toggle('d-none', !show); + if (doFocus) { + this.focus(); + } }, //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- + /** + * Returns the main focusable element of the widget. By default supposes + * nothing is focusable. + * + * @todo review all specific widget's method + * @private + * @returns {HTMLElement} + */ + _getFocusableElement: function () { + return null; + }, /** * @private * @param {OdooEvent|Event} @@ -1203,6 +1231,17 @@ const InputUserValueWidget = UnitUserValueWidget.extend({ this.inputEl.value = this._value; }, + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _getFocusableElement() { + return this.inputEl; + }, + //-------------------------------------------------------------------------- // Handlers //-------------------------------------------------------------------------- @@ -4686,6 +4725,23 @@ registry.SnippetMove = SnippetOptionWidget.extend({ * Allows for media to be replaced. */ registry.ReplaceMedia = SnippetOptionWidget.extend({ + xmlDependencies: ['/web_editor/static/src/xml/image_link_tools.xml'], + + /** + * @override + */ + async start() { + core.bus.on('activate_image_link_tool', this, this._activateLinkTool); + return this._super(...arguments); + }, + /** + * @override + */ + onFocus() { + // When we start editing an image, rerender the UI to ensure the + // we-select that suggests the anchors is in a consistent state. + this.rerender = true; + }, //-------------------------------------------------------------------------- // Options @@ -4701,6 +4757,122 @@ registry.ReplaceMedia = SnippetOptionWidget.extend({ // to be refactored when the new editor is merged this.$target.dblclick(); }, + /** + * Makes the image a clickable link by wrapping it in an . + * This function is also called for the opposite operation. + * + * @see this.selectClass for parameters + */ + setLink(previewMode, widgetValue, params) { + const parentEl = this.$target[0].parentNode; + if (parentEl.tagName !== 'A') { + const wrapperEl = document.createElement('a'); + this.$target[0].after(wrapperEl); + wrapperEl.appendChild(this.$target[0]); + } else { + parentEl.replaceWith(this.$target[0]); + } + }, + /** + * Changes the image link so that the URL is opened on another tab or not + * when it is clicked. + * + * @see this.selectClass for parameters + */ + setNewWindow(previewMode, widgetValue, params) { + const linkEl = this.$target[0].parentElement; + if (widgetValue) { + linkEl.setAttribute('target', '_blank'); + } else { + linkEl.removeAttribute('target'); + } + }, + /** + * Records the target url of the hyperlink. + * + * @see this.selectClass for parameters + */ + setUrl(previewMode, widgetValue, params) { + const linkEl = this.$target[0].parentElement; + let url = widgetValue; + if (!url) { + // As long as there is no URL, the image is not considered a link. + linkEl.removeAttribute('href'); + return; + } + if (!url.startsWith('/') && !url.startsWith('#') + && !/^([a-zA-Z]*.):.+$/gm.test(url)) { + // We permit every protocol (http:, https:, ftp:, mailto:,...). + // If none is explicitly specified, we assume it is a http. + url = 'http://' + url; + } + linkEl.setAttribute('href', url); + this.rerender = true; + }, + /** + * @override + */ + async updateUI() { + if (this.rerender) { + this.rerender = false; + await this._rerenderXML(); + return; + } + return this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + */ + _activateLinkTool() { + if (this.$target[0].parentElement.tagName === 'A') { + this._requestUserValueWidgets('media_url_opt')[0].focus(); + } else { + this._requestUserValueWidgets('media_link_opt')[0].enable(); + } + }, + /** + * @override + */ + _computeWidgetState(methodName, params) { + const parentEl = this.$target[0].parentElement; + const linkEl = parentEl.tagName === 'A' ? parentEl : null; + switch (methodName) { + case 'setLink': { + return linkEl ? 'true' : ''; + } + case 'setUrl': { + let href = linkEl ? linkEl.getAttribute('href') : ''; + return href || ''; + } + case 'setNewWindow': { + const target = linkEl ? linkEl.getAttribute('target') : ''; + return target && target === '_blank' ? 'true' : ''; + } + } + return this._super(...arguments); + }, + /** + * @override + */ + async _computeWidgetVisibility(widgetName, params) { + if (widgetName === 'media_link_opt') { + return !this.$target[0].classList.contains('media_iframe_video'); + } + return this._super(...arguments); + }, + /** + * @override + */ + async _renderCustomXML(uiFragment) { + const rowEl = uiFragment.querySelector('we-row'); + rowEl.insertAdjacentHTML('beforeend', qweb.render('web_editor.media_link_tools_button')); + rowEl.insertAdjacentHTML('afterend', qweb.render('web.editor.media_link_tools_fields')); + }, }); /* diff --git a/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js b/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js index de6d1d85dcb..6f4f5522788 100644 --- a/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js +++ b/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js @@ -1304,9 +1304,16 @@ const Wysiwyg = Widget.extend({ * Handle custom keyboard shortcuts. */ _handleShortcuts: function (e) { - // Open the link modal / tool when CTRL+K is pressed. + // Open the link tool when CTRL+K is pressed. if (e && e.key === 'k' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); + const targetEl = this.odooEditor.document.getSelection().baseNode; // FIXME this is undefined on Firefox after clicking on an image and hitting CTRL-K + // Link tool is different if the selection is an image or a text. + if (targetEl instanceof HTMLElement + && (targetEl.tagName === 'IMG' || targetEl.querySelectorAll('img').length === 1)) { + core.bus.trigger('activate_image_link_tool'); + return; + } this.toggleLinkTools(); } // Override selectAll (CTRL+A) to restrict it to the editable zone / current snippet and prevent traceback. diff --git a/addons/web_editor/static/src/xml/image_link_tools.xml b/addons/web_editor/static/src/xml/image_link_tools.xml new file mode 100644 index 00000000000..32bb0c47609 --- /dev/null +++ b/addons/web_editor/static/src/xml/image_link_tools.xml @@ -0,0 +1,24 @@ + + + + + + + + + ⌙ Your URL + + ⌙ Open in new window + + + + diff --git a/addons/website/i18n/website.pot b/addons/website/i18n/website.pot index 1adf0b79ddc..564b76ee318 100644 --- a/addons/website/i18n/website.pot +++ b/addons/website/i18n/website.pot @@ -5006,6 +5006,7 @@ msgstr "" #. module: website #. openerp-web +#: code:addons/website/static/src/js/editor/snippets.options.js:0 #: code:addons/website/static/src/xml/website.editor.xml:0 #, python-format msgid "" @@ -11402,6 +11403,7 @@ msgstr "" #. module: website #. openerp-web +#: code:addons/website/static/src/js/editor/snippets.options.js:0 #: code:addons/website/static/src/xml/website.editor.xml:0 #, python-format msgid "⌙ Page Anchor" diff --git a/addons/website/static/src/js/editor/snippets.options.js b/addons/website/static/src/js/editor/snippets.options.js index 7d9104b4c5c..a6093e959a9 100644 --- a/addons/website/static/src/js/editor/snippets.options.js +++ b/addons/website/static/src/js/editor/snippets.options.js @@ -807,6 +807,90 @@ options.registry.BackgroundShape.include({ } }); +options.registry.ReplaceMedia.include({ + /** + * Adds an anchor to the url. + * Here "anchor" means a specific section of a page. + * + * @see this.selectClass for parameters + */ + setAnchor(previewMode, widgetValue, params) { + const linkEl = this.$target[0].parentElement; + let url = linkEl.getAttribute('href'); + url = url.split('#')[0]; + linkEl.setAttribute('href', url + widgetValue); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _computeWidgetState(methodName, params) { + if (methodName === 'setAnchor') { + const parentEl = this.$target[0].parentElement; + if (parentEl.tagName === 'A') { + const href = parentEl.getAttribute('href') || ''; + return href ? `#${href.split('#')[1]}` : ''; + } + return ''; + } + return this._super(...arguments); + }, + /** + * @override + */ + async _computeWidgetVisibility(widgetName, params) { + if (widgetName === 'media_link_anchor_opt') { + const parentEl = this.$target[0].parentElement; + const linkEl = parentEl.tagName === 'A' ? parentEl : null; + const href = linkEl ? linkEl.getAttribute('href') : false; + return href && href.startsWith('/'); + } + return this._super(...arguments); + }, + /** + * Fills the dropdown with the available anchors for the page referenced in + * the href. + * + * @override + */ + async _renderCustomXML(uiFragment) { + await this._super(...arguments); + + const oldURLWidgetEl = uiFragment.querySelector('[data-name="media_url_opt"]'); + + const URLWidgetEl = document.createElement('we-urlpicker'); + // Copy attributes + for (const {name, value} of oldURLWidgetEl.attributes) { + URLWidgetEl.setAttribute(name, value); + } + URLWidgetEl.title = _t("Hint: Type '/' to search an existing page and '#' to link to an anchor."); + oldURLWidgetEl.replaceWith(URLWidgetEl); + + const hrefValue = this.$target[0].parentElement.getAttribute('href'); + if (!hrefValue || !hrefValue.startsWith('/')) { + return; + } + const urlWithoutAnchor = hrefValue.split('#')[0]; + const selectEl = document.createElement('we-select'); + selectEl.dataset.name = 'media_link_anchor_opt'; + selectEl.dataset.dependencies = 'media_url_opt'; + selectEl.dataset.noPreview = 'true'; + selectEl.setAttribute('string', _t("⌙ Page Anchor")); + const anchors = await wUtils.loadAnchors(urlWithoutAnchor); + for (const anchor of anchors) { + const weButtonEl = document.createElement('we-button'); + weButtonEl.dataset.setAnchor = anchor; + weButtonEl.textContent = anchor; + selectEl.append(weButtonEl); + } + URLWidgetEl.after(selectEl); + }, +}); + options.registry.BackgroundVideo = options.Class.extend({ //--------------------------------------------------------------------------