diff --git a/src/commandbar.js b/src/commandbar.js index a704e2f..ba0418d 100644 --- a/src/commandbar.js +++ b/src/commandbar.js @@ -26,6 +26,9 @@ export class CommandBar { this._mainWrapperElement = document.createElement('div'); this._mainWrapperElement.className = 'oe-commandbar-mainWrapper'; this.el.append(this._mainWrapperElement); + this.el.addEventListener('mousedown', event => { + event.stopPropagation(); + }); } destroy() { @@ -117,6 +120,7 @@ export class CommandBar { event => { this._currentValidate(); event.preventDefault(); + event.stopPropagation(); }, true, ); @@ -266,7 +270,7 @@ export class CommandBar { } _resetPosition() { - const position = getRangePosition(this.el); + const position = getRangePosition(this.el, this.options.document); if (!position) { this.hide(); return; diff --git a/src/commands.js b/src/commands.js index ab7f365..1e0227a 100644 --- a/src/commands.js +++ b/src/commands.js @@ -17,6 +17,7 @@ import { isBlock, isBold, isContentTextNode, + isShrunkBlock, isVisible, isVisibleStr, leftDeepFirstPath, @@ -67,12 +68,31 @@ function insert(editor, data, isText = true) { let nodeToInsert; const insertedNodes = [...fakeEl.childNodes]; while ((nodeToInsert = fakeEl.childNodes[0])) { + if (isBlock(nodeToInsert) && !isBlock(startNode)) { + // Split blocks at the edges if inserting new blocks (preventing + //
text
scenarios). + while (startNode.parentElement !== editor.editable && !isBlock(startNode.parentElement)) { + let offset = childNodeIndex(startNode); + if (!insertBefore) { + offset += 1; + } + if (offset) { + const [left, right] = splitElement(startNode.parentElement, offset); + startNode = insertBefore ? right : left; + } else { + startNode = startNode.parentElement; + } + } + } if (insertBefore) { startNode.before(nodeToInsert); insertBefore = false; } else { startNode.after(nodeToInsert); } + if (isShrunkBlock(startNode)) { + startNode.remove(); + } startNode = nodeToInsert; } @@ -395,10 +415,14 @@ export const editorCommands = { const blocks = new Set(); for (const node of getTraversedNodes(editor.editable)) { - const block = closestBlock(node); - if (!['OL', 'UL'].includes(block.tagName)) { - const ublock = block.closest('ol, ul'); - ublock && getListMode(ublock) == mode ? li.add(block) : blocks.add(block); + if (node.nodeType === Node.TEXT_NODE && !isVisibleStr(node)) { + node.remove(); + } else { + const block = closestBlock(node); + if (!['OL', 'UL'].includes(block.tagName)) { + const ublock = block.closest('ol, ul'); + ublock && getListMode(ublock) == mode ? li.add(block) : blocks.add(block); + } } } diff --git a/src/editor.js b/src/editor.js index 10e765e..6421229 100644 --- a/src/editor.js +++ b/src/editor.js @@ -41,6 +41,7 @@ import { getUrlsInfosInString, URL_REGEX, isBold, + unwrapContents, } from './utils/utils.js'; import { editorCommands } from './commands.js'; import { CommandBar } from './commandbar.js'; @@ -58,6 +59,47 @@ const IS_KEYBOARD_EVENT_UNDO = ev => ev.key === 'z' && (ev.ctrlKey || ev.metaKey const IS_KEYBOARD_EVENT_REDO = ev => ev.key === 'y' && (ev.ctrlKey || ev.metaKey); const IS_KEYBOARD_EVENT_BOLD = ev => ev.key === 'b' && (ev.ctrlKey || ev.metaKey); +const CLIPBOARD_BLACKLISTS = { + unwrap: ['.Apple-interchange-newline', 'DIV'], // These elements' children will be unwrapped. + remove: ['META', 'STYLE', 'SCRIPT'], // These elements will be removed along with their children. +}; +const CLIPBOARD_WHITELISTS = { + nodes: [ + // Style + 'P', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'BLOCKQUOTE', 'PRE', + // List + 'UL', 'OL', 'LI', + // Inline style + 'I', 'B', 'U', 'EM', 'STRONG', + // Table + 'TABLE', 'TH', 'TBODY', 'TR', 'TD', + // Miscellaneous + 'IMG', 'BR', 'A', '.fa', + ], + classes: [ + // Media + /^float-/, + 'd-block', + 'mx-auto', + 'img-fluid', + 'img-thumbnail', + 'rounded', + 'rounded-circle', + /^padding-/, + /^shadow/, + // Odoo colors + /^text-o-/, + /^bg-o-/, + // Odoo checklists + 'o_checked', + 'o_checklist', + // Miscellaneous + /^btn/, + /^fa/, + ], + attributes: ['class', 'href', 'src'], +} + function defaultOptions(defaultObject, object) { const newObject = Object.assign({}, defaultObject, object); for (const [key, value] of Object.entries(object)) { @@ -164,6 +206,7 @@ export class OdooEditor extends EventTarget { this.addDomListener(this.editable, 'keydown', this._onKeyDown); this.addDomListener(this.editable, 'input', this._onInput); + this.addDomListener(this.editable, 'beforeinput', this._onBeforeInput); this.addDomListener(this.editable, 'mousedown', this._onMouseDown); this.addDomListener(this.editable, 'mouseup', this._onMouseup); this.addDomListener(this.editable, 'paste', this._onPaste); @@ -399,7 +442,9 @@ export class OdooEditor extends EventTarget { } } } - this.dispatchEvent(new Event('observerApply')); + if (records.length) { + this.dispatchEvent(new Event('observerApply')); + } } filterMutationRecords(records) { // Save the first attribute in a cache to compare only the first @@ -1209,15 +1254,15 @@ export class OdooEditor extends EventTarget { groupName: 'Basic blocks', title: 'Checklist', description: 'Track tasks with a checklist.', - fontawesome: 'fa-tasks', + fontawesome: 'fa-check-square-o', callback: () => { this.execCommand('toggleList', 'CL'); }, }, { groupName: 'Basic blocks', - title: 'Horizontal rule', - description: 'Insert an horizantal rule.', + title: 'Separator', + description: 'Insert an horizontal rule separator.', fontawesome: 'fa-minus', callback: () => { this.execCommand('insertHorizontalRule'); @@ -1241,6 +1286,7 @@ export class OdooEditor extends EventTarget { } this.commandBar = new CommandBar({ editable: this.editable, + document: this.document, _t: this.options._t, onShow: () => { this.commandbarTablePicker.hide(); @@ -1251,6 +1297,11 @@ export class OdooEditor extends EventTarget { for (const element of document.querySelectorAll(this.options.noScrollSelector)) { element.classList.add('oe-noscroll'); } + for (const element of this.document.querySelectorAll( + this.options.noScrollSelector, + )) { + element.classList.add('oe-noscroll'); + } this.observerActive(); }, preValidate: () => { @@ -1264,6 +1315,9 @@ export class OdooEditor extends EventTarget { for (const element of document.querySelectorAll('.oe-noscroll')) { element.classList.remove('oe-noscroll'); } + for (const element of this.document.querySelectorAll('.oe-noscroll')) { + element.classList.remove('oe-noscroll'); + } this.observerActive(); }, commands: [...mainCommands, ...(this.options.commands || [])], @@ -1440,10 +1494,99 @@ export class OdooEditor extends EventTarget { } } + // PASTING / DROPPING + + /** + * Prepare clipboard data (text/html) for safe pasting into the editor. + * + * @private + * @param {string} clipboardData + * @returns {string} + */ + _prepareClipboardData(clipboardData) { + const container = document.createElement('fake-container'); + container.innerHTML = clipboardData; + for (const child of [...container.childNodes]) { + this._cleanForPaste(child) + } + return container.innerHTML; + } + /** + * Clean a node for safely pasting. Cleaning an element involves unwrapping + * its contents if it's an illegal (blacklisted or not whitelisted) element, + * or removing its illegal attributes and classes. + * + * @param {Node} node + */ + _cleanForPaste(node) { + if (!this._isWhitelisted(node) || this._isBlacklisted(node)) { + if (node.matches(CLIPBOARD_BLACKLISTS.remove.join(','))) { + node.remove(); + } else { + // Unwrap the illegal node's contents. + for (const unwrappedNode of unwrapContents(node)) { + this._cleanForPaste(unwrappedNode); + } + } + } else if (node.nodeType !== Node.TEXT_NODE) { + // Remove all illegal attributes and classes from the node, then + // clean its children. + for (const attribute of [...node.attributes]) { + if (!this._isWhitelisted(attribute)) { + node.removeAttribute(attribute.name); + } + } + for (const klass of [...node.classList]) { + if (!this._isWhitelisted(klass)) { + node.classList.remove(klass); + } + } + for (const child of [...node.childNodes]) { + this._cleanForPaste(child); + } + } + } + /** + * Return true if the given attribute, class or node is whitelisted for + * pasting, false otherwise. + * + * @private + * @param {Attr | string | Node} item + * @returns {boolean} + */ + _isWhitelisted(item) { + if (item instanceof Attr) { + return CLIPBOARD_WHITELISTS.attributes.includes(item.name); + } else if (typeof item === 'string') { + return CLIPBOARD_WHITELISTS.classes.some(okClass => ( + okClass instanceof RegExp ? okClass.test(item) : okClass === item + )); + } else { + return item.nodeType === Node.TEXT_NODE || + item.matches(CLIPBOARD_WHITELISTS.nodes.join(',')); + } + } + /** + * Return true if the given node is blacklisted for pasting, false + * otherwise. + * + * @private + * @param {Node} node + * @returns {boolean} + */ + _isBlacklisted(node) { + return node.nodeType !== Node.TEXT_NODE && + node.matches([].concat(...Object.values(CLIPBOARD_BLACKLISTS)).join(',')); + } + //-------------------------------------------------------------------------- // Handlers //-------------------------------------------------------------------------- + _onBeforeInput(ev) { + this._lastBeforeInputType = ev.inputType; + } + /** * If backspace/delete input, rollback the operation and handle the * operation ourself. Needed for mobile, used for desktop for consistency. @@ -1457,21 +1600,28 @@ export class OdooEditor extends EventTarget { const cursor = this._historySteps[this._historySteps.length - 1].cursor; const { focusOffset, focusNode, anchorNode, anchorOffset } = cursor || {}; const wasCollapsed = !cursor || (focusNode === anchorNode && focusOffset === anchorOffset); + + // Sometimes google chrome wrongly triggers an input event with `data` + // being `null` on `deleteContentForward` `insertParagraph`. Luckily, + // chrome provide the proper signal with the event `beforeinput`. + const isChromeDeleteforward = + ev.inputType === 'insertText' && + ev.data === null && + this._lastBeforeInputType === 'deleteContentForward'; + const isChromeInsertParagraph = + ev.inputType === 'insertText' && + ev.data === null && + this._lastBeforeInputType === 'insertParagraph'; if (this.keyboardType === KEYBOARD_TYPES.PHYSICAL || !wasCollapsed) { if (ev.inputType === 'deleteContentBackward') { this.historyRollback(); ev.preventDefault(); this._applyCommand('oDeleteBackward'); - } else if (ev.inputType === 'deleteContentForward') { + } else if (ev.inputType === 'deleteContentForward' || isChromeDeleteforward) { this.historyRollback(); ev.preventDefault(); this._applyCommand('oDeleteForward'); - } else if ( - ev.inputType === 'insertParagraph' || - (ev.inputType === 'insertText' && ev.data === null) - ) { - // Sometimes the browser wrongly triggers an insertText - // input event with null data on enter. + } else if (ev.inputType === 'insertParagraph' || isChromeInsertParagraph) { this.historyRollback(); ev.preventDefault(); if (this._applyCommand('oEnter') === UNBREAKABLE_ROLLBACK_CODE) { @@ -1547,6 +1697,7 @@ export class OdooEditor extends EventTarget { this.execCommand('insertText', '\u00A0 \u00A0\u00A0'); } ev.preventDefault(); + ev.stopPropagation(); } else if (IS_KEYBOARD_EVENT_UNDO(ev)) { // Ctrl-Z ev.preventDefault(); @@ -1802,35 +1953,47 @@ export class OdooEditor extends EventTarget { } /** - * Prevent the pasting of HTML and paste text only instead. + * Handle safe pasting of html or plain text into the editor. */ _onPaste(ev) { ev.preventDefault(); - const pastedText = (ev.originalEvent || ev).clipboardData.getData('text/plain'); - const splitAroundUrl = pastedText.split(URL_REGEX); - const linkAttrs = - Object.entries(this.options.defaultLinkAttributes) - .map(entry => entry.join('="')) - .join('" ') + '" '; - - for (let i = 0; i < splitAroundUrl.length; i++) { - // Even indexes will always be plain text, and odd indexes will always be URL. - if (i % 2) { - const url = /^https?:\/\//gi.test(splitAroundUrl[i]) - ? splitAroundUrl[i] - : 'https://' + splitAroundUrl[i]; - this.execCommand( - 'insertHTML', - `${splitAroundUrl[i]}`, - ); - } else if (splitAroundUrl[i] !== '') { - this.execCommand('insertText', splitAroundUrl[i]); + const clipboardData = ev.clipboardData.getData('text/html'); + if (clipboardData) { + this.execCommand('insertHTML', this._prepareClipboardData(clipboardData)); + } else { + const text = ev.clipboardData.getData('text/plain'); + const splitAroundUrl = text.split(URL_REGEX); + const linkAttrs = + Object.entries(this.options.defaultLinkAttributes) + .map(entry => entry.join('="')) + .join('" ') + '" '; + + for (let i = 0; i < splitAroundUrl.length; i++) { + // Even indexes will always be plain text, and odd indexes will always be URL. + if (i % 2) { + const url = /^https?:\/\//gi.test(splitAroundUrl[i]) + ? splitAroundUrl[i] + : 'https://' + splitAroundUrl[i]; + this.execCommand( + 'insertHTML', + `${splitAroundUrl[i]}`, + ); + } else if (splitAroundUrl[i] !== '') { + const textFragments = splitAroundUrl[i].split('\n'); + let textIndex = 1; + for (const textFragment of textFragments) { + this.execCommand('insertText', textFragment); + if (textIndex < textFragments.length) { + this._applyCommand('oShiftEnter'); + } + textIndex++ + } + } } } } - /** - * Prevent the dropping of HTML and paste text only instead. + * Handle safe dropping of html into the editor. */ _onDrop(ev) { ev.preventDefault(); @@ -1844,21 +2007,21 @@ export class OdooEditor extends EventTarget { ancestor = ancestor.parentNode; } const transferItem = [...(ev.originalEvent || ev).dataTransfer.items].find( - item => item.type === 'text/plain', + item => item.type === 'text/html', ); if (transferItem) { transferItem.getAsString(pastedText => { if (isInEditor && !sel.isCollapsed) { this.deleteRange(sel); } - if (document.caretPositionFromPoint) { + if (this.document.caretPositionFromPoint) { const range = this.document.caretPositionFromPoint(ev.clientX, ev.clientY); setCursor(range.offsetNode, range.offset); - } else if (document.caretRangeFromPoint) { + } else if (this.document.caretRangeFromPoint) { const range = this.document.caretRangeFromPoint(ev.clientX, ev.clientY); setCursor(range.startContainer, range.startOffset); } - insertText(this.document.getSelection(), pastedText); + this.execCommand('insertHTML', this._prepareClipboardData(pastedText)); }); } this.historyStep(); @@ -1867,10 +2030,13 @@ export class OdooEditor extends EventTarget { _bindToolbar() { for (const buttonEl of this.toolbar.querySelectorAll('[data-call]')) { buttonEl.addEventListener('mousedown', ev => { - this.execCommand(buttonEl.dataset.call, buttonEl.dataset.arg1); + const sel = this.document.getSelection() + if (sel.anchorNode && ancestors(sel.anchorNode).includes(this.editable)) { + this.execCommand(buttonEl.dataset.call, buttonEl.dataset.arg1); - ev.preventDefault(); - this._updateToolbar(); + ev.preventDefault(); + this._updateToolbar(); + } }); } } diff --git a/src/tablepicker.js b/src/tablepicker.js index ae7fb41..2e635be 100644 --- a/src/tablepicker.js +++ b/src/tablepicker.js @@ -133,7 +133,7 @@ export class TablePicker extends EventTarget { } }; - const offset = getRangePosition(this.el); + const offset = getRangePosition(this.el, this.options.document); this.el.style.left = `${offset.left}px`; this.el.style.top = `${offset.top}px`; diff --git a/src/utils/utils.js b/src/utils/utils.js index 0db8579..77b659e 100644 --- a/src/utils/utils.js +++ b/src/utils/utils.js @@ -256,6 +256,7 @@ export function closestElement(node, selector) { * Returns a list of all the ancestors nodes of the provided node. * * @param {Node} node + * @param {Node} [editable] include to prevent bubbling up further than the editable. * @returns {HTMLElement[]} */ export function ancestors(node, editable) { @@ -790,6 +791,7 @@ const blockTagNames = [ 'UL', // The following elements are not in the W3C list, for some reason. 'SELECT', + 'OPTION', 'TR', 'TD', 'TBODY', @@ -1254,6 +1256,22 @@ export function insertText(sel, content) { setCursor(...boundariesOut(txt), false); } + +/** + * Remove node from the DOM while preserving their contents if any. + * + * @param {Node} node + * @returns {Node[]} + */ +export function unwrapContents (node) { + const contents = [...node.childNodes]; + for (const child of contents) { + node.parentNode.insertBefore(child, node); + }; + node.parentNode.removeChild(node); + return contents; +} + /** * Add a BR in the given node if its closest ancestor block has nothing to make * it visible, and/or add a zero-width space in the given node if it's an empty @@ -1781,7 +1799,7 @@ export function rgbToHex(rgb = '') { ); } -export function getRangePosition(el, options = {}) { +export function getRangePosition(el, document, options = {}) { const selection = document.getSelection(); if (!selection.isCollapsed || !selection.rangeCount) return; const range = selection.getRangeAt(0);