[FIX] web_editor: remove link when pasting over its content

This commit prevents pasting content inside a link when it is
empty (ZeroWidthSpace only) or its text content is fully selected,
making the pasted content replace the link.

task-3081254

closes odoo/odoo#118204

X-original-commit: 7d0da7f56904927f93ef3d678083f3219fadfdc9
Signed-off-by: David Monjoie (dmo) <dmo@odoo.com>
This commit is contained in:
Rodolpho Lima
2023-04-12 12:26:09 +02:00
parent 302f279c86
commit 5190870a7c
2 changed files with 157 additions and 9 deletions
@@ -73,6 +73,7 @@ import {
cleanZWS,
isZWS,
getDeepestPosition,
leftPos,
} from './utils/utils.js';
import { editorCommands } from './commands/commands.js';
import { Powerbox } from './powerbox/Powerbox.js';
@@ -4282,15 +4283,27 @@ export class OdooEditor extends EventTarget {
const odooEditorHtml = ev.clipboardData.getData('text/odoo-editor');
const clipboardHtml = ev.clipboardData.getData('text/html');
const targetSupportsHtmlContent = isHtmlContentSupported(sel.anchorNode);
// Replace entire link if its label is fully selected.
const link = closestElement(sel.anchorNode, 'a');
if (link && sel.toString().replace(/\u200B/g, '') === link.innerText.replace(/\u200B/g, '')) {
const start = leftPos(link);
// Exit link isolation since we're removing the link and editing outside of it.
if (this._fixLinkMutatedElements && this._fixLinkMutatedElements.link === link) {
this.resetContenteditableLink();
this._activateContenteditable();
}
link.remove();
setSelection(...start, ...start, false);
}
if (odooEditorHtml && targetSupportsHtmlContent) {
const fragment = parseHTML(odooEditorHtml);
DOMPurify.sanitize(fragment, { IN_PLACE: true });
if (fragment.hasChildNodes()) {
this.execCommand('insert', fragment);
this._applyCommand('insert', fragment);
}
} else if (files.length && targetSupportsHtmlContent) {
this.addImagesFiles(files).then(html => {
const imageNodes = this.execCommand('insert', this._prepareClipboardData(html));
const imageNodes = this._applyCommand('insert', this._prepareClipboardData(html));
if (imageNodes && this.options.dropImageAsAttachment) {
// Mark images as having to be saved as attachments.
for (const imageNode of imageNodes) {
@@ -4299,7 +4312,7 @@ export class OdooEditor extends EventTarget {
}
});
} else if (clipboardHtml && targetSupportsHtmlContent) {
this.execCommand('insert', this._prepareClipboardData(clipboardHtml));
this._applyCommand('insert', this._prepareClipboardData(clipboardHtml));
} else {
const text = ev.clipboardData.getData('text/plain');
const selectionIsInsideALink = !!closestElement(sel.anchorNode, 'a');
@@ -4326,13 +4339,19 @@ export class OdooEditor extends EventTarget {
}
} else if (isImageUrl || youtubeUrl) {
// Open powerbox with commands to embed media or paste as link.
// Insert URL as text, store history step index to revert it later.
// Store history step index to revert it later.
const stepIndexBeforeInsert = this._historySteps.length - 1;
// Store mutations before text insertion, to reapply them after history revert.
this.observerFlush();
const currentStepMutations = [...this._currentStep.mutations];
// Insert URL as text, revert it later.
this._applyCommand('insert', text);
const revertTextInsertion = () => {
this.historyRevertUntil(stepIndexBeforeInsert);
this.historyStep(true);
this._historyStepsStates.set(peek(this._historySteps).id, 'consumed');
// Reapply mutations that were done before the text insertion.
this.historyApply(currentStepMutations);
};
let commands;
const pasteAsURLCommand = {
@@ -1418,13 +1418,76 @@ describe('Paste', () => {
contentAfter: '<p>a<a href="https://boom.com">boom[].com</a>d</p>',
});
});
it('should paste and transform URL over the existing url', async () => {
it('should replace link for new content when pasting in an empty link', async () => {
await testEditor(BasicEditor, {
contentBefore: '<p>ab[<a href="http://www.xyz.com">http://www.xyz.com</a>]cd</p>',
contentBefore: '<p><a href="#" oe-zws-empty-inline="">[]\u200B</a></p>',
stepFunction: async editor => {
await pasteText(editor, 'https://www.xyz.xdc ');
await pasteText(editor, 'abc');
},
contentAfter: '<p>ab<a href="https://www.xyz.xdc">https://www.xyz.xdc</a> []cd</p>',
contentAfter: '<p>abc[]</p>',
});
await testEditor(BasicEditor, {
contentBefore: '<p>xy<a href="#" oe-zws-empty-inline="">\u200B[]</a>z</p>',
stepFunction: async editor => {
await pasteText(editor, 'abc');
},
contentAfter: '<p>xyabc[]z</p>',
});
await testEditor(BasicEditor, {
contentBefore: '<p>xy<a href="#" oe-zws-empty-inline="">\u200B[]</a>z</p>',
stepFunction: async editor => {
await pasteText(editor, 'http://odoo.com');
},
contentAfter: '<p>xy<a href="http://odoo.com">http://odoo.com</a>[]z</p>',
});
const imageUrl = 'https://download.odoocdn.com/icons/website/static/description/icon.png';
await testEditor(BasicEditor, {
contentBefore: '<p>xy<a href="#" oe-zws-empty-inline="">\u200B[]</a>z</p>',
stepFunction: async editor => {
await pasteText(editor, imageUrl);
// Ensure the powerbox is active
window.chai.expect(editor.powerbox.isOpen).to.be.true;
// Pick the first command (Embed image)
triggerEvent(editor.editable, 'keydown', { key: 'Enter' });
},
contentAfter: `<p>xy<img src="${imageUrl}">[]z</p>`,
});
await testEditor(BasicEditor, {
contentBefore: '<p>xy<a href="#" oe-zws-empty-inline="">\u200B[]</a>z</p>',
stepFunction: async editor => {
await pasteText(editor, imageUrl);
// Ensure the powerbox is active
window.chai.expect(editor.powerbox.isOpen).to.be.true;
// Pick the second command (Paste as URL)
triggerEvent(editor.editable, 'keydown', { key: 'ArrowDown' });
triggerEvent(editor.editable, 'keydown', { key: 'Enter' });
},
contentAfter: `<p>xy<a href="${imageUrl}">${imageUrl}</a>[]z</p>`,
});
});
it('should paste and transform plain text content over an empty link', async () => {
await testEditor(BasicEditor, {
contentBefore: '<p><a href="#">[]\u200B</a></p>',
stepFunction: async editor => {
await pasteText(editor, 'abc www.odoo.com xyz');
},
contentAfter: '<p>abc <a href="https://www.odoo.com">www.odoo.com</a> xyz[]</p>',
});
await testEditor(BasicEditor, {
contentBefore: '<p><a href="#">[]\u200B</a></p>',
stepFunction: async editor => {
await pasteText(editor, 'odoo.com\ngoogle.com');
},
contentAfter: '<p><a href="https://odoo.com">odoo.com</a><br><a href="https://google.com">google.com</a>[]<br></p>',
});
});
it('should paste html content over an empty link', async () => {
await testEditor(BasicEditor, {
contentBefore: '<p><a href="#">[]\u200B</a></p>',
stepFunction: async editor => {
await pasteHtml(editor, '<a href="www.odoo.com">odoo.com</a><br><a href="www.google.com">google.com</a>');
},
contentAfter: '<p><a href="www.odoo.com">odoo.com</a><br><a href="www.google.com">google.com</a>[]</p>',
});
});
it('should paste and transform URL among text', async () => {
@@ -1536,7 +1599,6 @@ describe('Paste', () => {
},
contentAfter: '<p>[abc]</p>',
});
});
it('should restore selection after pasting HTML followed by UNDO', async () => {
await testEditor(BasicEditor, {
@@ -1589,6 +1651,73 @@ describe('Paste', () => {
contentAfter: `<p><a href="${url}">${url}</a> <a href="${videoUrl}">${videoUrl}</a> <a href="${imgUrl}">${imgUrl}</a>[]<br></p>`,
});
});
it('should paste and transform URL over the existing url', async () => {
await testEditor(BasicEditor, {
contentBefore: '<p>ab[<a href="http://www.xyz.com">http://www.xyz.com</a>]cd</p>',
stepFunction: async editor => {
await pasteText(editor, 'https://www.xyz.xdc ');
},
contentAfter: '<p>ab<a href="https://www.xyz.xdc">https://www.xyz.xdc</a> []cd</p>',
});
});
it('should paste plain text content over a link if all of its contents is selected', async () => {
await testEditor(BasicEditor, {
contentBefore: '<p>a<a href="#">[xyz]</a>d</p>',
stepFunction: async editor => {
await pasteText(editor, 'bc');
},
contentAfter: '<p>abc[]d</p>',
});
});
it('should paste and transform plain text content over a link if all of its contents is selected', async () => {
await testEditor(BasicEditor, {
contentBefore: '<p><a href="#">[xyz]</a></p>',
stepFunction: async editor => {
await pasteText(editor, 'www.odoo.com');
},
contentAfter: '<p><a href="https://www.odoo.com">www.odoo.com</a>[]</p>',
});
await testEditor(BasicEditor, {
contentBefore: '<p><a href="#">[xyz]</a></p>',
stepFunction: async editor => {
await pasteText(editor, 'abc www.odoo.com xyz');
},
contentAfter: '<p>abc <a href="https://www.odoo.com">www.odoo.com</a> xyz[]</p>',
});
const imageUrl = 'https://download.odoocdn.com/icons/website/static/description/icon.png';
await testEditor(BasicEditor, {
contentBefore: '<p>ab<a href="http://www.xyz.com">[http://www.xyz.com]</a>cd</p>',
stepFunction: async editor => {
await pasteText(editor, imageUrl);
// Ensure the powerbox is active
window.chai.expect(editor.powerbox.isOpen).to.be.true;
// Pick the first command (Embed image)
triggerEvent(editor.editable, 'keydown', { key: 'Enter' });
},
contentAfter: `<p>ab<img src="${imageUrl}">[]cd</p>`,
});
await testEditor(BasicEditor, {
contentBefore: '<p>ab<a href="http://www.xyz.com">[http://www.xyz.com]</a>cd</p>',
stepFunction: async editor => {
await pasteText(editor, imageUrl);
// Ensure the powerbox is active
window.chai.expect(editor.powerbox.isOpen).to.be.true;
// Pick the second command (Paste as URL)
triggerEvent(editor.editable, 'keydown', { key: 'ArrowDown' });
triggerEvent(editor.editable, 'keydown', { key: 'Enter' });
},
contentAfter: `<p>ab<a href="${imageUrl}">${imageUrl}</a>[]cd</p>`,
});
});
it('should paste html content over a link if all of its contents is selected', async () => {
await testEditor(BasicEditor, {
contentBefore: '<p><a href="#">[xyz]</a></p>',
stepFunction: async editor => {
await pasteHtml(editor, '<a href="www.odoo.com">odoo.com</a><br><a href="www.google.com">google.com</a>');
},
contentAfter: '<p><a href="www.odoo.com">odoo.com</a><br><a href="www.google.com">google.com</a>[]</p>',
});
});
});
});
describe('images', () => {