diff --git a/addons/web_editor/static/src/js/editor/odoo-editor/src/OdooEditor.js b/addons/web_editor/static/src/js/editor/odoo-editor/src/OdooEditor.js index aa4505abe43..1d6cc50cd42 100644 --- a/addons/web_editor/static/src/js/editor/odoo-editor/src/OdooEditor.js +++ b/addons/web_editor/static/src/js/editor/odoo-editor/src/OdooEditor.js @@ -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 = { diff --git a/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/copyPaste.test.js b/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/copyPaste.test.js index 74ddaf0bd30..3dbd4c189e9 100644 --- a/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/copyPaste.test.js +++ b/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/copyPaste.test.js @@ -1418,13 +1418,76 @@ describe('Paste', () => { contentAfter: '

aboom[].comd

', }); }); - 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: '

ab[http://www.xyz.com]cd

', + contentBefore: '

[]\u200B

', stepFunction: async editor => { - await pasteText(editor, 'https://www.xyz.xdc '); + await pasteText(editor, 'abc'); }, - contentAfter: '

abhttps://www.xyz.xdc []cd

', + contentAfter: '

abc[]

', + }); + await testEditor(BasicEditor, { + contentBefore: '

xy\u200B[]z

', + stepFunction: async editor => { + await pasteText(editor, 'abc'); + }, + contentAfter: '

xyabc[]z

', + }); + await testEditor(BasicEditor, { + contentBefore: '

xy\u200B[]z

', + stepFunction: async editor => { + await pasteText(editor, 'http://odoo.com'); + }, + contentAfter: '

xyhttp://odoo.com[]z

', + }); + const imageUrl = 'https://download.odoocdn.com/icons/website/static/description/icon.png'; + await testEditor(BasicEditor, { + contentBefore: '

xy\u200B[]z

', + 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: `

xy[]z

`, + }); + await testEditor(BasicEditor, { + contentBefore: '

xy\u200B[]z

', + 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: `

xy${imageUrl}[]z

`, + }); + }); + it('should paste and transform plain text content over an empty link', async () => { + await testEditor(BasicEditor, { + contentBefore: '

[]\u200B

', + stepFunction: async editor => { + await pasteText(editor, 'abc www.odoo.com xyz'); + }, + contentAfter: '

abc www.odoo.com xyz[]

', + }); + await testEditor(BasicEditor, { + contentBefore: '

[]\u200B

', + stepFunction: async editor => { + await pasteText(editor, 'odoo.com\ngoogle.com'); + }, + contentAfter: '

odoo.com
google.com[]

', + }); + }); + it('should paste html content over an empty link', async () => { + await testEditor(BasicEditor, { + contentBefore: '

[]\u200B

', + stepFunction: async editor => { + await pasteHtml(editor, 'odoo.com
google.com'); + }, + contentAfter: '

odoo.com
google.com[]

', }); }); it('should paste and transform URL among text', async () => { @@ -1536,7 +1599,6 @@ describe('Paste', () => { }, contentAfter: '

[abc]

', }); - }); it('should restore selection after pasting HTML followed by UNDO', async () => { await testEditor(BasicEditor, { @@ -1589,6 +1651,73 @@ describe('Paste', () => { contentAfter: `

${url} ${videoUrl} ${imgUrl}[]

`, }); }); + it('should paste and transform URL over the existing url', async () => { + await testEditor(BasicEditor, { + contentBefore: '

ab[http://www.xyz.com]cd

', + stepFunction: async editor => { + await pasteText(editor, 'https://www.xyz.xdc '); + }, + contentAfter: '

abhttps://www.xyz.xdc []cd

', + }); + }); + it('should paste plain text content over a link if all of its contents is selected', async () => { + await testEditor(BasicEditor, { + contentBefore: '

a[xyz]d

', + stepFunction: async editor => { + await pasteText(editor, 'bc'); + }, + contentAfter: '

abc[]d

', + }); + }); + it('should paste and transform plain text content over a link if all of its contents is selected', async () => { + await testEditor(BasicEditor, { + contentBefore: '

[xyz]

', + stepFunction: async editor => { + await pasteText(editor, 'www.odoo.com'); + }, + contentAfter: '

www.odoo.com[]

', + }); + await testEditor(BasicEditor, { + contentBefore: '

[xyz]

', + stepFunction: async editor => { + await pasteText(editor, 'abc www.odoo.com xyz'); + }, + contentAfter: '

abc www.odoo.com xyz[]

', + }); + const imageUrl = 'https://download.odoocdn.com/icons/website/static/description/icon.png'; + await testEditor(BasicEditor, { + contentBefore: '

ab[http://www.xyz.com]cd

', + 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: `

ab[]cd

`, + }); + await testEditor(BasicEditor, { + contentBefore: '

ab[http://www.xyz.com]cd

', + 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: `

ab${imageUrl}[]cd

`, + }); + }); + it('should paste html content over a link if all of its contents is selected', async () => { + await testEditor(BasicEditor, { + contentBefore: '

[xyz]

', + stepFunction: async editor => { + await pasteHtml(editor, 'odoo.com
google.com'); + }, + contentAfter: '

odoo.com
google.com[]

', + }); + }); }); }); describe('images', () => {