[FIX] web_editor: properly clean zero-width spaces inserted for formats

When inserting a format with a collapsed selection, we need to insert a
zero-width space. It should not be saved so we need to isolate it and
put the "oe-zws-empty-inline" attribute on its parent so it gets cleaned
up by the sanitizer.

X-original-commit: 4ce274ba39cd8046b9ef4e0b99df66913375f993
Part-of: odoo/odoo#86930
This commit is contained in:
Antoine Guenet
2022-03-22 22:12:55 +01:00
parent f41c3e4a42
commit 6cd42319fa
2 changed files with 84 additions and 34 deletions
@@ -354,6 +354,19 @@ export function toggleFormat(editor, format) {
}
}
if (wasCollapsed) {
const siblings = [...zws.parentElement.childNodes];
if (
selectedTextNodes.includes(siblings[0]) &&
selectedTextNodes.includes(siblings[siblings.length - 1])
) {
zws.parentElement.setAttribute('oe-zws-empty-inline', '');
} else {
const span = document.createElement('span');
span.setAttribute('oe-zws-empty-inline', '');
zws.before(span);
span.append(zws);
}
setSelection(zws, 1);
} else {
setSelection(anchorNode, anchorOffset, focusNode, focusOffset);
@@ -376,6 +389,21 @@ export function toggleFormat(editor, format) {
} else {
el.style[style.name] = style.value;
}
if (zws) {
const siblings = [...zws.parentElement.childNodes];
if (
selectedTextNodes.includes(siblings[0]) &&
selectedTextNodes.includes(siblings[siblings.length - 1])
) {
zws.parentElement.setAttribute('oe-zws-empty-inline', '');
} else {
const span = document.createElement('span');
span.setAttribute('oe-zws-empty-inline', '');
zws.before(span);
span.append(zws);
}
}
}, format, !isAlreadyFormatted);
}
}
@@ -15,8 +15,8 @@ const strikeThrough = async editor => {
};
describe('Format', () => {
const b = content => `<span style="font-weight: bolder;">${content}</span>`;
const notB = (content, weight) => `<span style="font-weight: ${weight || 'normal'};">${content}</span>`;
const b = (content, zws) => `<span${zws === 'first' ? ' oe-zws-empty-inline=""' : ''} style="font-weight: bolder;"${zws === 'last' ? ' oe-zws-empty-inline=""' : ''}>${content}</span>`;
const notB = (content, weight, zws) => `<span${zws === 'first' ? ' oe-zws-empty-inline=""' : ''} style="font-weight: ${weight || 'normal'};"${zws === 'last' ? ' oe-zws-empty-inline=""' : ''}>${content}</span>`;
describe('bold', () => {
it('should make a few characters bold', async () => {
await testEditor(BasicEditor, {
@@ -86,19 +86,21 @@ describe('Format', () => {
await testEditor(BasicEditor, {
contentBefore: '<p>ab[]cd</p>',
stepFunction: bold,
contentAfter: `<p>ab${b(`[]\u200B`)}cd</p>`,
contentAfterEdit: `<p>ab${b(`[]\u200B`, 'first')}cd</p>`,
contentAfter: `<p>ab[]cd</p>`,
});
});
it('should get ready to type in not bold', async () => {
await testEditor(BasicEditor, {
contentBefore: `<p>${b(`ab[]cd`)}</p>`,
stepFunction: bold,
contentAfter: `<p>${b(`ab${notB(`[]\u200B`)}cd`)}</p>`,
contentAfterEdit: `<p>${b(`ab${notB(`[]\u200B`, undefined, 'first')}cd`)}</p>`,
contentAfter: `<p>${b(`ab[]cd`)}</p>`,
});
});
});
const i = content => `<span style="font-style: italic;">${content}</span>`;
const notI = content => `<span style="font-style: normal;">${content}</span>`;
const i = (content, zws) => `<span${zws === 'first' ? ' oe-zws-empty-inline=""' : ''} style="font-style: italic;"${zws === 'last' ? ' oe-zws-empty-inline=""' : ''}>${content}</span>`;
const notI = (content, zws) => `<span${zws === 'first' ? ' oe-zws-empty-inline=""' : ''} style="font-style: normal;"${zws === 'last' ? ' oe-zws-empty-inline=""' : ''}>${content}</span>`;
describe('italic', () => {
it('should make a few characters italic', async () => {
await testEditor(BasicEditor, {
@@ -168,18 +170,20 @@ describe('Format', () => {
await testEditor(BasicEditor, {
contentBefore: `<p>ab[]cd</p>`,
stepFunction: italic,
contentAfter: `<p>ab${i(`[]\u200B`)}cd</p>`,
contentAfterEdit: `<p>ab${i(`[]\u200B`, 'first')}cd</p>`,
contentAfter: `<p>ab[]cd</p>`,
});
});
it('should get ready to type in not italic', async () => {
await testEditor(BasicEditor, {
contentBefore: `<p>${i(`ab[]cd`)}</p>`,
stepFunction: italic,
contentAfter: `<p>${i(`ab${notI(`[]\u200B`)}cd`)}</p>`,
contentAfterEdit: `<p>${i(`ab${notI(`[]\u200B`, 'first')}cd`)}</p>`,
contentAfter: `<p>${i(`ab[]cd`)}</p>`,
});
});
});
const u = content => `<span style="text-decoration-line: underline;">${content}</span>`;
const u = (content, zws) => `<span${zws === 'first' ? ' oe-zws-empty-inline=""' : ''} style="text-decoration-line: underline;"${zws === 'last' ? ' oe-zws-empty-inline=""' : ''}>${content}</span>`;
describe('underline', () => {
it('should make a few characters underline', async () => {
await testEditor(BasicEditor, {
@@ -248,18 +252,20 @@ describe('Format', () => {
await testEditor(BasicEditor, {
contentBefore: `<p>ab[]cd</p>`,
stepFunction: underline,
contentAfter: `<p>ab${u(`[]\u200B`)}cd</p>`,
contentAfterEdit: `<p>ab${u(`[]\u200B`, 'first')}cd</p>`,
contentAfter: `<p>ab[]cd</p>`,
});
});
it('should get ready to type in not underline', async () => {
await testEditor(BasicEditor, {
contentBefore: `<p>${u(`ab[]cd`)}</p>`,
stepFunction: underline,
contentAfter: `<p>${u(`ab`)}\u200B[]${u(`cd`)}</p>`,
contentAfterEdit: `<p>${u(`ab`)}<span oe-zws-empty-inline="">\u200B[]</span>${u(`cd`)}</p>`,
contentAfter: `<p>${u(`ab`)}[]${u(`cd`)}</p>`,
});
});
});
const s = content => `<span style="text-decoration-line: line-through;">${content}</span>`;
const s = (content, zws) => `<span${zws === 'first' ? ' oe-zws-empty-inline=""' : ''} style="text-decoration-line: line-through;"${zws === 'last' ? ' oe-zws-empty-inline=""' : ''}>${content}</span>`;
describe('strikeThrough', () => {
it('should make a few characters strikeThrough', async () => {
await testEditor(BasicEditor, {
@@ -328,14 +334,16 @@ describe('Format', () => {
await testEditor(BasicEditor, {
contentBefore: `<p>ab[]cd</p>`,
stepFunction: strikeThrough,
contentAfter: `<p>ab${s(`[]\u200B`)}cd</p>`,
contentAfterEdit: `<p>ab${s(`[]\u200B`, 'first')}cd</p>`,
contentAfter: `<p>ab[]cd</p>`,
});
});
it('should get ready to type in not strikeThrough', async () => {
it('should get ready to type in not underline', async () => {
await testEditor(BasicEditor, {
contentBefore: `<p>${s(`ab[]cd`)}</p>`,
stepFunction: strikeThrough,
contentAfter: `<p>${s(`ab`)}\u200B[]${s(`cd`)}</p>`,
contentAfterEdit: `<p>${s(`ab`)}<span oe-zws-empty-inline="">\u200B[]</span>${s(`cd`)}</p>`,
contentAfter: `<p>${s(`ab`)}[]${s(`cd`)}</p>`,
});
});
});
@@ -344,32 +352,37 @@ describe('Format', () => {
await testEditor(BasicEditor, {
contentBefore: `<p>ab${u(s(`cd[]ef`))}</p>`,
stepFunction: underline,
contentAfter: `<p>ab${u(s(`cd`))}${s(`\u200b[]`)}${u(s(`ef`))}</p>`,
contentAfterEdit: `<p>ab${u(s(`cd`))}${s(`\u200b[]`, 'last')}${u(s(`ef`))}</p>`,
contentAfter: `<p>ab${u(s(`cd`))}[]${u(s(`ef`))}</p>`,
});
});
it('should restore underline after removing it (collapsed, strikeThrough)', async () => {
await testEditor(BasicEditor, {
contentBefore: `<p>ab${u(s(`cd`))}${s(`\u200b[]`)}${u(s(`ef`))}</p>`,
contentBefore: `<p>ab${u(s(`cd`))}${s(`\u200b[]`, 'first')}${u(s(`ef`))}</p>`,
stepFunction: underline,
contentAfter: `<p>ab${u(s(`cd`))}${s(u(`[]\u200b`))}${u(s(`ef`))}</p>`,
contentAfterEdit: `<p>ab${u(s(`cd`))}${s(u(`[]\u200b`, 'first'), 'first')}${u(s(`ef`))}</p>`,
contentAfter: `<p>ab${u(s(`cd`))}[]${u(s(`ef`))}</p>`,
});
});
it('should remove underline after restoring it after removing it (collapsed, strikeThrough)', async () => {
await testEditor(BasicEditor, {
contentBefore: `<p>ab${u(s(`cd`))}${s(u(`[]\u200b`))}${u(s(`ef`))}</p>`,
contentBefore: `<p>ab${u(s(`cd`))}${s(u(`[]\u200b`, 'first'))}${u(s(`ef`))}</p>`,
stepFunction: underline,
contentAfter: `<p>ab${u(s(`cd`))}${s(`\u200b[]`)}${u(s(`ef`))}</p>`,
contentAfterEdit: `<p>ab${u(s(`cd`))}${s(`\u200b[]`, 'last')}${u(s(`ef`))}</p>`,
contentAfter: `<p>ab${u(s(`cd`))}[]${u(s(`ef`))}</p>`,
});
});
it('should remove underline after restoring it and writing after removing it (collapsed, strikeThrough)', async () => {
await testEditor(BasicEditor, {
contentBefore: `<p>ab${u(s(`cd`))}${s(u(`ghi[]`))}${u(s(`ef`))}</p>`,
stepFunction: underline,
contentAfter: `<p>ab${u(s(`cd`))}${s(u(`ghi`) + `\u200b[]`)}${u(s(`ef`))}</p>`,
contentAfterEdit: `<p>ab${u(s(`cd`))}${s(u(`ghi`) + `<span oe-zws-empty-inline="">\u200b[]</span>`)}${u(s(`ef`))}</p>`,
contentAfter: `<p>ab${u(s(`cd`))}${s(u(`ghi`) + `[]`)}${u(s(`ef`))}</p>`,
});
});
it('should remove underline, write, restore underline, write, remove underline again, write (collapsed, strikeThrough)', async () => {
const uselessSpan = u(''); // TODO: clean
const uselessSpan = content => `<span>${content}</span>`; // TODO: clean
const uselessU = u(''); // TODO: clean
await testEditor(BasicEditor, {
contentBefore: `<p>ab${u(s(`cd[]ef`))}</p>`,
stepFunction: async editor => {
@@ -380,12 +393,12 @@ describe('Format', () => {
await editor.execCommand('underline');
await editor.execCommand('insertText', 'C');
},
contentAfterEdit: `<p>ab${u(s(`cd`))}${s(`A${u(`B`)}C[]${uselessSpan}`)}${u(s(`ef`))}</p>`,
contentAfterEdit: `<p>ab${u(s(`cd`))}${s(`A${u(`B`)}${uselessSpan(`C[]`)}${uselessU}`)}${u(s(`ef`))}</p>`,
});
});
});
describe('underline + italic', () => {
const iAndU = content => `<span style="font-style: italic; text-decoration-line: underline;">${content}</span>`;
const iAndU = (content, zws) => `<span${zws === 'first' ? ' oe-zws-empty-inline=""' : ''} style="font-style: italic; text-decoration-line: underline;"${zws === 'last' ? ' oe-zws-empty-inline=""' : ''}>${content}</span>`;
it('should get ready to write in italic and underline', async () => {
await testEditor(BasicEditor, {
contentBefore: `<p>ab[]cd</p>`,
@@ -393,7 +406,8 @@ describe('Format', () => {
await editor.execCommand('italic');
await editor.execCommand('underline');
},
contentAfter: `<p>ab${iAndU(`[]\u200B`)}cd</p>`,
contentAfterEdit: `<p>ab${iAndU(`[]\u200B`, 'first')}cd</p>`,
contentAfter: `<p>ab[]cd</p>`,
});
});
it('should get ready to write in italic, after changing one\'s mind about underline', async () => {
@@ -404,7 +418,8 @@ describe('Format', () => {
await editor.execCommand('underline');
await editor.execCommand('underline');
},
contentAfter: `<p>ab${i(`\u200B[]`)}cd</p>`,
contentAfterEdit: `<p>ab${i(`\u200B[]`, 'first')}cd</p>`,
contentAfter: `<p>ab[]cd</p>`,
});
await testEditor(BasicEditor, {
contentBefore: `<p>ab[]cd</p>`,
@@ -413,7 +428,8 @@ describe('Format', () => {
await editor.execCommand('italic');
await editor.execCommand('underline');
},
contentAfter: `<p>ab${i(`\u200B[]`)}cd</p>`,
contentAfterEdit: `<p>ab${i(`\u200B[]`, 'first')}cd</p>`,
contentAfter: `<p>ab[]cd</p>`,
});
await testEditor(BasicEditor, {
contentBefore: `<p>ab[]cd</p>`,
@@ -422,39 +438,45 @@ describe('Format', () => {
await editor.execCommand('underline');
await editor.execCommand('italic');
},
contentAfter: `<p>ab${i(`[]\u200B`)}cd</p>`,
contentAfterEdit: `<p>ab${i(`[]\u200B`, 'first')}cd</p>`,
contentAfter: `<p>ab[]cd</p>`,
});
});
it('should get ready to write in italic without underline (underline was first)', async () => {
await testEditor(BasicEditor, {
contentBefore: `<p>ab${u(i(`cd[]ef`))}</p>`,
stepFunction: underline,
contentAfter: `<p>ab${u(i(`cd`))}${i(`\u200b[]`)}${u(i(`ef`))}</p>`,
contentAfterEdit: `<p>ab${u(i(`cd`))}${i(`\u200b[]`, 'last')}${u(i(`ef`))}</p>`,
contentAfter: `<p>ab${u(i(`cd`))}[]${u(i(`ef`))}</p>`,
});
});
it('should restore underline after removing it (collapsed, italic)', async () => {
await testEditor(BasicEditor, {
contentBefore: `<p>ab${u(i(`cd`))}${i(`\u200b[]`)}${u(i(`ef`))}</p>`,
stepFunction: underline,
contentAfter: `<p>ab${u(i(`cd`))}${iAndU(`[]\u200b`)}${u(i(`ef`))}</p>`,
contentAfterEdit: `<p>ab${u(i(`cd`))}${iAndU(`[]\u200b`, 'last')}${u(i(`ef`))}</p>`,
contentAfter: `<p>ab${u(i(`cd`))}[]${u(i(`ef`))}</p>`,
});
});
it('should remove underline after restoring it after removing it (collapsed, italic)', async () => {
await testEditor(BasicEditor, {
contentBefore: `<p>ab${u(i(`cd`))}${i(u(`[]\u200b`))}${u(i(`ef`))}</p>`,
stepFunction: underline,
contentAfter: `<p>ab${u(i(`cd`))}${i(`\u200b[]`)}${u(i(`ef`))}</p>`,
contentAfterEdit: `<p>ab${u(i(`cd`))}${i(`\u200b[]`, 'last')}${u(i(`ef`))}</p>`,
contentAfter: `<p>ab${u(i(`cd`))}[]${u(i(`ef`))}</p>`,
});
});
it('should remove underline after restoring it and writing after removing it (collapsed, italic)', async () => {
await testEditor(BasicEditor, {
contentBefore: `<p>ab${u(i(`cd`))}${i(u(`ghi[]`))}${u(i(`ef`))}</p>`,
stepFunction: underline,
contentAfter: `<p>ab${u(i(`cd`))}${i(u(`ghi`) + `\u200b[]`)}${u(i(`ef`))}</p>`,
contentAfterEdit: `<p>ab${u(i(`cd`))}${i(u(`ghi`) + `<span oe-zws-empty-inline="">\u200b[]</span>`)}${u(i(`ef`))}</p>`,
contentAfter: `<p>ab${u(i(`cd`))}${i(u(`ghi`) + `[]`)}${u(i(`ef`))}</p>`,
});
});
it('should remove underline, write, restore underline, write, remove underline again, write (collapsed, italic)', async () => {
const uselessSpan = u(''); // TODO: clean
const uselessSpan = content => `<span>${content}</span>`;
const uselessU = u(''); // TODO: clean
await testEditor(BasicEditor, {
contentBefore: `<p>ab${u(i(`cd[]ef`))}</p>`,
stepFunction: async editor => {
@@ -465,7 +487,7 @@ describe('Format', () => {
await editor.execCommand('underline');
await editor.execCommand('insertText', 'C');
},
contentAfterEdit: `<p>ab${u(i(`cd`))}${i(`A${u(`B`)}C[]${uselessSpan}`)}${u(i(`ef`))}</p>`,
contentAfter: `<p>ab${u(i(`cd`))}${i(`A${u(`B`)}${uselessSpan(`C[]`)}${uselessU}`)}${u(i(`ef`))}</p>`,
});
});
});