diff --git a/addons/web_editor/static/src/js/wysiwyg/widgets/link.js b/addons/web_editor/static/src/js/wysiwyg/widgets/link.js
new file mode 100644
index 00000000000..70804bbcd50
--- /dev/null
+++ b/addons/web_editor/static/src/js/wysiwyg/widgets/link.js
@@ -0,0 +1,421 @@
+odoo.define('wysiwyg.widgets.Link', function (require) {
+'use strict';
+
+const core = require('web.core');
+const OdooEditorLib = require('web_editor.odoo-editor');
+const wysiwygUtils = require('@web_editor/js/wysiwyg/wysiwyg_utils');
+const Widget = require('web.Widget');
+
+const getDeepRange = OdooEditorLib.getDeepRange;
+const getInSelection = OdooEditorLib.getInSelection;
+const _t = core._t;
+
+/**
+ * Allows to customize link content and style.
+ */
+const Link = Widget.extend({
+ xmlDependencies: ['/web_editor/static/src/xml/wysiwyg.xml'],
+ events: {
+ 'input': '_onAnyChange',
+ 'change': '_onAnyChange',
+ 'input input[name="url"]': '_onURLInput',
+ },
+
+ /**
+ * @constructor
+ * @param {Boolean} data.isButton - whether if the target is a button element.
+ */
+ init: function (parent, options, editable, data, $button, link) {
+ this.options = options || {};
+ this._super(parent, _.extend({
+ title: _t("Link to"),
+ }, this.options));
+
+ this.data = data || {};
+ this.isButton = this.data.isButton;
+ this.$button = $button;
+
+ this.data.className = this.data.className || "";
+ this.data.iniClassName = this.data.iniClassName || "";
+
+ // Using explicit type 'link' to preserve style when the target is .
+ this.colorsData = [
+ {type: this.isButton ? 'link' : '', label: _t("Link"), btnPreview: 'link'},
+ {type: 'primary', label: _t("Primary"), btnPreview: 'primary'},
+ {type: 'secondary', label: _t("Secondary"), btnPreview: 'secondary'},
+ // Note: by compatibility the dialog should be able to remove old
+ // colors that were suggested like the BS status colors or the
+ // alpha -> epsilon classes. This is currently done by removing
+ // all btn-* classes anyway.
+ ];
+
+ this.editable = editable;
+ this.$editable = $(editable);
+
+ if (link) {
+ const range = document.createRange();
+ range.selectNodeContents(link);
+ this.data.range = range;
+ this.$link = $(link);
+ } else {
+ const selection = editable && editable.ownerDocument.getSelection();
+ this.data.range = selection && selection.rangeCount && selection.getRangeAt(0);
+ }
+
+ if (this.data.range) {
+ this.$link = this.$link || $(OdooEditorLib.getInSelection(this.editable.ownerDocument, 'a'));
+ this.data.iniClassName = this.$link.attr('class') || '';
+ this.colorCombinationClass = false;
+ let $node = this.$link;
+ while ($node.length && !$node.is('body')) {
+ const className = $node.attr('class') || '';
+ const m = className.match(/\b(o_cc\d+)\b/g);
+ if (m) {
+ this.colorCombinationClass = m[0];
+ break;
+ }
+ $node = $node.parent();
+ }
+ this.data.className = this.data.iniClassName.replace(/(^|\s+)btn(-[a-z0-9_-]*)?/gi, ' ');
+
+ const textLink = this.$link[0] || this.data.range.cloneContents();
+ const [encodedText, images] = this._encodeNodeToText(textLink);
+ this.data.originalText = this._decodeText(encodedText, images);
+ this.data.content = encodedText;
+ this.data.images = images;
+ this.data.url = this.$link.attr('href') || '';
+ } else {
+ this.data.content = this.data.content ? this.data.content.replace(/[ \t\r\n]+/g, ' ') : '';
+ }
+
+ const allBtnClassSuffixes = /(^|\s+)btn(-[a-z0-9_-]*)?/gi;
+ const allBtnShapes = /\s*(rounded-circle|flat)\s*/gi;
+ this.data.className = this.data.iniClassName
+ .replace(allBtnClassSuffixes, ' ')
+ .replace(allBtnShapes, ' ');
+ // 'o_submit' class will force anchor to be handled as a button in linkdialog.
+ if (/(?:s_website_form_send|o_submit)/.test(this.data.className)) {
+ this.isButton = true;
+ }
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ for (const option of this._getLinkOptions()) {
+ const $option = $(option);
+ const value = $option.is('input') ? $option.val() : $option.data('value');
+ let active = false;
+ if (value) {
+ const subValues = value.split(',');
+ let subActive = true;
+ for (let subValue of subValues) {
+ const classPrefix = new RegExp('(^|btn-| |btn-outline-)' + subValue);
+ subActive = subActive && classPrefix.test(this.data.iniClassName);
+ }
+ active = subActive;
+ } else {
+ active = !this.data.iniClassName || !this.data.iniClassName.includes('btn-');
+ }
+ this._setSelectOption($option, active);
+ }
+ if (this.data.url) {
+ var match = /mailto:(.+)/.exec(this.data.url);
+ this.$('input[name="url"]').val(match ? match[1] : this.data.url);
+ this._onURLInput();
+ }
+
+ this._updateOptionsUI();
+ this._adaptPreview(false);
+
+ this.$('input:visible:first').focus();
+
+ return this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Apply the new link to the DOM (via `this.$link`).
+ *
+ * @param {object} data
+ */
+ applyLinkToDom: function (data) {
+ const attrs = Object.assign({}, this.data.oldAttributes, {
+ href: data.url,
+ target: data.isNewWindow ? '_blank' : '',
+ class: `${data.classes}`,
+ });
+ this.$link.attr(attrs);
+ if (!this.$link.attr('target')) {
+ this.$link[0].removeAttribute('target');
+ }
+ if (data.content !== this.data.originalText || data.url !== this.data.url) {
+ const content = (data.content && data.content.length) ? data.content : data.url;
+ // If there is a this.data.originalText, it means that we selected
+ // the text and we could not change the content through the text input.
+ // .html() is needed in case we selected images.
+ if (this.data.originalText) {
+ this.$link.html(content);
+ } else {
+ this.$link.text(content);
+ }
+ }
+ },
+ /**
+ * Return the link element to edit. Create one from selection if none was
+ * present in selection.
+ *
+ * @param {Node} editable
+ * @returns {Node}
+ */
+ getOrCreateLink: function (editable) {
+ const doc = editable.ownerDocument;
+ const range = getDeepRange(editable, {splitText: true, select: true, correctTripleClick: true});
+ this.needLabel = false;
+ let link = getInSelection(doc, 'a');
+ const $link = $(link);
+ if (link && (!$link.has(range.startContainer).length || !$link.has(range.endContainer).length)) {
+ // Expand the current link to include the whole selection.
+ let before = link.previousSibling;
+ while (before !== null && range.intersectsNode(before)) {
+ link.insertBefore(before, link.firstChild);
+ before = link.previousSibling;
+ }
+ let after = link.nextSibling;
+ while (after !== null && range.intersectsNode(after)) {
+ link.appendChild(after);
+ after = link.nextSibling;
+ }
+ } else if (!link) {
+ link = document.createElement('a');
+ if (range.collapsed) {
+ range.insertNode(link);
+ this.needLabel = true;
+ } else {
+ link.appendChild(range.extractContents());
+ range.insertNode(link);
+ }
+ }
+ return link;
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Abstract method: adapt the link to changes.
+ *
+ * @abstract
+ * @private
+ */
+ _adaptPreview: function (createStep = true) {},
+ /**
+ * @private
+ */
+ _correctLink: function (url) {
+ if (url.indexOf('mailto:') === 0 || url.indexOf('tel:') === 0) {
+ url = url.replace(/^tel:([0-9]+)$/, 'tel://$1');
+ } else if (url.indexOf('@') !== -1 && url.indexOf(':') === -1) {
+ url = 'mailto:' + url;
+ } else if (url.indexOf('://') === -1 && url[0] !== '/'
+ && url[0] !== '#' && url.slice(0, 2) !== '${') {
+ url = 'http://' + url;
+ }
+ return url;
+ },
+ /**
+ * Take an HTML string and return an escape HTML string with each instance
+ * of the string "[IMG]" is replaced with the HTML from the given images
+ * respectively.
+ *
+ * @private
+ * @param {string} text
+ * @param {Node[]} images
+ * @returns {string}
+ */
+ _decodeText: function (text, images = []) {
+ text = _.escape(text);
+ for (const img of images) {
+ text = text.replace(/\[IMG\]/, img.outerHTML);
+ }
+ return text;
+ },
+ /**
+ * Abstract method: return true if the URL should be stripped of its domain.
+ *
+ * @abstract
+ * @private
+ * @returns {boolean}
+ */
+ _doStripDomain: function () {},
+ /**
+ * Take an element and return a tuple with its inner text where each image
+ * is replaced with the string "[IMG]", and a list of the replaced image
+ * elements.
+ *
+ * @param {Node} rootNode
+ * @returns {[string, Node[]]}
+ */
+ _encodeNodeToText: function (rootNode) {
+ const fake = document.createElement('fake');
+ for (const node of [...rootNode.cloneNode(true).childNodes]) {
+ fake.appendChild(node);
+ }
+
+ const nodes = this._getDescendants(fake);
+ const images = [];
+ for (const node of nodes) {
+ if (wysiwygUtils.isImg(node)) {
+ node.before(document.createTextNode('[IMG]'));
+ node.remove();
+ images.push(node);
+ }
+ }
+
+ const text = fake.innerText.replace(/[ \t\r\n]+/g, ' ');
+ return [text, images];
+ },
+ /**
+ * Get the link's data (url, content and styles).
+ *
+ * @private
+ * @returns {Object} {content: String, url: String, classes: String, isNewWindow: Boolean}
+ */
+ _getData: function () {
+ var $url = this.$('input[name="url"]');
+ var url = $url.val();
+ var content = this.$('input[name="label"]').val() || url;
+ content = this._decodeText(content, this.data.images);
+
+ if (!this.isButton && $url.prop('required') && (!url || !$url[0].checkValidity())) {
+ return null;
+ }
+
+ const type = this._getLinkType();
+ const size = this._getLinkSize();
+ const shape = this._getLinkShape();
+ const shapes = shape ? shape.split(',') : [];
+ const style = ['outline', 'fill'].includes(shapes[0]) ? `${shapes[0]}-` : '';
+ const shapeClasses = shapes.slice(style ? 1 : 0).join(' ');
+ const classes = (this.data.className || '') +
+ (type ? (` btn btn-${style}${type}`) : '') +
+ (type && shapeClasses ? (` ${shapeClasses}`) : '') +
+ (type && size ? (' btn-' + size) : '');
+ var isNewWindow = this._isNewWindow(url);
+ var doStripDomain = this._doStripDomain();
+ if (url.indexOf('@') >= 0 && url.indexOf('mailto:') < 0 && !url.match(/^http[s]?/i)) {
+ url = ('mailto:' + url);
+ } else if (url.indexOf(location.origin) === 0 && doStripDomain) {
+ url = url.slice(location.origin.length);
+ }
+ var allWhitespace = /\s+/gi;
+ var allStartAndEndSpace = /^\s+|\s+$/gi;
+ return {
+ content: content,
+ url: this._correctLink(url),
+ classes: classes.replace(allWhitespace, ' ').replace(allStartAndEndSpace, '').replace(/oe_edited_link/, ''),
+ oldAttributes: this.data.oldAttributes,
+ isNewWindow: isNewWindow,
+ doStripDomain: doStripDomain,
+ };
+ },
+ /**
+ * Return a list of all the descendants of a given element.
+ *
+ * @private
+ * @param {Node} rootNode
+ * @returns {Node[]}
+ */
+ _getDescendants: function (rootNode) {
+ const nodes = [];
+ for (const node of rootNode.childNodes) {
+ nodes.push(node);
+ nodes.push(...this._getDescendants(node));
+ }
+ return nodes;
+ },
+ /**
+ * Abstract method: return a JQuery object containing the UI elements
+ * holding the styling options of the link (eg: color, size, shape).
+ *
+ * @abstract
+ * @private
+ * @returns {JQuery}
+ */
+ _getLinkOptions: function () {},
+ /**
+ * Abstract method: return the shape(s) to apply to the link (eg:
+ * "outline", "rounded-circle", "outline,rounded-circle").
+ *
+ * @abstract
+ * @private
+ * @returns {string}
+ */
+ _getLinkShape: function () {},
+ /**
+ * Abstract method: return the size to apply to the link (eg:
+ * "sm", "lg").
+ *
+ * @private
+ * @returns {string}
+ */
+ _getLinkSize: function () {},
+ /**
+ * Abstract method: return the type to apply to the link (eg:
+ * "primary", "secondary").
+ *
+ * @private
+ * @returns {string}
+ */
+ _getLinkType: function () {},
+ /**
+ * Abstract method: return true if the link should open in a new window.
+ *
+ * @abstract
+ * @private
+ * @returns {boolean}
+ */
+ _isNewWindow: function (url) {},
+ /**
+ * Abstract method: mark one or several options as active or inactive.
+ *
+ * @abstract
+ * @private
+ * @param {JQuery} $option
+ * @param {boolean} [active]
+ */
+ _setSelectOption: function ($option, active) {},
+ /**
+ * @abstract
+ * @private
+ */
+ _updateOptionsUI: function () {},
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onAnyChange: function () {
+ this._adaptPreview();
+ },
+ /**
+ * @private
+ */
+ _onURLInput: function () {
+ var $linkUrlInput = this.$('#o_link_dialog_url_input');
+ let value = $linkUrlInput.val();
+ let isLink = value.indexOf('@') < 0;
+ this.$('input[name="is_new_window"]').closest('.form-group').toggleClass('d-none', !isLink);
+ this.$('.o_strip_domain').toggleClass('d-none', value.indexOf(window.location.origin) !== 0);
+ },
+});
+
+return Link;
+});
diff --git a/addons/web_editor/static/src/js/wysiwyg/widgets/link_dialog.js b/addons/web_editor/static/src/js/wysiwyg/widgets/link_dialog.js
index 88c533c6f61..f624738b485 100644
--- a/addons/web_editor/static/src/js/wysiwyg/widgets/link_dialog.js
+++ b/addons/web_editor/static/src/js/wysiwyg/widgets/link_dialog.js
@@ -1,150 +1,27 @@
odoo.define('wysiwyg.widgets.LinkDialog', function (require) {
'use strict';
-var core = require('web.core');
-var Dialog = require('wysiwyg.widgets.Dialog');
-const OdooEditorLib = require('web_editor.odoo-editor');
-const wysiwygUtils = require('@web_editor/js/wysiwyg/wysiwyg_utils');
+const Dialog = require('wysiwyg.widgets.Dialog');
+const Link = require('wysiwyg.widgets.Link');
-var _t = core._t;
-/**
- * Allows to customize link content and style.
- */
-var LinkDialog = Dialog.extend({
+// This widget is there only to extend Link and be instantiated by LinkDialog.
+const _DialogLinkWidget = Link.extend({
template: 'wysiwyg.widgets.link',
- xmlDependencies: (Dialog.prototype.xmlDependencies || []).concat([
- '/web_editor/static/src/xml/wysiwyg.xml'
- ]),
- events: _.extend({}, Dialog.prototype.events || {}, {
- 'input': '_onAnyChange',
+ events: _.extend({}, Link.prototype.events || {}, {
'change [name="link_style_color"]': '_onTypeChange',
- 'change': '_onAnyChange',
- 'input input[name="url"]': '_onURLInput',
}),
- /**
- * @constructor
- * @param {Boolean} linkInfo.isButton - whether if the target is a button element.
- */
- init: function (parent, options, editable, data, link) {
- this.options = options || {};
- this._super(parent, _.extend({
- title: _t("Link to"),
- }, this.options));
-
- this.data = data || {};
- this.isButton = this.data.isButton;
- // Using explicit type 'link' to preserve style when the target is .
- this.colorsData = [
- {type: this.isButton ? 'link' : '', label: _t("Link"), btnPreview: 'link'},
- {type: 'primary', label: _t("Primary"), btnPreview: 'primary'},
- {type: 'secondary', label: _t("Secondary"), btnPreview: 'secondary'},
- // Note: by compatibility the dialog should be able to remove old
- // colors that were suggested like the BS status colors or the
- // alpha -> epsilon classes. This is currently done by removing
- // all btn-* classes anyway.
- ];
-
- this.editable = editable;
- this.$editable = $(editable);
- this.data = data || {};
-
- this.data.className = this.data.className || "";
- this.data.iniClassName = this.data.iniClassName || "";
-
- if (link) {
- const range = document.createRange();
- range.selectNodeContents(link);
- this.data.range = range;
- } else {
- const selection = editable && editable.ownerDocument.getSelection();
- this.data.range = selection && selection.rangeCount && selection.getRangeAt(0);
- }
-
- if (this.data.range) {
- this.needLabel = this.data.range.collapsed;
- let $link;
- if (link) {
- $link = $(link);
- } else {
- const firstLinkInSelection = OdooEditorLib.getInSelection(this.editable.ownerDocument, 'a');
- $link = $(firstLinkInSelection);
- }
- this.data.iniClassName = $link.attr("class") || "";
- this.colorCombinationClass = false;
- let $node = $link;
- while ($node.length && !$node.is('body')) {
- const className = $node.attr('class') || '';
- const m = className.match(/\b(o_cc\d+)\b/g);
- if (m) {
- this.colorCombinationClass = m[0];
- break;
- }
- $node = $node.parent();
- }
- this.data.className = this.data.iniClassName.replace(/(^|\s+)btn(-[a-z0-9_-]*)?/gi, ' ');
-
- let textLink = link;
- if (!textLink) {
- const startLink = $(this.data.range.startContainer).closest('a')[0];
- const endLink = $(this.data.range.endContainer).closest('a')[0];
- const isLink = startLink && (startLink === endLink);
- textLink = isLink ? startLink : this.data.range.cloneContents();
- }
- const [encodedText, images] = wysiwygUtils.encodeNodeToText(textLink);
- this.data.originalText = wysiwygUtils.decodeText(encodedText, images);
- this.data.content = encodedText;
- this.data.images = images;
- this.data.url = $link.attr('href') || '';
- } else {
- this.data.content = this.data.content ? this.data.content.replace(/[ \t\r\n]+/g, ' ') : '';
- this.needLabel = true;
- }
-
- var allBtnClassSuffixes = /(^|\s+)btn(-[a-z0-9_-]*)?/gi;
- var allBtnShapes = /\s*(rounded-circle|flat)\s*/gi;
- this.data.className = this.data.iniClassName
- .replace(allBtnClassSuffixes, ' ')
- .replace(allBtnShapes, ' ');
- // 'o_submit' class will force anchor to be handled as a button in linkdialog.
- if (/(?:s_website_form_send|o_submit)/.test(this.data.className)) {
- this.isButton = true;
- }
- },
/**
* @override
*/
start: function () {
this.buttonOptsCollapseEl = this.el.querySelector('#o_link_dialog_button_opts_collapse');
-
this.$styleInputs = this.$('input.link-style');
this.$styleInputs.prop('checked', false).filter('[value=""]').prop('checked', true);
- if (this.data.iniClassName) {
- _.each(this.$('input[name="link_style_color"], select[name="link_style_size"] > option, select[name="link_style_shape"] > option'), el => {
- var $option = $(el);
- if ($option.val() && this.data.iniClassName.match(new RegExp('(^|btn-| |btn-outline-)' + $option.val()))) {
- if ($option.is("input")) {
- $option.prop("checked", true);
- } else {
- $option.parent().find('option').removeAttr('selected').removeProp('selected');
- $option.parent().val($option.val());
- $option.attr('selected', 'selected').prop('selected', 'selected');
- }
- }
- });
+ if (this.data.isNewWindow) {
+ this.$('we-button.o_we_checkbox_wrapper').toggleClass('active', true);
}
- if (this.data.url) {
- var match = /mailto:(.+)/.exec(this.data.url);
- this.$('input[name="url"]').val(match ? match[1] : this.data.url);
- this._onURLInput();
- }
-
- this._updateOptionsUI();
- this._adaptPreview();
-
- this.$('input:visible:first').focus();
-
return this._super.apply(this, arguments);
},
@@ -168,7 +45,7 @@ var LinkDialog = Dialog.extend({
var allWhitespace = /\s+/gi;
var allStartAndEndSpace = /^\s+|\s+$/gi;
var allBtnTypes = /(^|[ ])(btn-secondary|btn-success|btn-primary|btn-info|btn-warning|btn-danger)([ ]|$)/gi;
- this.data.className = data.classes.replace(allWhitespace, ' ').replace(allStartAndEndSpace, '');
+ this.data.classes = data.classes.replace(allWhitespace, ' ').replace(allStartAndEndSpace, '');
if (data.classes.replace(allBtnTypes, ' ')) {
this.data.style = {
'background-color': '',
@@ -177,11 +54,6 @@ var LinkDialog = Dialog.extend({
}
this.data.isNewWindow = data.isNewWindow;
this.final_data = this.data;
- const returnValue = this._super.apply(this, arguments);
- if (this.insertedAnchors && this.insertedAnchors.length) {
- $(this.insertedAnchors[this.insertedAnchors.length - 1]).focus();
- }
- return returnValue;
},
//--------------------------------------------------------------------------
@@ -189,9 +61,7 @@ var LinkDialog = Dialog.extend({
//--------------------------------------------------------------------------
/**
- * Adapt the link preview to changes.
- *
- * @private
+ * @override
*/
_adaptPreview: function () {
var data = this._getData();
@@ -205,55 +75,40 @@ var LinkDialog = Dialog.extend({
};
this.$("#link-preview").attr(attrs).html((data.content && data.content.length) ? data.content : data.url);
},
-
/**
- * Get the link's data (url, content and styles).
- *
- * @private
- * @returns {Object} {content: String, url: String, classes: String, isNewWindow: Boolean}
+ * @override
*/
- _getData: function () {
- var $url = this.$('input[name="url"]');
- var url = $url.val();
- var content = this.$('input[name="label"]').val() || url;
- content = wysiwygUtils.decodeText(content, this.data.images);
-
- if (!this.isButton && $url.prop('required') && (!url || !$url[0].checkValidity())) {
- return null;
- }
-
- const type = this.$('input[name="link_style_color"]:checked').val() || '';
- const size = this.$('select[name="link_style_size"]').val() || '';
- const shape = this.$('select[name="link_style_shape"]').val() || '';
- const shapes = shape ? shape.split(',') : [];
- const style = ['outline', 'fill'].includes(shapes[0]) ? `${shapes[0]}-` : '';
- const shapeClasses = shapes.slice(style ? 1 : 0).join(' ');
- const classes = (this.data.className || '') +
- (type ? (` btn btn-${style}${type}`) : '') +
- (shapeClasses ? (` ${shapeClasses}`) : '') +
- (size ? (' btn-' + size) : '');
- var isNewWindow = this.options.forceNewWindow ?
- this._isFromAnotherHostName(url) : this.$('input[name="is_new_window"]').prop('checked');
- if (url.indexOf('@') >= 0 && url.indexOf('mailto:') < 0 && !url.match(/^http[s]?/i)) {
- url = ('mailto:' + url);
- } else if (url.indexOf(location.origin) === 0 && this.$('#o_link_dialog_url_strip_domain').prop("checked")) {
- url = url.slice(location.origin.length);
- }
- var allWhitespace = /\s+/gi;
- var allStartAndEndSpace = /^\s+|\s+$/gi;
- return {
- content: content,
- url: this._correctLink(url),
- classes: classes.replace(allWhitespace, ' ').replace(allStartAndEndSpace, ''),
- isNewWindow: isNewWindow,
- };
+ _doStripDomain: function () {
+ return this.$('#o_link_dialog_url_strip_domain').prop('checked');
},
/**
- * @private
+ * @override
*/
- _updateOptionsUI: function () {
- const el = this.el.querySelector('[name="link_style_color"]:checked');
- $(this.buttonOptsCollapseEl).collapse(el && el.value ? 'show' : 'hide');
+ _getLinkOptions: function () {
+ const options = [
+ 'input[name="link_style_color"]',
+ 'select[name="link_style_size"] > option',
+ 'select[name="link_style_shape"] > option',
+ ];
+ return this.$(options.join(','));
+ },
+ /**
+ * @override
+ */
+ _getLinkShape: function () {
+ return this.$('select[name="link_style_shape"]').val() || '';
+ },
+ /**
+ * @override
+ */
+ _getLinkSize: function () {
+ return this.$('select[name="link_style_size"]').val() || '';
+ },
+ /**
+ * @override
+ */
+ _getLinkType: function () {
+ return this.$('input[name="link_style_color"]:checked').val() || '';
},
/**
* @private
@@ -271,30 +126,39 @@ var LinkDialog = Dialog.extend({
}
},
/**
- * @private
+ * @override
*/
- _correctLink: function (url) {
- if (url.indexOf('mailto:') === 0 || url.indexOf('tel:') === 0) {
- url = url.replace(/^tel:([0-9]+)$/, 'tel://$1');
- } else if (url.indexOf('@') !== -1 && url.indexOf(':') === -1) {
- url = 'mailto:' + url;
- } else if (url.indexOf('://') === -1 && url[0] !== '/'
- && url[0] !== '#' && url.slice(0, 2) !== '${') {
- url = 'http://' + url;
+ _isNewWindow: function (url) {
+ if (this.options.forceNewWindow) {
+ return this._isFromAnotherHostName(url);
+ } else {
+ return this.$('input[name="is_new_window"]').prop('checked');
}
- return url;
+ },
+ /**
+ * @override
+ */
+ _setSelectOption: function ($option, active) {
+ if ($option.is("input")) {
+ $option.prop("checked", active);
+ } else if (active) {
+ $option.parent().find('option').removeAttr('selected').removeProp('selected');
+ $option.parent().val($option.val());
+ $option.attr('selected', 'selected').prop('selected', 'selected');
+ }
+ },
+ /**
+ * @override
+ */
+ _updateOptionsUI: function () {
+ const el = this.el.querySelector('[name="link_style_color"]:checked');
+ $(this.buttonOptsCollapseEl).collapse(el && el.value ? 'show' : 'hide');
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
- /**
- * @private
- */
- _onAnyChange: function () {
- this._adaptPreview();
- },
/**
* @private
*/
@@ -302,15 +166,39 @@ var LinkDialog = Dialog.extend({
this._updateOptionsUI();
},
/**
- * @private
+ * @override
*/
_onURLInput: function () {
- var $linkUrlInput = this.$('#o_link_dialog_url_input');
- $linkUrlInput.closest('.form-group').removeClass('o_has_error').find('.form-control, .custom-select').removeClass('is-invalid');
- let value = $linkUrlInput.val();
- let isLink = value.indexOf('@') < 0;
- this.$('input[name="is_new_window"]').closest('.form-group').toggleClass('d-none', !isLink);
- this.$('.o_strip_domain').toggleClass('d-none', value.indexOf(window.location.origin) !== 0);
+ this._super(...arguments);
+ this.$('#o_link_dialog_url_input').closest('.form-group').removeClass('o_has_error').find('.form-control, .custom-select').removeClass('is-invalid');
+ },
+});
+
+/**
+ * Allows to customize link content and style.
+ */
+ const LinkDialog = Dialog.extend({
+ init: function (parent, ...args) {
+ this._super(...arguments);
+ this.linkWidget = new _DialogLinkWidget(this, ...args);
+ },
+ start: async function () {
+ const res = await this._super(...arguments);
+ this.linkWidget.appendTo(this.$el);
+ return res;
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ save: function () {
+ this.linkWidget.save();
+ this.final_data = this.linkWidget.final_data;
+ return this._super(...arguments);
},
});
diff --git a/addons/web_editor/static/src/js/wysiwyg/widgets/link_tools.js b/addons/web_editor/static/src/js/wysiwyg/widgets/link_tools.js
index fa73e4ac88a..cc661c86700 100644
--- a/addons/web_editor/static/src/js/wysiwyg/widgets/link_tools.js
+++ b/addons/web_editor/static/src/js/wysiwyg/widgets/link_tools.js
@@ -1,129 +1,37 @@
odoo.define('wysiwyg.widgets.LinkTools', function (require) {
'use strict';
-const core = require('web.core');
+const Link = require('wysiwyg.widgets.Link');
const OdooEditorLib = require('web_editor.odoo-editor');
-const Widget = require('web.Widget');
-const wysiwygUtils = require('@web_editor/js/wysiwyg/wysiwyg_utils');
-const getInSelection = OdooEditorLib.getInSelection;
-const getDeepRange = OdooEditorLib.getDeepRange;
-const setCursor = OdooEditorLib.setCursor;
const nodeSize = OdooEditorLib.nodeSize;
-
-const _t = core._t;
+const setCursor = OdooEditorLib.setCursor;
/**
* Allows to customize link content and style.
*/
-const LinkTools = Widget.extend({
+const LinkTools = Link.extend({
template: 'wysiwyg.widgets.linkTools',
- xmlDependencies: ['/web_editor/static/src/xml/wysiwyg.xml'],
- events: {
- 'input': '_onAnyChange',
- 'change': '_onAnyChange',
- 'input input[name="url"]': '_onURLInput',
+ events: _.extend({}, Link.prototype.events, {
'click we-select we-button': '_onPickSelectOption',
'click we-checkbox': '_onClickCheckbox',
- },
+ }),
/**
- * @constructor
+ * @override
*/
- init: function (parent, options, editable, $button, link) {
- this.options = options || {};
- this._super(parent, _.extend({
- title: _t("Link to"),
- }, this.options));
-
- this.$button = $button;
- this.colorsData = [
- {type: '', label: _t("Link"), btnPreview: 'link'},
- {type: 'primary', label: _t("Primary"), btnPreview: 'primary'},
- {type: 'secondary', label: _t("Secondary"), btnPreview: 'secondary'},
- // Note: by compatibility the dialog should be able to remove old
- // colors that were suggested like the BS status colors or the
- // alpha -> epsilon classes. This is currently done by removing
- // all btn-* classes anyway.
- ];
-
- this.editable = editable;
- this.$editable = $(editable);
- this.data = {};
-
- this.data.className = "";
- this.data.iniClassName = "";
-
- this.data.range = editable.ownerDocument.getSelection().getRangeAt(0);
-
- this.$link = this._getOrCreateLink(link);
- this.data.iniClassName = this.$link.attr("class") || "";
- this.colorCombinationClass = '';
-
- const allBtnClassSuffixes = /(^|\s+)btn(-[a-z0-9_-]*)?/gi;
- this.data.className = this.data.iniClassName.replace(allBtnClassSuffixes, ' ');
- const [encodedText, images] = wysiwygUtils.encodeNodeToText(this.$link[0]);
- this.data.content = encodedText;
- this.data.originalText = wysiwygUtils.decodeText(encodedText, images);
- this.data.images = images;
- this.data.oldAttributes = this.$link.getAttributes();
- this.data.url = this.$link.attr('href');
- this.data.isNewWindow = this.$link.attr('target') === '_blank';
-
- const allBtnShapes = /\s*(rounded-circle|flat)\s*/gi;
- this.data.className = this.data.iniClassName
- .replace(allBtnClassSuffixes, ' ')
- .replace(allBtnShapes, ' ');
- // 'o_submit' class will force anchor to be handled as a button in linkdialog.
- if (/(?:s_website_form_send|o_submit)/.test(this.data.className)) {
- this.isButton = true;
- }
+ init: function (parent, options, editable, data, $button, link) {
+ options.wysiwyg.odooEditor.automaticStepSkipStack();
+ link = link || this.getOrCreateLink(editable);
+ this._super(parent, options, editable, data, $button, link);
},
/**
* @override
*/
start: function () {
+ this.$link.addClass('oe_edited_link');
this.$button.addClass('active');
-
- if (this.data.iniClassName) {
- const options = [
- 'we-selection-items[name="link_style_color"] > we-button',
- 'we-selection-items[name="link_style_size"] > we-button',
- 'we-selection-items[name="link_style_shape"] > we-button',
- ];
- for (const option of this.$(options.join(','))) {
- const $option = $(option);
- const value = $option.data('value');
- let active = false;
- if (value) {
- const subValues = value.split(',');
- let subActive = true;
- for (let subValue of subValues) {
- const classPrefix = new RegExp('(^|btn-| |btn-outline-)' + subValue);
- subActive = subActive && classPrefix.test(this.data.iniClassName);
- }
- active = subActive;
- } else {
- active = !this.data.iniClassName.includes('btn-');
- }
- this._setSelectOption($option, active);
- }
- }
- if (this.data.url) {
- var match = /mailto:(.+)/.exec(this.data.url);
- this.$('input[name="url"]').val(match ? match[1] : this.data.url);
- this._onURLInput();
- }
- if (this.data.isNewWindow) {
- this.$('we-button.o_we_checkbox_wrapper').toggleClass('active', true);
- }
-
- this._updateOptionsUI();
- this._adaptLink(false);
-
- this.$('input:visible:first').focus();
-
- return this._super.apply(this, arguments);
+ return this._super(...arguments);
},
destroy: function () {
this.options.wysiwyg.odooEditor.automaticStepSkipStack();
@@ -144,34 +52,16 @@ const LinkTools = Widget.extend({
//--------------------------------------------------------------------------
/**
- * Adapt the link to changes.
- *
- * @private
+ * @override
*/
- _adaptLink: function (createStep = true) {
+ _adaptPreview: function (createStep = true) {
var data = this._getData();
if (data === null) {
return;
}
- const attrs = Object.assign({}, this.data.oldAttributes, {
- target: data.isNewWindow ? '_blank' : '',
- href: data.url,
- class: `${data.classes}`,
- });
const $links = $('.oe_edited_link');
$links.removeClass('oe_edited_link');
- this.$link.attr(attrs);
- if (data.content !== this.data.originalText || data.url !== this.data.url) {
- const content = (data.content && data.content.length) ? data.content : data.url;
- // If there is a this.data.originalText, it means that we selected
- // the text and we could not change the content through the text input.
- // .html() in needed in case we selected images.
- if (this.data.originalText) {
- this.$link.html(content);
- } else {
- this.$link.text(content);
- }
- }
+ this.applyLinkToDom(data);
if (createStep) {
this.options.wysiwyg.odooEditor.historyStep();
}
@@ -179,81 +69,49 @@ const LinkTools = Widget.extend({
$links.addClass('oe_edited_link');
},
/**
- * Get the link's data (url, content and styles).
- *
- * @private
- * @returns {Object} {content: String, url: String, classes: String, isNewWindow: Boolean}
+ * @override
*/
- _getData: function () {
- var $url = this.$('input[name="url"]');
- var url = $url.val();
- var content = this.$('input[name="label"]').val() || url;
- content = wysiwygUtils.decodeText(content, this.data.images);
-
- if (!this.isButton && $url.prop('required') && (!url || !$url[0].checkValidity())) {
- return null;
- }
-
- const type = this.$('we-selection-items[name="link_style_color"] we-button.active').data('value') || '';
- const size = this.$('we-selection-items[name="link_style_size"] we-button.active').data('value') || '';
- const shape = this.$('we-selection-items[name="link_style_shape"] we-button.active').data('value') || '';
- const shapes = shape ? shape.split(',') : [];
- const style = ['outline', 'fill'].includes(shapes[0]) ? `${shapes[0]}-` : '';
- const shapeClasses = shapes.slice(style ? 1 : 0).join(' ');
- const classes = (this.data.className || '') +
- (type ? (` btn btn-${style}${type}`) : '') +
- (type && shapeClasses ? (` ${shapeClasses}`) : '') +
- (type && size ? (' btn-' + size) : '');
- var isNewWindow = this.$('we-checkbox[name="is_new_window"]').closest('we-button.o_we_checkbox_wrapper').hasClass('active');
- var doStripDomain = this.$('we-checkbox[name="do_strip_domain"]').closest('we-button.o_we_checkbox_wrapper').hasClass('active');
- if (url.indexOf('@') >= 0 && url.indexOf('mailto:') < 0 && !url.match(/^http[s]?/i)) {
- url = ('mailto:' + url);
- } else if (url.indexOf(location.origin) === 0 && doStripDomain) {
- url = url.slice(location.origin.length);
- }
- var allWhitespace = /\s+/gi;
- var allStartAndEndSpace = /^\s+|\s+$/gi;
- return {
- content: content,
- url: this._correctLink(url),
- classes: classes.replace(allWhitespace, ' ').replace(allStartAndEndSpace, '').replace(/oe_edited_link/, ''),
- oldAttributes: this.data.oldAttributes,
- isNewWindow: isNewWindow,
- doStripDomain: doStripDomain,
- };
+ _doStripDomain: function () {
+ return this.$('we-checkbox[name="do_strip_domain"]').closest('we-button.o_we_checkbox_wrapper').hasClass('active');
},
- _getOrCreateLink: function (linkToEdit) {
- this.options.wysiwyg.odooEditor.automaticStepSkipStack();
- const doc = this.editable.ownerDocument;
- const range = getDeepRange(this.editable, {splitText: true, select: true, correctTripleClick: true});
- this.needLabel = false;
- let link = linkToEdit || getInSelection(doc, 'a');
- const $link = $(link);
- if (link && !linkToEdit && (!$link.has(range.startContainer).length || !$link.has(range.endContainer).length)) {
- // Expand the current link to include the whole selection.
- let before = link.previousSibling;
- while (before !== null && range.intersectsNode(before)) {
- link.insertBefore(before, link.firstChild);
- before = link.previousSibling;
- }
- let after = link.nextSibling;
- while (after !== null && range.intersectsNode(after)) {
- link.appendChild(after);
- after = link.nextSibling;
- }
- } else if (!link) {
- link = document.createElement('a');
- if (range.collapsed) {
- range.insertNode(link);
- this.needLabel = true;
- } else {
- link.appendChild(range.extractContents());
- range.insertNode(link);
- }
- }
- link.classList.add('oe_edited_link');
- return $(link);
+ /**
+ * @override
+ */
+ _getLinkOptions: function () {
+ const options = [
+ 'we-selection-items[name="link_style_color"] > we-button',
+ 'we-selection-items[name="link_style_size"] > we-button',
+ 'we-selection-items[name="link_style_shape"] > we-button',
+ ];
+ return this.$(options.join(','));
},
+ /**
+ * @override
+ */
+ _getLinkShape: function () {
+ return this.$('we-selection-items[name="link_style_shape"] we-button.active').data('value') || '';
+ },
+ /**
+ * @override
+ */
+ _getLinkSize: function () {
+ return this.$('we-selection-items[name="link_style_size"] we-button.active').data('value') || '';
+ },
+ /**
+ * @override
+ */
+ _getLinkType: function () {
+ return this.$('we-selection-items[name="link_style_color"] we-button.active').data('value') || '';
+ },
+ /**
+ * @override
+ */
+ _isNewWindow: function () {
+ return this.$('we-checkbox[name="is_new_window"]').closest('we-button.o_we_checkbox_wrapper').hasClass('active');
+ },
+ /**
+ * @override
+ */
_setSelectOption: function ($option, active) {
$option.toggleClass('active', active);
if (active) {
@@ -263,7 +121,7 @@ const LinkTools = Widget.extend({
}
},
/**
- * @private
+ * @override
*/
_updateOptionsUI: function () {
const el = this.el.querySelector('[name="link_style_color"] we-button.active');
@@ -273,35 +131,15 @@ const LinkTools = Widget.extend({
this.$('.link-size-row, .link-shape-row').toggleClass('d-none', !this.colorCombinationClass);
}
},
- /**
- * @private
- */
- _correctLink: function (url) {
- if (url.indexOf('mailto:') === 0 || url.indexOf('tel:') === 0) {
- url = url.replace(/^tel:([0-9]+)$/, 'tel://$1');
- } else if (url.indexOf('@') !== -1 && url.indexOf(':') === -1) {
- url = 'mailto:' + url;
- } else if (url.indexOf('://') === -1 && url[0] !== '/'
- && url[0] !== '#' && url.slice(0, 2) !== '${') {
- url = 'http://' + url;
- }
- return url;
- },
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
- /**
- * @private
- */
- _onAnyChange: function () {
- this._adaptLink();
- },
_onClickCheckbox: function (ev) {
const $target = $(ev.target);
$target.closest('we-button.o_we_checkbox_wrapper').toggleClass('active');
- this._adaptLink();
+ this._adaptPreview();
},
_onPickSelectOption: function (ev) {
const $target = $(ev.target);
@@ -309,17 +147,7 @@ const LinkTools = Widget.extend({
$select.find('we-selection-items we-button').toggleClass('active', false);
this._setSelectOption($target, true);
this._updateOptionsUI();
- this._adaptLink();
- },
- /**
- * @private
- */
- _onURLInput: function () {
- var $linkUrlInput = this.$('#o_link_dialog_url_input');
- let value = $linkUrlInput.val();
- let isLink = value.indexOf('@') < 0;
- this.$('input[name="is_new_window"]').closest('.form-group').toggleClass('d-none', !isLink);
- this.$('.o_strip_domain').toggleClass('d-none', value.indexOf(window.location.origin) !== 0);
+ this._adaptPreview();
},
});
diff --git a/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js b/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js
index 34075878beb..55d726a53bc 100644
--- a/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js
+++ b/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js
@@ -540,7 +540,7 @@ const Wysiwyg = Widget.extend({
}
if (forceOpen || !this.linkTools) {
const $btn = this.toolbar.$el.find('#create-link');
- this.linkTools = new weWidgets.LinkTools(this, {wysiwyg: this}, this.odooEditor.editable, $btn, link);
+ this.linkTools = new weWidgets.LinkTools(this, {wysiwyg: this}, this.odooEditor.editable, {}, $btn, link);
this.linkTools.appendTo(this.toolbar.$el);
} else {
this.linkTools = undefined;
@@ -548,44 +548,16 @@ const Wysiwyg = Widget.extend({
} else {
const linkDialog = new weWidgets.LinkDialog(this, {
forceNewWindow: this.options.linkForceNewWindow,
- }, this.$editable[0], undefined, link);
+ wysiwyg: this,
+ }, this.$editable[0], {}, undefined, link);
linkDialog.open();
- linkDialog.on('save', this, (linkInfo) => {
- const linkUrl = linkInfo.url;
- const linkContent = linkInfo.content;
- const isNewWindow = linkInfo.isNewWindow;
- const hasTextChanged = linkInfo.originalText !== linkContent;
-
- const range = linkInfo.range;
-
- const ancestorAnchor = $(range.startContainer).closest('a')[0];
- let anchors = [];
- if (ancestorAnchor && ancestorAnchor === $(range.endContainer).closest('a')[0]) {
- anchors.push($(ancestorAnchor).html(linkContent).get(0));
- } else if (hasTextChanged) {
- const anchor = $('' + linkContent + '')[0];
- range.insertNode(anchor);
- anchors.push(anchor);
- } else {
- const anchor = $('')[0];
- anchor.appendChild(range.extractContents());
- range.insertNode(anchor);
- anchors.push(anchor);
+ linkDialog.on('save', this, data => {
+ const linkWidget = linkDialog.linkWidget;
+ getDeepRange(this.$editable[0], {range: data.range, select: true});
+ if (!linkWidget.$link.length) {
+ linkWidget.$link = $(linkWidget.getOrCreateLink(this.$editable[0]));
}
- linkDialog.insertedAnchors = [...anchors];
- for (const anchor of anchors) {
- $(anchor).attr('href', linkUrl);
- $(anchor).attr('class', linkInfo.className || null);
- $(anchor).css(linkInfo.style || {});
- if (isNewWindow) {
- $(anchor).attr('target', '_blank');
- } else {
- $(anchor).removeAttr('target');
- }
- range.selectNode(anchor);
- range.collapse();
- }
- getDeepRange(this.odooEditor.editable, {range, select: true});
+ linkWidget.applyLinkToDom(data);
});
}
},
diff --git a/addons/web_editor/static/src/js/wysiwyg/wysiwyg_utils.js b/addons/web_editor/static/src/js/wysiwyg/wysiwyg_utils.js
index 607cb90e1a2..e15bd82e845 100644
--- a/addons/web_editor/static/src/js/wysiwyg/wysiwyg_utils.js
+++ b/addons/web_editor/static/src/js/wysiwyg/wysiwyg_utils.js
@@ -3,41 +3,3 @@
export function isImg(node) {
return (node && (node.nodeName === "IMG" || (node.className && node.className.match(/(^|\s)(media_iframe_video|o_image|fa)(\s|$)/i))));
}
-
-function getIntermediateNodes(rootNode) {
- const nodes = [];
- for (const node of rootNode.childNodes) {
- nodes.push(node);
- nodes.push(...getIntermediateNodes(node));
- }
- return nodes;
-}
-
-export function encodeNodeToText(rootNode) {
- const fake = document.createElement('fake');
- for (const node of [...rootNode.cloneNode(true).childNodes]) {
- fake.appendChild(node);
- }
-
- const nodes = getIntermediateNodes(fake);
-
- const images = [];
- for (const node of nodes) {
- if (isImg(node)) {
- node.before(document.createTextNode('[IMG]'));
- node.remove();
- images.push(node);
- }
- }
-
- const text = fake.innerText.replace(/[ \t\r\n]+/g, ' ');
- return [text, images];
-}
-
-export function decodeText(text, images = []) {
- text = _.escape(text);
- for (const img of images) {
- text = text.replace(/\[IMG\]/, img.outerHTML);
- }
- return text;
-}