[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:
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user