From 6227dee6acc2ecef828611c50b1f3119e14df074 Mon Sep 17 00:00:00 2001 From: Nicolas Bayet Date: Wed, 28 Apr 2021 07:46:31 +0000 Subject: [PATCH] [IMP] web_editor: update odoo-editor lib to commit 62dbf38 closes odoo/odoo#69471 Related: odoo/enterprise#17788 Signed-off-by: David Monjoie (dmo) --- .../static/lib/odoo-editor/odoo-editor.css | 138 +- .../static/lib/odoo-editor/odoo-editor.js | 1114 +++++++++++++++-- 2 files changed, 1147 insertions(+), 105 deletions(-) diff --git a/addons/web_editor/static/lib/odoo-editor/odoo-editor.css b/addons/web_editor/static/lib/odoo-editor/odoo-editor.css index dbaf8c71760..f281cd7194d 100644 --- a/addons/web_editor/static/lib/odoo-editor/odoo-editor.css +++ b/addons/web_editor/static/lib/odoo-editor/odoo-editor.css @@ -1,3 +1,13 @@ +.oe-floating { + box-shadow: 0px 3px 18px rgb(0 0 0 / 23%); + border-radius: 4px; + position: absolute; +} + +.oe-noscroll { + overflow: hidden !important; +} + /* toolbar styling */ .oe-toolbar { @@ -57,27 +67,41 @@ .oe-toolbar .btn.active { background: #555555; } -.oe-toolbar .tablepicker { +.oe-tablepicker-dropdown .oe-tablepicker { margin: -3px 2px -6px 2px; +} +.oe-tablepicker { width: max-content; width: -webkit-max-content; } -.oe-toolbar .tablepicker .tablepicker-row .tablepicker-cell { +.oe-tablepicker-wrapper.oe-floating { + padding: 3px; + z-index: 1051; background: white; - width: 5px; - height: 5px; +} +.oe-tablepicker-row { + line-height: 0; +} +.oe-tablepicker .oe-tablepicker-row .oe-tablepicker-cell { + display: inline-block; + background: #EFEFEE; + width: 19px; + height: 19px; + padding: 0; margin-right: 3px; margin-bottom: 3px; } -.oe-toolbar .tablepicker .tablepicker-row .tablepicker-cell:last-of-type { +.oe-tablepicker .oe-tablepicker-row .oe-tablepicker-cell:last-of-type { margin-right: 0; } -.oe-toolbar .tablepicker .tablepicker-row .tablepicker-cell.active { - background: #ddeafb; - border-color: #c3d9ff; +.oe-tablepicker .oe-tablepicker-row .oe-tablepicker-cell.active { + background: #d3d1cb; } -.oe-toolbar .tablepicker-size { +.oe-tablepicker-size { + text-align: center; margin-top: 7px; +} +.oe-tablepicker-dropdown .oe-tablepicker-size { color: white; } .oe-toolbar .dropdown-toggle { @@ -176,4 +200,100 @@ li.oe-nested { display: block; } +.oe-commandbar-wrapper { + position: absolute; + z-index: 1051; + border: black; + background-color: white; + max-height: 40vh; + box-sizing: border-box; + max-width: 100%; + box-shadow: 0px 3px 18px rgb(0 0 0 / 23%); + border-radius: 4px; + overflow: hidden; + display: flex; +} +.oe-commandbar-mainWrapper { + flex: 1 1 auto; + overflow: auto; + padding: 5px 0; +} +.oe-commandbar-groupName, .oe-commandbar-noResult { + margin: 10px; + color: rgba(0, 0, 0, 0.6); + font-size: 11px; +} +.oe-commandbar-groupName { + text-transform: uppercase; +} +.oe-commandbar-noResult { + font-size: 15px; +} + +.oe-commandbar-commandWrapper { + display: flex; + padding: 3px 10px; + cursor: pointer; +} + +.oe-commandbar-commandWrapper.active { + background: #EFEFEE; +} + +i.oe-commandbar-commandImg { + display: flex; + height: 50px; + width: 50px; + align-items: center; + justify-content: center; + background: white; + color: rgba(0, 0, 0, 0.6); + border: 1px solid rgba(0, 0, 0, 0.1); + border-radius: 4px; + font-size: 25px; +} + +.oe-commandbar-commandTitle { + margin-top: 6px; + font-size: 14px; +} + +.oe-commandbar-commandDescription { + color: rgba(0, 0, 0, 0.5); + font-size: 12px; + margin: 4px 0; +} + +.oe-commandbar-commandRightCol { + margin: 0 10px; +} + +.oe-commandbar-wrapper ::-webkit-scrollbar { + background: transparent; +} +.oe-commandbar-wrapper ::-webkit-scrollbar { + width: 10px; + height: 10px; +} +.oe-commandbar-wrapper ::-webkit-scrollbar-thumb { + background: #D3D1CB; +} +.oe-commandbar-wrapper ::-webkit-scrollbar-track { + background: #EDECE9; +} + +/* Command hints */ + +.oe-hint { + position: relative; + caret-color: rgb(55, 53, 47); +} +.oe-hint:before { + content: attr(placeholder); + position: absolute; + top: 0; + left: 0; + display: block; + color: rgba(55, 53, 47, 0.4); +} diff --git a/addons/web_editor/static/lib/odoo-editor/odoo-editor.js b/addons/web_editor/static/lib/odoo-editor/odoo-editor.js index 6a4903a0112..baf6d429db8 100644 --- a/addons/web_editor/static/lib/odoo-editor/odoo-editor.js +++ b/addons/web_editor/static/lib/odoo-editor/odoo-editor.js @@ -684,7 +684,7 @@ var exportVariable = (function (exports) { let newNode = node.childNodes[offset]; if (newNode) { newNode = getNextVisibleNode(newNode); - if (!newNode || isEmptyBlock(newNode)) break; + if (!newNode || isVisibleEmpty(newNode)) break; found = true; node = newNode; offset = 0; @@ -696,7 +696,7 @@ var exportVariable = (function (exports) { while (node.hasChildNodes()) { let newNode = node.childNodes[offset - 1]; newNode = getNextVisibleNode(newNode); - if (!newNode || isEmptyBlock(newNode)) break; + if (!newNode || isVisibleEmpty(newNode)) break; node = newNode; offset = nodeSize(node); } @@ -799,7 +799,7 @@ var exportVariable = (function (exports) { * @param node */ function isBlock(node) { - if (!(node instanceof Element)) { + if (node.nodeType !== Node.ELEMENT_NODE) { return false; } const tagName = node.nodeName.toUpperCase(); @@ -1729,6 +1729,61 @@ var exportVariable = (function (exports) { ); } + function getRangePosition(el, options = {}) { + const selection = document.getSelection(); + if (!selection.isCollapsed || !selection.rangeCount) return; + const range = selection.getRangeAt(0); + + const marginRight = options.marginRight || 20; + const marginBottom = options.marginBottom || 20; + const marginTop = options.marginTop || 10; + const marginLeft = options.marginLeft || 10; + + let offset; + if (range.endOffset - 1 > 0) { + const clonedRange = range.cloneRange(); + clonedRange.setStart(range.endContainer, range.endOffset - 1); + clonedRange.setEnd(range.endContainer, range.endOffset); + const rect = clonedRange.getBoundingClientRect(); + offset = { height: rect.height, left: rect.left + rect.width, top: rect.top }; + clonedRange.detach(); + } + + if (!offset || offset.heigh === 0) { + const clonedRange = range.cloneRange(); + const shadowCaret = document.createTextNode('|'); + clonedRange.insertNode(shadowCaret); + clonedRange.selectNode(shadowCaret); + const rect = clonedRange.getBoundingClientRect(); + offset = { height: rect.height, left: rect.left, top: rect.top }; + shadowCaret.remove(); + clonedRange.detach(); + } + + const leftMove = Math.max(0, offset.left + el.offsetWidth + marginRight - window.innerWidth); + if (leftMove && offset.left - leftMove > marginLeft) { + offset.left -= leftMove; + } else if (offset.left - leftMove < marginLeft) { + offset.left = marginLeft; + } + + if ( + offset.top - marginTop + offset.height + el.offsetHeight > window.innerHeight && + offset.top - el.offsetHeight - marginBottom > 0 + ) { + offset.top -= el.offsetHeight; + } else { + offset.top += offset.height; + } + + if (offset) { + offset.top += window.scrollY; + offset.left += window.scrollX; + } + + return offset; + } + Text.prototype.oDeleteBackward = function (offset, alreadyMoved = false) { const parentNode = this.parentNode; @@ -2879,9 +2934,9 @@ var exportVariable = (function (exports) { restoreCursor(); }, // Table - insertTable: (editor, { rowCount = 2, colCount = 2 } = {}) => { - const tdsHtml = new Array(colCount).fill('
').join(''); - const trsHtml = new Array(rowCount).fill(`${tdsHtml}`).join(''); + insertTable: (editor, { rowNumber = 2, colNumber = 2 } = {}) => { + const tdsHtml = new Array(colNumber).fill('
').join(''); + const trsHtml = new Array(rowNumber).fill(`${tdsHtml}`).join(''); const tableHtml = `${trsHtml}
`; const sel = editor.document.getSelection(); if (!sel.isCollapsed) { @@ -2934,16 +2989,718 @@ var exportVariable = (function (exports) { siblingRow ? setCursor(...startPos(siblingRow)) : deleteTable(editor, table); }, deleteTable: (editor, table) => deleteTable(editor, table), + insertHorizontalRule(editor) { + const selection = editor.document.getSelection(); + const range = selection.getRangeAt(0); + const element = closestElement( + range.startContainer, + 'P, PRE, H1, H2, H3, H4, H5, H6, BLOCKQUOTE', + ); + + if (element && ancestors(element).includes(editor.editable)) { + element.before(editor.document.createElement('hr')); + } + }, }; + /** + * program: "patienceDiff" algorithm implemented in javascript. + * author: Jonathan Trent + * version: 2.0 + * + * use: patienceDiff( aLines[], bLines[], diffPlusFlag) + * + * where: + * aLines[] contains the original text lines. + * bLines[] contains the new text lines. + * diffPlusFlag if true, returns additional arrays with the subset of lines that were + * either deleted or inserted. These additional arrays are used by patienceDiffPlus. + * + * returns an object with the following properties: + * lines[] with properties of: + * line containing the line of text from aLines or bLines. + * aIndex referencing the index in aLine[]. + * bIndex referencing the index in bLines[]. + * (Note: The line is text from either aLines or bLines, with aIndex and bIndex + * referencing the original index. If aIndex === -1 then the line is new from bLines, + * and if bIndex === -1 then the line is old from aLines.) + * moved is true if the line was moved from elsewhere in aLines[] or bLines[]. + * lineCountDeleted is the number of lines from aLines[] not appearing in bLines[]. + * lineCountInserted is the number of lines from bLines[] not appearing in aLines[]. + * lineCountMoved is the number of lines moved outside of the Longest Common Subsequence. + * + */ + + function patienceDiff(aLines, bLines, diffPlusFlag) { + // + // findUnique finds all unique values in arr[lo..hi], inclusive. This + // function is used in preparation for determining the longest common + // subsequence. Specifically, it first reduces the array range in question + // to unique values. + // + // Returns an ordered Map, with the arr[i] value as the Map key and the + // array index i as the Map value. + // + function findUnique(arr, lo, hi) { + var lineMap = new Map(); + + for (let i = lo; i <= hi; i++) { + let line = arr[i]; + if (lineMap.has(line)) { + lineMap.get(line).count++; + lineMap.get(line).index = i; + } else { + lineMap.set(line, { count: 1, index: i }); + } + } + + lineMap.forEach((val, key, map) => { + if (val.count !== 1) { + map.delete(key); + } else { + map.set(key, val.index); + } + }); + + return lineMap; + } + + // + // uniqueCommon finds all the unique common entries between aArray[aLo..aHi] + // and bArray[bLo..bHi], inclusive. This function uses findUnique to pare + // down the aArray and bArray ranges first, before then walking the comparison + // between the two arrays. + // + // Returns an ordered Map, with the Map key as the common line between aArray + // and bArray, with the Map value as an object containing the array indexes of + // the matching unique lines. + // + function uniqueCommon(aArray, aLo, aHi, bArray, bLo, bHi) { + let ma = findUnique(aArray, aLo, aHi); + let mb = findUnique(bArray, bLo, bHi); + + ma.forEach((val, key, map) => { + if (mb.has(key)) { + map.set(key, { indexA: val, indexB: mb.get(key) }); + } else { + map.delete(key); + } + }); + + return ma; + } + + // + // longestCommonSubsequence takes an ordered Map from the function uniqueCommon + // and determines the Longest Common Subsequence (LCS). + // + // Returns an ordered array of objects containing the array indexes of the + // matching lines for a LCS. + // + function longestCommonSubsequence(abMap) { + var ja = []; + + // First, walk the list creating the jagged array. + abMap.forEach((val, key, map) => { + let i = 0; + while (ja[i] && ja[i][ja[i].length - 1].indexB < val.indexB) { + i++; + } + + if (!ja[i]) { + ja[i] = []; + } + + if (0 < i) { + val.prev = ja[i - 1][ja[i - 1].length - 1]; + } + + ja[i].push(val); + }); + + // Now, pull out the longest common subsequence. + var lcs = []; + if (0 < ja.length) { + let n = ja.length - 1; + var lcs = [ja[n][ja[n].length - 1]]; + while (lcs[lcs.length - 1].prev) { + lcs.push(lcs[lcs.length - 1].prev); + } + } + + return lcs.reverse(); + } + + // "result" is the array used to accumulate the aLines that are deleted, the + // lines that are shared between aLines and bLines, and the bLines that were + // inserted. + let result = []; + let deleted = 0; + let inserted = 0; + + // aMove and bMove will contain the lines that don't match, and will be returned + // for possible searching of lines that moved. + + let aMove = []; + let aMoveIndex = []; + let bMove = []; + let bMoveIndex = []; + + // + // addToResult simply pushes the latest value onto the "result" array. This + // array captures the diff of the line, aIndex, and bIndex from the aLines + // and bLines array. + // + function addToResult(aIndex, bIndex) { + if (bIndex < 0) { + aMove.push(aLines[aIndex]); + aMoveIndex.push(result.length); + deleted++; + } else if (aIndex < 0) { + bMove.push(bLines[bIndex]); + bMoveIndex.push(result.length); + inserted++; + } + + result.push({ + line: 0 <= aIndex ? aLines[aIndex] : bLines[bIndex], + aIndex: aIndex, + bIndex: bIndex, + }); + } + + // + // addSubMatch handles the lines between a pair of entries in the LCS. Thus, + // this function might recursively call recurseLCS to further match the lines + // between aLines and bLines. + // + function addSubMatch(aLo, aHi, bLo, bHi) { + // Match any lines at the beginning of aLines and bLines. + while (aLo <= aHi && bLo <= bHi && aLines[aLo] === bLines[bLo]) { + addToResult(aLo++, bLo++); + } + + // Match any lines at the end of aLines and bLines, but don't place them + // in the "result" array just yet, as the lines between these matches at + // the beginning and the end need to be analyzed first. + let aHiTemp = aHi; + while (aLo <= aHi && bLo <= bHi && aLines[aHi] === bLines[bHi]) { + aHi--; + bHi--; + } + + // Now, check to determine with the remaining lines in the subsequence + // whether there are any unique common lines between aLines and bLines. + // + // If not, add the subsequence to the result (all aLines having been + // deleted, and all bLines having been inserted). + // + // If there are unique common lines between aLines and bLines, then let's + // recursively perform the patience diff on the subsequence. + let uniqueCommonMap = uniqueCommon(aLines, aLo, aHi, bLines, bLo, bHi); + if (uniqueCommonMap.size === 0) { + while (aLo <= aHi) { + addToResult(aLo++, -1); + } + while (bLo <= bHi) { + addToResult(-1, bLo++); + } + } else { + recurseLCS(aLo, aHi, bLo, bHi, uniqueCommonMap); + } + + // Finally, let's add the matches at the end to the result. + while (aHi < aHiTemp) { + addToResult(++aHi, ++bHi); + } + } + + // + // recurseLCS finds the longest common subsequence (LCS) between the arrays + // aLines[aLo..aHi] and bLines[bLo..bHi] inclusive. Then for each subsequence + // recursively performs another LCS search (via addSubMatch), until there are + // none found, at which point the subsequence is dumped to the result. + // + function recurseLCS(aLo, aHi, bLo, bHi, uniqueCommonMap) { + var x = longestCommonSubsequence( + uniqueCommonMap || uniqueCommon(aLines, aLo, aHi, bLines, bLo, bHi), + ); + if (x.length === 0) { + addSubMatch(aLo, aHi, bLo, bHi); + } else { + if (aLo < x[0].indexA || bLo < x[0].indexB) { + addSubMatch(aLo, x[0].indexA - 1, bLo, x[0].indexB - 1); + } + + let i; + for (i = 0; i < x.length - 1; i++) { + addSubMatch(x[i].indexA, x[i + 1].indexA - 1, x[i].indexB, x[i + 1].indexB - 1); + } + + if (x[i].indexA <= aHi || x[i].indexB <= bHi) { + addSubMatch(x[i].indexA, aHi, x[i].indexB, bHi); + } + } + } + + recurseLCS(0, aLines.length - 1, 0, bLines.length - 1); + + if (diffPlusFlag) { + return { + lines: result, + lineCountDeleted: deleted, + lineCountInserted: inserted, + lineCountMoved: 0, + aMove: aMove, + aMoveIndex: aMoveIndex, + bMove: bMove, + bMoveIndex: bMoveIndex, + }; + } + + return { + lines: result, + lineCountDeleted: deleted, + lineCountInserted: inserted, + lineCountMoved: 0, + }; + } + + /** + * Make `num` cycle from 0 to `max`. + */ + function cycle(num, max) { + const y = max + 1; + return ((num % y) + y) % y; + } + + class CommandBar { + constructor(options = {}) { + this.options = options; + this.options.width = this.options.width || 340; + if (!this.options._t) this.options._t = string => string; + + this.el = document.createElement('div'); + this.el.className = 'oe-commandbar-wrapper'; + this.el.style.display = 'none'; + this.el.style.width = `${this.options.width}px`; + document.body.append(this.el); + + this.options.editable.addEventListener('keydown', this.onKeydown.bind(this), true); + + this._mainWrapperElement = document.createElement('div'); + this._mainWrapperElement.className = 'oe-commandbar-mainWrapper'; + this.el.append(this._mainWrapperElement); + } + + destroy() { + this.el.remove(); + } + + render(commands) { + this._mainWrapperElement.innerHTML = ''; + clearTimeout(this._renderingTimeout); + this._hoverActive = false; + + if (commands.length === 0) { + const groupWrapperEl = document.createElement('div'); + groupWrapperEl.className = 'oe-commandbar-groupWrapper'; + const groupNameEl = document.createElement('div'); + groupNameEl.className = 'oe-commandbar-noResult'; + groupWrapperEl.append(groupNameEl); + this._mainWrapperElement.append(groupWrapperEl); + groupNameEl.innerText = this.options._t('No results'); + return; + } + + this._currentSelectedCommand = + commands.find(c => c === this._currentSelectedCommand) || commands[0]; + const groups = {}; + for (const command of commands) { + groups[command.groupName] = groups[command.groupName] || []; + groups[command.groupName].push(command); + } + for (const [groupName, commands] of Object.entries(groups)) { + const groupWrapperEl = document.createElement('div'); + groupWrapperEl.className = 'oe-commandbar-groupWrapper'; + const groupNameEl = document.createElement('div'); + groupNameEl.className = 'oe-commandbar-groupName'; + groupWrapperEl.append(groupNameEl); + this._mainWrapperElement.append(groupWrapperEl); + groupNameEl.innerText = groupName; + for (const command of commands) { + const commandElWrapper = document.createElement('div'); + commandElWrapper.className = 'oe-commandbar-commandWrapper'; + if (this._currentSelectedCommand === command) { + commandElWrapper.classList.add('active'); + // use setTimeout in order to avoid to call it upon the + // first rendering. + this._renderingTimeout = setTimeout(() => { + commandElWrapper.scrollIntoView({ + block: 'nearest', + inline: 'nearest', + }); + }); + } + + commandElWrapper.innerHTML = ` +
+ +
+
+
+
+
+
+
+ `; + const commandImgEl = commandElWrapper.querySelector('.oe-commandbar-commandImg'); + const commandTitleEl = commandElWrapper.querySelector( + '.oe-commandbar-commandTitle', + ); + const commandDescriptionEl = commandElWrapper.querySelector( + '.oe-commandbar-commandDescription', + ); + commandTitleEl.innerText = command.title; + commandDescriptionEl.innerText = command.description; + commandImgEl.classList.add(command.fontawesome); + groupWrapperEl.append(commandElWrapper); + + const commandElWrapperMouseMove = () => { + if (!this._hoverActive || commandElWrapper.classList.contains('active')) { + return; + } + this.el + .querySelector('.oe-commandbar-commandWrapper.active') + .classList.remove('active'); + this._currentSelectedCommand = command; + commandElWrapper.classList.add('active'); + }; + commandElWrapper.addEventListener('mousemove', commandElWrapperMouseMove); + commandElWrapper.addEventListener( + 'mousedown', + event => { + this._currentValidate(); + event.preventDefault(); + }, + true, + ); + } + } + } + + onKeydown(event) { + const selection = document.getSelection(); + if (!selection.isCollapsed || !selection.rangeCount) return; + + if (event.key === '/' && !this._active) { + this.options.onActivate && this.options.onActivate(); + + const showOnceOnKeyup = () => { + this.show(); + event.target.removeEventListener('keyup', showOnceOnKeyup, true); + initialTarget = event.target; + oldValue = event.target.innerText; + }; + event.target.addEventListener('keyup', showOnceOnKeyup, true); + this._active = true; + this.render(this.options.commands); + this._resetPosition(); + + let initialTarget; + let oldValue; + + const keyup = event => { + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + event.preventDefault(); + return; + } + if (!initialTarget) return; + const diff = patienceDiff( + oldValue.split(''), + initialTarget.innerText.split(''), + true, + ); + this._lastText = diff.bMove.join('').trim(); + + if (this._lastText.match(/\s/)) { + this._stop(); + return; + } + const term = this._lastText; + + this._currentFilteredCommands = this._filter(term); + this.render(this._currentFilteredCommands); + this._resetPosition(); + }; + const keydown = e => { + if (e.key === 'Enter') { + e.stopImmediatePropagation(); + this._currentValidate(); + e.preventDefault(); + } else if (e.key === 'Escape') { + e.stopImmediatePropagation(); + this._stop(); + e.preventDefault(); + } else if (e.key === 'Backspace' && !this._lastText) { + this._stop(); + } else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + e.preventDefault(); + e.stopImmediatePropagation(); + + const index = this._currentFilteredCommands.findIndex( + c => c === this._currentSelectedCommand, + ); + if (!this._currentFilteredCommands.length || index === -1) { + this._currentSelectedCommand = undefined; + } else { + const n = e.key === 'ArrowDown' ? 1 : -1; + const newIndex = cycle(index + n, this._currentFilteredCommands.length - 1); + this._currentSelectedCommand = this._currentFilteredCommands[newIndex]; + } + e.preventDefault(); + this.render(this._currentFilteredCommands); + } + }; + const mousemove = () => { + this._hoverActive = true; + }; + + this._stop = () => { + this._active = false; + this.hide(); + this._currentSelectedCommand = undefined; + + document.removeEventListener('mousedown', this._stop); + document.removeEventListener('keyup', keyup); + document.removeEventListener('keydown', keydown, true); + document.removeEventListener('mousemove', mousemove); + + this.options.onStop && this.options.onStop(); + }; + this._currentValidate = () => { + const command = this._currentFilteredCommands.find( + c => c === this._currentSelectedCommand, + ); + if (command) { + this.options.preValidate && this.options.preValidate(); + command.callback(); + this.options.postValidate && this.options.postValidate(); + } + this._stop(); + }; + document.addEventListener('mousedown', this._stop); + document.addEventListener('keyup', keyup); + document.addEventListener('keydown', keydown, true); + document.addEventListener('mousemove', mousemove); + } + } + + show() { + this.options.onShow && this.options.onShow(); + this.el.style.display = 'flex'; + this._resetPosition(); + } + + hide() { + this.el.style.display = 'none'; + if (this._active) this._stop(); + } + + // ------------------------------------------------------------------------- + // private + // ------------------------------------------------------------------------- + + _filter(term) { + let commands = this.options.commands; + term = term.toLowerCase(); + term = term.replaceAll(/\s/g, '\\s'); + const regex = new RegExp( + term + .split('') + .map(c => c.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&')) + .join('.*'), + ); + if (term.length) { + commands = this.options.commands.filter(command => { + const commandText = (command.groupName + ' ' + command.title).toLowerCase(); + return commandText.match(regex); + }); + } + return commands; + } + + _resetPosition() { + const position = getRangePosition(this.el); + if (!position) { + this.hide(); + return; + } + const { left, top } = position; + + this.el.style.left = `${left}px`; + this.el.style.top = `${top}px`; + } + } + + class TablePicker extends EventTarget { + constructor(options = {}) { + super(); + this.options = options; + this.options.minRowCount = this.options.minRowCount || 3; + this.options.minColCount = this.options.minColCount || 3; + + this.rowNumber = this.options.minRowCount; + this.colNumber = this.options.minColCount; + + this.tablePickerWrapper = document.createElement('div'); + this.tablePickerWrapper.classList.add('oe-tablepicker-wrapper'); + this.tablePickerWrapper.innerHTML = ` +
+
+ `; + + if (this.options.floating) { + this.tablePickerWrapper.style.position = 'absolute'; + this.tablePickerWrapper.classList.add('oe-floating'); + } + + this.tablePickerElement = this.tablePickerWrapper.querySelector('.oe-tablepicker'); + this.tablePickerSizeViewElement = this.tablePickerWrapper.querySelector( + '.oe-tablepicker-size', + ); + + this.el = this.tablePickerWrapper; + + this.hide(); + } + + render() { + this.tablePickerElement.innerHTML = ''; + + const colCount = Math.max(this.colNumber, this.options.minRowCount); + const rowCount = Math.max(this.rowNumber, this.options.minRowCount); + const extraCol = 1; + const extraRow = 1; + + for (let rowNumber = 1; rowNumber <= rowCount + extraRow; rowNumber++) { + const rowElement = this.options.document.createElement('div'); + rowElement.classList.add('oe-tablepicker-row'); + this.tablePickerElement.appendChild(rowElement); + for (let colNumber = 1; colNumber <= colCount + extraCol; colNumber++) { + const cell = this.options.document.createElement('div'); + cell.classList.add('oe-tablepicker-cell', 'btn'); + rowElement.appendChild(cell); + + if (rowNumber <= this.rowNumber && colNumber <= this.colNumber) { + cell.classList.add('active'); + } + + const bindMouseMove = () => { + cell.addEventListener('mouseover', () => { + if (this.colNumber !== colNumber || this.rowNumber != rowNumber) { + this.colNumber = colNumber; + this.rowNumber = rowNumber; + this.render(); + } + }); + this.options.document.removeEventListener('mousemove', bindMouseMove); + }; + this.options.document.addEventListener('mousemove', bindMouseMove); + cell.addEventListener('mousedown', this.selectCell.bind(this)); + } + } + + this.tablePickerSizeViewElement.textContent = `${this.colNumber}x${this.rowNumber}`; + } + + show() { + this.reset(); + this.el.style.display = 'block'; + if (this.options.floating) { + this._showFloating(); + } + } + + hide() { + this.el.style.display = 'none'; + } + + reset() { + this.rowNumber = this.options.minRowCount; + this.colNumber = this.options.minColCount; + this.render(); + } + + selectCell() { + this.dispatchEvent( + new CustomEvent('cell-selected', { + detail: { colNumber: this.colNumber, rowNumber: this.rowNumber }, + }), + ); + } + + _showFloating() { + const keydown = e => { + const actions = { + ArrowRight: { + colNumber: this.colNumber + 1, + rowNumber: this.rowNumber, + }, + ArrowLeft: { + colNumber: this.colNumber - 1 || 1, + rowNumber: this.rowNumber, + }, + ArrowUp: { + colNumber: this.colNumber, + rowNumber: this.rowNumber - 1 || 1, + }, + ArrowDown: { + colNumber: this.colNumber, + rowNumber: this.rowNumber + 1, + }, + }; + const action = actions[e.key]; + if (action) { + this.rowNumber = action.rowNumber || this.rowNumber; + this.colNumber = action.colNumber || this.colNumber; + this.render(); + + e.preventDefault(); + } else if (e.key === 'Enter') { + this.selectCell(); + e.preventDefault(); + } else if (e.key === 'Escape') { + stop(); + e.preventDefault(); + } + }; + + const offset = getRangePosition(this.el); + this.el.style.left = `${offset.left}px`; + this.el.style.top = `${offset.top}px`; + + const stop = () => { + this.hide(); + this.options.document.removeEventListener('mousedown', stop); + this.removeEventListener('cell-selected', stop); + this.options.document.removeEventListener('keydown', keydown, true); + }; + + // Allow the mousedown that activate this command callback to release before adding the listener. + setTimeout(() => { + this.options.document.addEventListener('mousedown', stop); + }); + this.options.document.addEventListener('keydown', keydown, true); + this.addEventListener('cell-selected', stop); + } + } + const UNBREAKABLE_ROLLBACK_CODE = 'UNBREAKABLE'; const UNREMOVABLE_ROLLBACK_CODE = 'UNREMOVABLE'; const BACKSPACE_ONLY_COMMANDS = ['oDeleteBackward', 'oDeleteForward']; const BACKSPACE_FIRST_COMMANDS = BACKSPACE_ONLY_COMMANDS.concat(['oEnter', 'oShiftEnter']); - const TABLEPICKER_ROW_COUNT = 3; - const TABLEPICKER_COL_COUNT = 3; - const KEYBOARD_TYPES = { VIRTUAL: 'VIRTUAL', PHYSICAL: 'PHYSICAL', UNKNOWN: 'UKNOWN' }; const isUndo = ev => ev.key === 'z' && (ev.ctrlKey || ev.metaKey); @@ -2972,6 +3729,7 @@ var exportVariable = (function (exports) { toSanitize: true, isRootEditable: true, getContentEditableAreas: () => [], + _t: string => string, }, options, ); @@ -3034,6 +3792,16 @@ var exportVariable = (function (exports) { this.idSet(editable); + this._createCommandBar(); + + this.toolbarTablePicker = new TablePicker({ document: this.document }); + this.toolbarTablePicker.addEventListener('cell-selected', ev => { + this.execCommand('insertTable', { + rowNumber: ev.detail.rowNumber, + colNumber: ev.detail.colNumber, + }); + }); + // ----------- // Bind events // ----------- @@ -3048,6 +3816,7 @@ var exportVariable = (function (exports) { this.addDomListener(this.editable, 'drop', this._onDrop); this.addDomListener(this.document, 'selectionchange', this._onSelectionChange); + this.addDomListener(this.document, 'selectionchange', this._handleCommandHint); this.addDomListener(this.document, 'keydown', this._onDocumentKeydown); this.addDomListener(this.document, 'keyup', this._onDocumentKeyup); @@ -3061,13 +3830,14 @@ var exportVariable = (function (exports) { // Ensure anchors in the toolbar don't trigger a hash change. const toolbarAnchors = this.toolbar.querySelectorAll('a'); toolbarAnchors.forEach(a => a.addEventListener('click', e => e.preventDefault())); - this.tablePicker = this.toolbar.querySelector('.tablepicker'); - if (this.tablePicker) { - this.tablePickerSizeView = this.toolbar.querySelector('.tablepicker-size'); - this.toolbar - .querySelector('#tableDropdownButton') - .addEventListener('click', this._initTablePicker.bind(this)); - } + const tablepickerDropdown = this.toolbar.querySelector('.oe-tablepicker-dropdown'); + tablepickerDropdown && tablepickerDropdown.append(this.toolbarTablePicker.el); + this.toolbarTablePicker.show(); + const tableDropdownButton = this.toolbar.querySelector('#tableDropdownButton'); + tableDropdownButton && + tableDropdownButton.addEventListener('click', () => { + this.toolbarTablePicker.reset(); + }); for (const colorLabel of this.toolbar.querySelectorAll('label')) { colorLabel.addEventListener('mousedown', ev => { // Hack to prevent loss of focus (done by preventDefault) while still opening @@ -3097,6 +3867,8 @@ var exportVariable = (function (exports) { destroy() { this.observerUnactive(); this._removeDomListener(); + this.commandBar.destroy(); + this.commandbarTablePicker.el.remove(); } sanitize() { @@ -3877,7 +4649,11 @@ var exportVariable = (function (exports) { } } } - + _removeContenteditableLinks() { + for (const node of this.editable.querySelectorAll('a[contenteditable]')) { + node.removeAttribute('contenteditable'); + } + } _activateContenteditable() { this.editable.setAttribute('contenteditable', this.options.isRootEditable); @@ -3957,6 +4733,164 @@ var exportVariable = (function (exports) { return canRedo ? pos : -1; } + // COMMAND BAR + // =========== + + _createCommandBar() { + this.options.noScrollSelector = this.options.noScrollSelector || 'body'; + + const revertHistoryBeforeCommandbar = () => { + let stepIndex = this._historySteps.length - 1; + while (stepIndex >= this._beforeCommandbarStepIndex) { + const stepState = this._historyStepsStates.get(stepIndex); + if (stepState !== 2) { + this.historyRevert(this._historySteps[stepIndex]); + } + this._historyStepsStates.set(stepIndex, 2); + stepIndex--; + } + this._historyStepsStates.set(this._historySteps.length - 1, 1); + this.historyStep(true); + setTimeout(() => { + this.editable.focus(); + getDeepRange(this.editable, { select: true }); + }); + }; + + this.commandbarTablePicker = new TablePicker({ + document: this.document, + floating: true, + }); + + document.body.appendChild(this.commandbarTablePicker.el); + + this.commandbarTablePicker.addEventListener('cell-selected', ev => { + this.execCommand('insertTable', { + rowNumber: ev.detail.rowNumber, + colNumber: ev.detail.colNumber, + }); + }); + + const mainCommands = [ + { + groupName: 'Basic blocks', + title: 'Heading 1', + description: 'Big section heading.', + fontawesome: 'fa-header', + callback: () => { + this.execCommand('setTag', 'H1'); + }, + }, + { + groupName: 'Basic blocks', + title: 'Heading 2', + description: 'Medium section heading.', + fontawesome: 'fa-header', + callback: () => { + this.execCommand('setTag', 'H2'); + }, + }, + { + groupName: 'Basic blocks', + title: 'Heading 3', + description: 'Small section heading.', + fontawesome: 'fa-header', + callback: () => { + this.execCommand('setTag', 'H3'); + }, + }, + { + groupName: 'Basic blocks', + title: 'Text', + description: 'Paragraph block.', + fontawesome: 'fa-paragraph', + callback: () => { + this.execCommand('setTag', 'P'); + }, + }, + { + groupName: 'Basic blocks', + title: 'Bulleted list', + description: 'Create a simple bulleted list.', + fontawesome: 'fa-list-ul', + callback: () => { + this.execCommand('toggleList', 'UL'); + }, + }, + { + groupName: 'Basic blocks', + title: 'Numbered list', + description: 'Create a list with numbering.', + fontawesome: 'fa-list-ol', + callback: () => { + this.execCommand('toggleList', 'OL'); + }, + }, + { + groupName: 'Basic blocks', + title: 'Checklist', + description: 'Track tasks with a checklist.', + fontawesome: 'fa-tasks', + callback: () => { + this.execCommand('toggleList', 'CL'); + }, + }, + { + groupName: 'Basic blocks', + title: 'Horizontal rule', + description: 'Insert an horizantal rule.', + fontawesome: 'fa-minus', + callback: () => { + this.execCommand('insertHorizontalRule'); + }, + }, + { + groupName: 'Basic blocks', + title: 'Table', + description: 'Insert a table.', + fontawesome: 'fa-table', + callback: () => { + this.commandbarTablePicker.show(); + }, + }, + ]; + // Translate the command title and description if a translate function + // is provided. + for (const command of mainCommands) { + command.title = this.options._t(command.title); + command.description = this.options._t(command.description); + } + this.commandBar = new CommandBar({ + editable: this.editable, + _t: this.options._t, + onShow: () => { + this.commandbarTablePicker.hide(); + }, + onActivate: () => { + this._beforeCommandbarStepIndex = this._historySteps.length - 1; + this.observerUnactive(); + for (const element of document.querySelectorAll(this.options.noScrollSelector)) { + element.classList.add('oe-noscroll'); + } + this.observerActive(); + }, + preValidate: () => { + revertHistoryBeforeCommandbar(); + }, + postValidate: () => { + this.historyStep(true); + }, + onStop: () => { + this.observerUnactive(); + for (const element of document.querySelectorAll('.oe-noscroll')) { + element.classList.remove('oe-noscroll'); + } + this.observerActive(); + }, + commands: [...mainCommands, ...(this.options.commands || [])], + }); + } + // TOOLBAR // ======= @@ -4281,6 +5215,70 @@ var exportVariable = (function (exports) { } } + clean() { + this.observerUnactive(); + for (const hint of document.querySelectorAll('.oe-hint')) { + hint.classList.remove('oe-hint', 'oe-command-temporary-hint'); + hint.removeAttribute('placeholder'); + } + this.observerActive(); + } + /** + * Handle the hint preview for the commandbar. + * @private + */ + _handleCommandHint() { + const selectors = { + BLOCKQUOTE: 'Empty quote', + H1: 'Heading 1', + H2: 'Heading 2', + H3: 'Heading 3', + H4: 'Heading 4', + H5: 'Heading 5', + H6: 'Heading 6', + 'UL LI': 'List', + 'OL LI': 'List', + 'CL LI': 'To-do', + }; + + for (const hint of document.querySelectorAll('.oe-hint')) { + if (hint.classList.contains('oe-command-temporary-hint') || !isEmptyBlock(hint)) { + this.observerUnactive(); + hint.classList.remove('oe-hint', 'oe-command-temporary-hint'); + hint.removeAttribute('placeholder'); + this.observerActive(); + } + } + + for (const [selector, text] of Object.entries(selectors)) { + for (const el of this.editable.querySelectorAll(selector)) { + this._makeHint(el, text); + } + } + + const selection = document.getSelection(); + if (!selection.isCollapsed || !selection.rangeCount) return; + + const block = closestElement(selection.anchorNode, 'P, DIV'); + this._makeHint(block, 'Type "/" for commands', true); + } + _makeHint(block, text, temporary = false) { + const content = block && block.innerHTML.trim(); + if ( + block && + (content === '' || content === '
') && + ancestors(block, this.editable).includes(this.editable) + ) { + this.observerUnactive(); + block.setAttribute('placeholder', text); + block.classList.add('oe-hint'); + if (temporary) { + block.classList.add('oe-command-temporary-hint'); + } + this.observerActive(); + } + } + _onMouseup(ev) { this._currentMouseState = ev.type; @@ -4298,10 +5296,12 @@ var exportVariable = (function (exports) { this.automaticStepSkipStack(); const link = closestElement(ev.target, 'a'); if (link && !link.querySelector('div') && !closestElement(ev.target, '.o_not_editable')) { + this._removeContenteditableLinks(); const editableChildren = link.querySelectorAll('[contenteditable=true]'); this._stopContenteditable(); [...editableChildren, link].forEach(node => node.setAttribute('contenteditable', true)); } else { + this._removeContenteditableLinks(); this._activateContenteditable(); } @@ -4407,85 +5407,6 @@ var exportVariable = (function (exports) { }); } } - _initTablePicker() { - for (const child of [...this.tablePicker.childNodes]) { - child.remove(); - } - this.tablePicker.dataset.rowCount = 0; - this.tablePicker.dataset.colCount = 0; - for (let rowIndex = 0; rowIndex < TABLEPICKER_ROW_COUNT; rowIndex++) { - this._addTablePickerRow(); - } - for (let colIndex = 0; colIndex < TABLEPICKER_COL_COUNT; colIndex++) { - this._addTablePickerColumn(); - } - this.tablePicker.querySelector('.tablepicker-cell').classList.toggle('active', true); - this.tablePickerSizeView.textContent = '1x1'; - } - _addTablePickerRow() { - const row = this.document.createElement('div'); - row.classList.add('tablepicker-row'); - row.dataset.rowId = this.tablePicker.querySelectorAll('.tablepicker-row').length + 1; - this.tablePicker.appendChild(row); - this.tablePicker.dataset.rowCount = +this.tablePicker.dataset.rowCount + 1; - for (let i = 0; i < +this.tablePicker.dataset.colCount; i++) { - this._addTablePickerCell(row); - } - return row; - } - _addTablePickerColumn() { - for (const row of this.tablePicker.querySelectorAll('.tablepicker-row')) { - this._addTablePickerCell(row); - } - this.tablePicker.dataset.colCount = +this.tablePicker.dataset.colCount + 1; - } - _addTablePickerCell(row) { - const rowId = +row.dataset.rowId; - const colId = row.querySelectorAll('.tablepicker-cell').length + 1; - const cell = this.document.createElement('div'); - cell.classList.add('tablepicker-cell', 'btn'); - cell.dataset.rowId = rowId; - cell.dataset.colId = colId; - row.appendChild(cell); - cell.addEventListener('mouseover', () => this._onHoverTablePickerCell(rowId, colId)); - } - _onHoverTablePickerCell(targetRowId, targetColId) { - // Hightlight the active cells, remove highlight of the others. - for (const cell of this.tablePicker.querySelectorAll('.tablepicker-cell')) { - const [rowId, colId] = [+cell.dataset.rowId, +cell.dataset.colId]; - const isActive = rowId <= targetRowId && colId <= targetColId; - cell.classList.toggle('active', isActive); - } - this.tablePickerSizeView.textContent = `${targetColId}x${targetRowId}`; - - // Add/remove rows to expand/shrink the tablepicker. - if (targetRowId >= +this.tablePicker.dataset.rowCount) { - this._addTablePickerRow(); - } else if (+this.tablePicker.dataset.rowCount > TABLEPICKER_ROW_COUNT) { - for (const row of this.tablePicker.querySelectorAll('.tablepicker-row')) { - const rowId = +row.dataset.rowId; - if (rowId >= TABLEPICKER_ROW_COUNT && rowId > targetRowId + 1) { - row.remove(); - this.tablePicker.dataset.rowCount = +this.tablePicker.dataset.rowCount - 1; - } - } - } - // Add/remove cols to expand/shrink the tablepicker. - const colCount = +this.tablePicker.dataset.colCount; - if (targetColId >= colCount) { - this._addTablePickerColumn(); - } else if (colCount > TABLEPICKER_COL_COUNT) { - const removedColIds = new Set(); - for (const cell of this.tablePicker.querySelectorAll('.tablepicker-cell')) { - const colId = +cell.dataset.colId; - if (colId >= TABLEPICKER_COL_COUNT && colId > targetColId + 1) { - cell.remove(); - removedColIds.add(colId); - } - } - this.tablePicker.dataset.colCount = colCount - removedColIds.size; - } - } _onTabulationInTable(ev) { const sel = this.document.getSelection(); const closestTable = closestElement(sel.anchorNode, 'table'); @@ -4610,6 +5531,7 @@ var exportVariable = (function (exports) { exports.getListMode = getListMode; exports.getNormalizedCursorPosition = getNormalizedCursorPosition; exports.getOuid = getOuid; + exports.getRangePosition = getRangePosition; exports.getSelectedNodes = getSelectedNodes; exports.getState = getState; exports.getTraversedNodes = getTraversedNodes;