[REF] web_editor: refactor the Powerbox

This refactors the Powerbox for maintainability and efficiency, and
removes unused code from it.

task-2901697

Part-of: odoo/odoo#96287
This commit is contained in:
Antoine Guenet
2022-08-08 14:17:00 +02:00
parent 9584f80a48
commit ef1fe4a700
3 changed files with 464 additions and 444 deletions
@@ -307,7 +307,7 @@ export class OdooEditor extends EventTarget {
this._pluginCall('sanitizeElement', [editable]);
this._createCommandBar();
this._createPowerbox();
this.toolbarTablePicker = new TablePicker({ document: this.document });
this.toolbarTablePicker.addEventListener('cell-selected', ev => {
@@ -401,8 +401,8 @@ export class OdooEditor extends EventTarget {
destroy() {
this.observerUnactive();
this._removeDomListener();
this.commandBar.destroy();
this.commandbarTablePicker.el.remove();
this.powerbox.destroy();
this.powerboxTablePicker.el.remove();
this._collabSelectionsContainer.remove();
this._resizeObserver.disconnect();
clearInterval(this._snapshotInterval);
@@ -1781,15 +1781,15 @@ export class OdooEditor extends EventTarget {
// COMMAND BAR
// ===========
_createCommandBar() {
this.commandbarTablePicker = new TablePicker({
_createPowerbox() {
this.powerboxTablePicker = new TablePicker({
document: this.document,
floating: true,
});
document.body.appendChild(this.commandbarTablePicker.el);
document.body.appendChild(this.powerboxTablePicker.el);
this.commandbarTablePicker.addEventListener('cell-selected', ev => {
this.powerboxTablePicker.addEventListener('cell-selected', ev => {
this.execCommand('insertTable', {
rowNumber: ev.detail.rowNumber,
colNumber: ev.detail.colNumber,
@@ -1875,7 +1875,7 @@ export class OdooEditor extends EventTarget {
description: this.options._t('Insert a table.'),
fontawesome: 'fa-table',
callback: () => {
this.commandbarTablePicker.show();
this.powerboxTablePicker.show();
},
},
{
@@ -1912,30 +1912,32 @@ export class OdooEditor extends EventTarget {
},
},
];
this.commandBar = new Powerbox({
let beforeStepIndex;
this.powerbox = new Powerbox({
editable: this.editable,
document: this.document,
getContextFromParentRect: this.options.getContextFromParentRect,
commandFilters: this.options.powerboxFilters,
_t: this.options._t,
onShow: () => {
this.commandbarTablePicker.hide();
this.powerboxTablePicker.hide();
},
shouldActivate: () => !!this.options.getPowerboxElement(),
onActivate: () => {
this._beforeCommandbarStepIndex = this._historySteps.length - 1;
onOpen: () => {
// Undo input '/'.
beforeStepIndex = this._historySteps.length - 2;
},
preValidate: () => {
this._historyRevertUntil(this._beforeCommandbarStepIndex);
this.historyStep(true);
this._historyStepsStates.set(peek(this._historySteps).id, 'consumed');
setTimeout(() => {
ensureFocus(this.editable);
getDeepRange(this.editable, { select: true });
});
},
postValidate: () => {
beforeCommand: () => {
if (this._isPowerboxOpenOnInput) {
this._historyRevertUntil(beforeStepIndex);
this.historyStep(true);
this._historyStepsStates.set(peek(this._historySteps).id, 'consumed');
setTimeout(() => {
ensureFocus(this.editable);
getDeepRange(this.editable, { select: true });
});
}
},
afterCommand: () => {
this.historyStep(true);
this._isPowerboxOpenOnInput = false;
},
commands: [...mainCommands, ...(this.options.commands || [])],
});
@@ -2349,6 +2351,11 @@ export class OdooEditor extends EventTarget {
* @private
*/
_onInput(ev) {
// See if the Powerbox should be opened. If so, it will open at the end.
const newSelection = this.document.getSelection();
const shouldOpenPowerbox = newSelection.isCollapsed && newSelection.rangeCount &&
ev.data === '/' && this.powerbox && !this.powerbox.isOpen &&
(!this.options.getPowerboxElement || !!this.options.getPowerboxElement());
// Record the selection position that was computed on keydown or before
// contentEditable execCommand (whatever preceded the 'input' event)
this._recordHistorySelection(true);
@@ -2356,7 +2363,6 @@ export class OdooEditor extends EventTarget {
const { anchorNodeOid, anchorOffset, focusNodeOid, focusOffset } = selection || {};
const wasCollapsed =
!selection || (focusNodeOid === anchorNodeOid && 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`.
@@ -2434,8 +2440,7 @@ export class OdooEditor extends EventTarget {
selection.anchorNode &&
!closestElement(selection.anchorNode).closest('a') &&
selection.anchorNode.nodeType === Node.TEXT_NODE &&
(!this.commandBar._active ||
this.commandBar._currentOpenOptions.closeOnSpace !== true)
!this.powerbox.isOpen
) {
const textSliced = selection.anchorNode.textContent.slice(0, selection.anchorOffset);
const textNodeSplitted = textSliced.split(/\s/);
@@ -2468,6 +2473,10 @@ export class OdooEditor extends EventTarget {
} else if (ev.inputType === 'insertCompositionText') {
this._fromCompositionText = true;
}
if (shouldOpenPowerbox) {
this._isPowerboxOpenOnInput = true;
this.powerbox.open();
}
}
/**
@@ -2784,7 +2793,7 @@ export class OdooEditor extends EventTarget {
}
/**
* Handle the hint preview for the commandbar.
* Handle the hint preview for the Powerbox.
* @private
*/
_handleCommandHint() {
@@ -3189,78 +3198,74 @@ export class OdooEditor extends EventTarget {
if (isImageUrl) {
const stepIndexBeforeInsert = this._historySteps.length - 1;
this.execCommand('insertText', splitAroundUrl[i]);
this.commandBar.open({
commands: [
{
groupName: this.options._t('Embed'),
title: this.options._t('Embed Image'),
description: this.options._t('Embed the image in the document.'),
fontawesome: 'fa-image',
shouldPreValidate: () => false,
callback: () => {
execCommandAtStepIndex(stepIndexBeforeInsert, () => {
const img = document.createElement('IMG');
img.setAttribute('src', url);
const sel = this.document.getSelection();
if (!sel.isCollapsed) {
this.deleteRange(sel);
}
if (sel.rangeCount) {
sel.getRangeAt(0).insertNode(img);
sel.collapseToEnd();
}
});
},
this.powerbox.open([
{
groupName: this.options._t('Embed'),
title: this.options._t('Embed Image'),
description: this.options._t('Embed the image in the document.'),
fontawesome: 'fa-image',
callback: () => {
execCommandAtStepIndex(stepIndexBeforeInsert, () => {
const img = document.createElement('IMG');
img.setAttribute('src', url);
const sel = this.document.getSelection();
if (!sel.isCollapsed) {
this.deleteRange(sel);
}
if (sel.rangeCount) {
sel.getRangeAt(0).insertNode(img);
sel.collapseToEnd();
}
});
},
].concat(baseEmbedCommand),
});
},
...baseEmbedCommand,
]);
} else if (this.options.allowCommandVideo && youtubeUrl) {
const stepIndexBeforeInsert = this._historySteps.length - 1;
this.execCommand('insertText', splitAroundUrl[i]);
this.commandBar.open({
commands: [
{
groupName: this.options._t('Embed'),
title: this.options._t('Embed Youtube Video'),
description: this.options._t('Embed the youtube video in the document.'),
fontawesome: 'fa-youtube-play',
shouldPreValidate: () => false,
callback: async () => {
let videoElement;
if (this.options.getYoutubeVideoElement) {
videoElement = await this.options.getYoutubeVideoElement(youtubeUrl[0]);
} else {
videoElement = document.createElement('iframe');
videoElement.setAttribute('width', '560');
videoElement.setAttribute('height', '315');
videoElement.setAttribute(
'src',
`https://www.youtube.com/embed/${youtubeUrl[1]}`,
);
videoElement.setAttribute('title', 'YouTube video player');
videoElement.setAttribute('frameborder', '0');
videoElement.setAttribute(
'allow',
'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture',
);
videoElement.setAttribute('allowfullscreen', '1');
this.powerbox.open([
{
groupName: this.options._t('Embed'),
title: this.options._t('Embed Youtube Video'),
description: this.options._t('Embed the youtube video in the document.'),
fontawesome: 'fa-youtube-play',
callback: async () => {
let videoElement;
if (this.options.getYoutubeVideoElement) {
videoElement = await this.options.getYoutubeVideoElement(youtubeUrl[0]);
} else {
videoElement = document.createElement('iframe');
videoElement.setAttribute('width', '560');
videoElement.setAttribute('height', '315');
videoElement.setAttribute(
'src',
`https://www.youtube.com/embed/${youtubeUrl[1]}`,
);
videoElement.setAttribute('title', 'YouTube video player');
videoElement.setAttribute('frameborder', '0');
videoElement.setAttribute(
'allow',
'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture',
);
videoElement.setAttribute('allowfullscreen', '1');
}
execCommandAtStepIndex(stepIndexBeforeInsert, () => {
const sel = this.document.getSelection();
if (!sel.isCollapsed) {
this.deleteRange(sel);
}
execCommandAtStepIndex(stepIndexBeforeInsert, () => {
const sel = this.document.getSelection();
if (!sel.isCollapsed) {
this.deleteRange(sel);
}
if (sel.rangeCount) {
sel.getRangeAt(0).insertNode(videoElement);
sel.collapseToEnd();
}
});
},
if (sel.rangeCount) {
sel.getRangeAt(0).insertNode(videoElement);
sel.collapseToEnd();
}
});
},
].concat(baseEmbedCommand),
});
},
...baseEmbedCommand,
]);
} else {
const link = document.createElement('A');
link.setAttribute('href', url);
@@ -2,6 +2,7 @@
import { patienceDiff } from './patienceDiff.js';
import { getRangePosition } from '../utils/utils.js';
const REGEX_RESERVED_CHARS = /[\\^$.*+?()[\]{}|]/g;
/**
* Make `num` cycle from 0 to `max`.
*/
@@ -10,366 +11,380 @@ function cycle(num, max) {
return ((num % y) + y) % y;
}
export class Powerbox {
constructor(options = {}) {
this._active = false;
this.options = options;
if (!this.options._t) this.options._t = string => string;
/**
* interface PowerboxCommand {
* groupName: string;
* title: string;
* description: string;
* fontawesome: string; // a fontawesome class name
* callback: () => void; // to execute when the command is picked
* isDisabled?: () => boolean; // return true to disable the command
* }
*/
export class Powerbox {
constructor({
commands, commandFilters, editable, getContextFromParentRect,
onOpen, onShow, onStop, beforeCommand, afterCommand
} = {}) {
this.commands = commands;
this.commandFilters = commandFilters || [];
this.editable = editable;
this.getContextFromParentRect = getContextFromParentRect;
this.onOpen = onOpen;
this.onShow = onShow;
this.onStop = onStop;
this.beforeCommand = beforeCommand;
this.afterCommand = afterCommand;
this.isOpen = false;
this.document = editable.ownerDocument;
// Draw the powerbox.
this.el = document.createElement('div');
this.el.className = 'oe-commandbar-wrapper';
this.el.style.display = 'none';
if (this.options.width !== undefined) {
this.el.style.width = `${this.options.width}px`;
}
document.body.append(this.el);
this.addHotKey('/', { commands: this.options.commands });
this._mainWrapperElement = document.createElement('div');
this._mainWrapperElement.className = 'oe-commandbar-mainWrapper';
this.el.append(this._mainWrapperElement);
this.el.addEventListener('mousedown', event => {
event.stopPropagation();
});
}
this.el.addEventListener('mousedown', ev => ev.stopPropagation());
// Set up events for later binding.
this._boundOnKeyup = this._onKeyup.bind(this);
this._boundOnKeydown = this._onKeydown.bind(this);
this._boundClose = this.close.bind(this);
this._events = [
[this.document, 'keyup', this._boundOnKeyup],
[this.document, 'keydown', this._boundOnKeydown, true],
[this.document, 'mousedown', this._boundClose],
]
// If the global document is different from the provided
// options.document, which happens when the editor is inside an iframe,
// we need to listen to the mouse event on both documents to be sure the
// Powerbox will always close when clicking outside of it.
if (document !== this.document) {
this._events.push(
[document, 'mousedown', this._boundClose],
);
}
}
destroy() {
if (this.isOpen) {
this.close();
}
this.el.remove();
}
render(commands) {
if (this.options.commandFilters && this.options.commandFilters.length) {
/**
* Some available commands may need to be disabled in certain
* situations. i.e.:
* in a knowledge article, prevent the usage of the /template
* command inside a /template block.
* @see commandFilters is an array of functions which returns
* commands that can be used.
*/
for (let filter of this.options.commandFilters) {
commands = filter(commands);
}
// -------------------------------------------------------------------------
// Public
// -------------------------------------------------------------------------
/**
* Open the Powerbox with the given commands or with all instance commands.
*
* @param {PowerboxCommand[]} [commands=this.commands]
*/
open(commands=this.commands) {
if (this.onOpen) {
this.onOpen();
}
this._mainWrapperElement.innerHTML = '';
clearTimeout(this._renderingTimeout);
this._hoverActive = false;
// Do not show disabled commands.
commands = commands.filter(command => !command.isDisabled || !command.isDisabled());
this._mainWrapperElement.classList.toggle('oe-commandbar-noResult', commands.length === 0);
if (commands.length === 0) {
return;
commands = this._getCurrentCommands(commands);
this._context = {
commands, filteredCommands: commands, selectedCommand: undefined,
initialTarget: this.editable, initialValue: this.editable.textContent,
lastText: undefined,
}
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',
});
});
}
let commandImgEl, commandTitleEl, commandDescriptionEl;
switch (command.style) {
case 'small':
commandTitleEl = document.createElement('div');
commandTitleEl.setAttribute('class', 'oe-commandbar-commandSmall');
commandTitleEl.setAttribute('title', command.description);
commandTitleEl.innerText = command.title;
commandElWrapper.append(commandTitleEl);
break;
default:
commandElWrapper.innerHTML = `
<div class="oe-commandbar-commandLeftCol">
<i class="oe-commandbar-commandImg fa"></i>
</div>
<div class="oe-commandbar-commandRightCol">
<div class="oe-commandbar-commandTitle">
</div>
<div class="oe-commandbar-commandDescription">
</div>
</div>`;
commandImgEl = commandElWrapper.querySelector('.oe-commandbar-commandImg');
commandTitleEl = commandElWrapper.querySelector(
'.oe-commandbar-commandTitle',
);
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',
ev => {
ev.preventDefault();
ev.stopImmediatePropagation();
this._currentValidate(command);
},
true,
);
}
}
this._resetPosition();
this.isOpen = true;
this._render(this._context.commands);
this._bindEvents();
this.show();
}
addHotKey(triggerKey, options) {
this.options.editable.addEventListener(
'input',
ev => {
const selection = this.options.document.getSelection();
if (!selection.isCollapsed || !selection.rangeCount) return;
if (
ev.data === triggerKey &&
!this._active &&
(!this.options.shouldActivate || this.options.shouldActivate())
) {
this.open({ ...options, openOnKeyupTarget: ev.target });
}
},
true,
);
}
open(openOptions) {
this.options.onActivate && this.options.onActivate();
this._currentOpenOptions = openOptions;
this._currentOpenOptions.commands = this._orderCommandsByGroup(openOptions.commands);
const openOnKeyupTarget =
this._currentOpenOptions.openOnKeyupTarget || this.options.editable;
const onValueChangeFunction = term =>
this._currentOpenOptions.valueChangeFunction
? this._currentOpenOptions.valueChangeFunction(term)
: this._filter(term, this._currentOpenOptions.commands);
const showOnceOnKeyup = () => {
this.show();
openOnKeyupTarget.removeEventListener('keyup', showOnceOnKeyup, true);
initialTarget = openOnKeyupTarget;
this._initialValue = openOnKeyupTarget.textContent;
};
openOnKeyupTarget.addEventListener('keyup', showOnceOnKeyup, true);
this._active = true;
this._currentFilteredCommands = this._currentOpenOptions.commands;
this.render(this._currentOpenOptions.commands);
let initialTarget;
const keyup = async ev => {
if (ev.key === 'ArrowDown' || ev.key === 'ArrowUp') {
ev.preventDefault();
return;
}
if (!initialTarget) return;
const diff = patienceDiff(
this._initialValue.split(''),
initialTarget.textContent.split(''),
true,
);
this._lastText = diff.bMove.join('');
if (this._lastText.match(/\s/) && this._currentOpenOptions.closeOnSpace !== false) {
this._stop();
return;
}
const term = this._lastText;
this._currentFilteredCommands =
term === '' ? this._currentOpenOptions.commands : await onValueChangeFunction(term);
this.render(this._currentFilteredCommands);
};
const keydown = ev => {
if (ev.key === 'Enter') {
ev.stopImmediatePropagation();
this._currentValidate();
ev.preventDefault();
} else if (ev.key === 'Escape') {
ev.stopImmediatePropagation();
this._stop();
ev.preventDefault();
} else if (ev.key === 'Backspace' && !this._lastText) {
this._stop();
} else if (ev.key === 'ArrowDown' || ev.key === 'ArrowUp') {
ev.preventDefault();
ev.stopImmediatePropagation();
const index = this._currentFilteredCommands.findIndex(
c => c === this._currentSelectedCommand,
);
if (!this._currentFilteredCommands.length || index === -1) {
this._currentSelectedCommand = undefined;
} else {
const n = ev.key === 'ArrowDown' ? 1 : -1;
const newIndex = cycle(index + n, this._currentFilteredCommands.length - 1);
this._currentSelectedCommand = this._currentFilteredCommands[newIndex];
}
ev.preventDefault();
this.render(this._currentFilteredCommands);
}
};
const mousemove = () => {
this._hoverActive = true;
};
this._stop = () => {
this._active = false;
this.hide();
this._currentSelectedCommand = undefined;
this.options.document.removeEventListener('keyup', keyup);
this.options.document.removeEventListener('keydown', keydown, true);
this.options.document.removeEventListener('mousemove', mousemove);
this.options.document.removeEventListener('mousedown', this._stop);
if (document !== this.options.document) {
document.removeEventListener('mousemove', mousemove);
document.removeEventListener('mousedown', this._stop);
}
this.options.onStop && this.options.onStop();
};
this._currentValidate = async (command) => {
if (!command) {
command = this._currentFilteredCommands.find(
c => c === this._currentSelectedCommand,
);
}
if (command) {
!command.isIntermediateStep &&
(!command.shouldPreValidate || command.shouldPreValidate()) &&
this.options.preValidate &&
await this.options.preValidate();
await command.callback();
!command.isIntermediateStep &&
this.options.postValidate &&
await this.options.postValidate();
}
if (!command || !command.isIntermediateStep) {
this._stop();
}
};
this.options.document.addEventListener('keyup', keyup);
this.options.document.addEventListener('keydown', keydown, true);
this.options.document.addEventListener('mousemove', mousemove);
this.options.document.addEventListener('mousedown', this._stop);
// If the Golbal document is diferent than the provided options.document,
// which happend when the editor is inside an Iframe.
// We need to listen to the mouse event on both document
// to be sure the command bar will always close when clicking outside of it.
if (document !== this.options.document) {
document.addEventListener('mousemove', mousemove);
document.addEventListener('mousedown', this._stop);
}
}
nextOpenOptions(openOptions) {
this._currentOpenOptions = openOptions;
this._currentOpenOptions.commands = this._orderCommandsByGroup(openOptions.commands);
this._initialValue = (
this._currentOpenOptions.openOnKeyupTarget || this.options.editable
).textContent;
this._currentFilteredCommands = this._currentOpenOptions.commands;
this.render(this._currentOpenOptions.commands);
}
/**
* Close the Powerbox without destroying it. Unbind events, reset context
* and call the optional `onStop` hook.
*/
close() {
this.isOpen = false;
this.hide();
this._context = undefined;
this._unbindEvents();
this.onStop && this.onStop();
};
/**
* Show the Powerbox and position it. Call the optional `onShow` hook.
*/
show() {
this.options.onShow && this.options.onShow();
this.onShow && this.onShow();
this.el.style.display = 'flex';
this._resetPosition();
}
/**
* Hide the Powerbox. If the Powerbox is active, close it.
*
* @see close
*/
hide() {
this.el.style.display = 'none';
if (this._active) this._stop();
if (this.isOpen) {
this.close();
}
}
// -------------------------------------------------------------------------
// Private
// -------------------------------------------------------------------------
_filter(term, commands) {
const initialCommands = 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 = initialCommands.filter(command => {
const commandText = (command.groupName + ' ' + command.title).toLowerCase();
return commandText.match(regex);
});
/**
* Render the Powerbox with the given commands, grouped by `groupName`.
*
* @private
* @param {PowerboxCommand[]} commands
*/
_render(commands) {
const parser = new DOMParser();
this._mainWrapperElement.innerHTML = '';
this._hoverActive = false;
this._mainWrapperElement.classList.toggle('oe-commandbar-noResult', commands.length === 0);
this._context.selectedCommand = commands.find(command => command === this._context.selectedCommand) || commands[0];
for (const [groupName, groupCommands] of Object.entries(this._groupCommands(commands))) {
const groupWrapperEl = parser.parseFromString(`
<div class="oe-commandbar-groupWrapper">
<div class="oe-commandbar-groupName"></div>
</div>`, 'text/html').body.firstChild;
this._mainWrapperElement.append(groupWrapperEl);
groupWrapperEl.firstElementChild.innerText = groupName;
for (const command of groupCommands) {
const commandElWrapper = document.createElement('div');
commandElWrapper.className = 'oe-commandbar-commandWrapper';
commandElWrapper.classList.toggle('active', this._context.selectedCommand === command);
commandElWrapper.replaceChildren(...parser.parseFromString(`
<div class="oe-commandbar-commandLeftCol">
<i class="oe-commandbar-commandImg fa"></i>
</div>
<div class="oe-commandbar-commandRightCol">
<div class="oe-commandbar-commandTitle"></div>
<div class="oe-commandbar-commandDescription"></div>
</div>`, 'text/html').body.children);
commandElWrapper.querySelector('.oe-commandbar-commandImg').classList.add(command.fontawesome);
commandElWrapper.querySelector('.oe-commandbar-commandTitle').innerText = command.title;
commandElWrapper.querySelector('.oe-commandbar-commandDescription').innerText = command.description;
groupWrapperEl.append(commandElWrapper);
// Handle events on command (activate and pick).
commandElWrapper.addEventListener('mousemove', () => {
this.el.querySelector('.oe-commandbar-commandWrapper.active').classList.remove('active');
this._context.selectedCommand = command;
commandElWrapper.classList.add('active');
});
commandElWrapper.addEventListener('mousedown', ev => {
ev.preventDefault();
ev.stopImmediatePropagation();
this._pickCommand(command);
}, true,
);
}
}
return commands;
this._resetPosition();
}
_orderCommandsByGroup(commands) {
/**
* Handle the selection of a command: call the command's callback. Also call
* the `beforeCommand` and `afterCommand` hooks if they exists.
*
* @private
* @param {PowerboxCommand} [command=this._context.selectedCommand]
*/
async _pickCommand(command=this._context.selectedCommand) {
if (command) {
if (this.beforeCommand) {
await this.beforeCommand();
}
await command.callback();
if (this.afterCommand) {
await this.afterCommand();
}
}
this.close();
};
/**
* Filter an array of commands based on the given term using fuzzy matching.
*
* @private
* @param {PowerboxCommand[]} commands
* @param {string} term
* @returns {PowerboxCommand[]}
*/
_filter(commands, term) {
term = term.toLowerCase().replaceAll(/\s/g, '\\s').replaceAll('\u200B', '');
if (term.length) {
const regex = new RegExp(term.split('').map(char => char.replace(REGEX_RESERVED_CHARS, '\\$&')).join('.*'));
return commands.filter(command => `${command.groupName} ${command.title}`.toLowerCase().match(regex));
} else {
return commands;
}
}
/**
* Take a list of commands, filter them based on `commandFilters` and
* `isDisabled`) and return the remaining commands, ordered so that commands
* that belong to the same group are grouped together.
* Note: `commandFilters` is on the Powerbox instance and allows the
* filtering of several commands at once (eg, a whole group), while
* `isDisabled` is on a specific command and is made to target that command.
*
* @see commandFilters {Array<() => PowerboxCommand[]} return commands that can be used.
* @see command.isDisabled {() => boolean} return true if the command is disabled.
* @private
* @param {PowerboxCommand[]} commands
* @returns {PowerboxCommand[]}
*/
_getCurrentCommands(commands) {
/**
* Some available commands may need to be disabled in certain
* situations. i.e.: in a knowledge article, prevent the usage of the
* /template command inside a /template block.
*/
for (let filter of this.commandFilters) {
commands = filter(commands);
}
// Do not show disabled commands.
commands = commands.filter(command => !command.isDisabled || !command.isDisabled());
return this._orderCommandsByGroup(commands);
}
/**
* Takes a list of commands and returns an object whose keys are all
* existing group names and whose values are each of these groups' commands.
*
* @private
* @param {PowerboxCommand[]} commands
* @returns {{[key: groupName]: PowerboxCommand[]}>}
*/
_groupCommands(commands) {
const groups = {};
for (const command of commands) {
groups[command.groupName] = groups[command.groupName] || [];
groups[command.groupName].push(command);
groups[command.groupName] = [...(groups[command.groupName] || []), command];
}
return groups;
}
/**
* Take a list of commands and returns them reordered so that commands that
* belong to the same group are grouped together.
*
* @private
* @param {PowerboxCommand[]} commands
* @returns {PowerboxCommand[]}
*/
_orderCommandsByGroup(commands) {
const groups = this._groupCommands(commands);
const reorderedCommands = [];
for (const groupCommands of Object.values(groups)) {
reorderedCommands.push(...groupCommands);
}
return reorderedCommands;
}
/**
* Recompute the Powerbox's position base on the selection in the document.
*
* @private
*/
_resetPosition() {
const position = getRangePosition(this.el, this.options.document);
if (!position) {
const position = getRangePosition(this.el, this.document);
if (position) {
let { left, top } = position;
if (this.getContextFromParentRect) {
const parentContextRect = this.getContextFromParentRect();
left += parentContextRect.left;
top += parentContextRect.top;
}
this.el.style.left = `${left}px`;
this.el.style.top = `${top}px`;
} else {
this.hide();
return;
}
let { left, top } = position;
if (this.options.getContextFromParentRect) {
const parentContextRect = this.options.getContextFromParentRect();
left += parentContextRect.left;
top += parentContextRect.top;
}
/**
* Add all events to their given target, based on @see _events.
*
* @private
*/
_bindEvents() {
for (const [target, eventName, callback, option] of this._events) {
target.addEventListener(eventName, callback, option);
}
}
/**
* Remove all events from their given target, based on @see _events.
*
* @private
*/
_unbindEvents() {
for (const [target, eventName, callback, option] of this._events) {
target.removeEventListener(eventName, callback, option);
}
}
this.el.style.left = `${left}px`;
this.el.style.top = `${top}px`;
// -------------------------------------------------------------------------
// Handlers
// -------------------------------------------------------------------------
/**
* Handle keyup events to filter commands based on what was typed, and
* prevent changing selection when using the arrow keys.
*
* @private
* @param {KeyboardEvent} ev
*/
_onKeyup(ev) {
if (ev.key === 'ArrowDown' || ev.key === 'ArrowUp') {
ev.preventDefault();
} else {
const diff = patienceDiff(
this._context.initialValue.split(''),
this._context.initialTarget.textContent.split(''),
true,
);
this._context.lastText = diff.bMove.join('');
if (this._context.lastText.match(/\s/)) {
this.close();
} else {
this._context.filteredCommands = this._context.lastText === ''
? this._context.commands
: this._filter(this._context.commands, this._context.lastText);
this._render(this._context.filteredCommands);
}
}
}
/**
* Handle keydown events to add keyboard interactions with the Powerbox.
*
* @private
* @param {KeyboardEvent} ev
*/
_onKeydown(ev) {
if (ev.key === 'Enter') {
ev.stopImmediatePropagation();
this._pickCommand();
ev.preventDefault();
} else if (ev.key === 'Escape') {
ev.stopImmediatePropagation();
this.close();
ev.preventDefault();
} else if (ev.key === 'Backspace' && !this._context.lastText) {
this.close();
} else if (ev.key === 'ArrowDown' || ev.key === 'ArrowUp') {
ev.preventDefault();
ev.stopImmediatePropagation();
const commandIndex = this._context.filteredCommands.findIndex(
command => command === this._context.selectedCommand,
);
if (this._context.filteredCommands.length && commandIndex !== -1) {
const nextIndex = commandIndex + (ev.key === 'ArrowDown' ? 1 : -1);
const newIndex = cycle(nextIndex, this._context.filteredCommands.length - 1);
this._context.selectedCommand = this._context.filteredCommands[newIndex];
} else {
this._context.selectedCommand = undefined;
}
this._render(this._context.filteredCommands);
this.el.querySelector('.oe-commandbar-commandWrapper.active').scrollIntoView({block: 'nearest', inline: 'nearest'});
}
}
}
@@ -1323,9 +1323,9 @@ describe('Copy and paste', () => {
stepFunction: async editor => {
await pasteText(editor, 'https://download.odoocdn.com/icons/website/static/description/icon.png');
// Ensure the powerbox is active
window.chai.expect(editor.commandBar._active).to.be.true;
// Force commandBar validation on the default first choice
await editor.commandBar._currentValidate();
window.chai.expect(editor.powerbox.isOpen).to.be.true;
// Force powerbox validation on the default first choice
await editor.powerbox._pickCommand();
},
contentAfter: '<p>ab<img src="https://download.odoocdn.com/icons/website/static/description/icon.png">[]cd</p>',
});
@@ -1336,9 +1336,9 @@ describe('Copy and paste', () => {
stepFunction: async editor => {
await pasteText(editor, 'https://download.odoocdn.com/icons/website/static/description/icon.png');
// Ensure the powerbox is active
window.chai.expect(editor.commandBar._active).to.be.true;
// Force commandBar validation on the default first choice
await editor.commandBar._currentValidate();
window.chai.expect(editor.powerbox.isOpen).to.be.true;
// Force powerbox validation on the default first choice
await editor.powerbox._pickCommand();
},
contentAfter: '<p>a<span>b<img src="https://download.odoocdn.com/icons/website/static/description/icon.png">[]c</span>d</p>',
});
@@ -1349,9 +1349,9 @@ describe('Copy and paste', () => {
stepFunction: async editor => {
await pasteText(editor, 'https://download.odoocdn.com/icons/website/static/description/icon.png');
// Ensure the powerbox is active
window.chai.expect(editor.commandBar._active).to.be.true;
// Force commandBar validation on the default first choice
await editor.commandBar._currentValidate();
window.chai.expect(editor.powerbox.isOpen).to.be.true;
// Force powerbox validation on the default first choice
await editor.powerbox._pickCommand();
},
contentAfter: '<p>a<a href="http://existing.com">b<img src="https://download.odoocdn.com/icons/website/static/description/icon.png">[]c</a>d</p>',
});
@@ -1364,9 +1364,9 @@ describe('Copy and paste', () => {
stepFunction: async editor => {
await pasteText(editor, 'https://download.odoocdn.com/icons/website/static/description/icon.png');
// Ensure the powerbox is active
window.chai.expect(editor.commandBar._active).to.be.true;
// Force commandBar validation on the default first choice
await editor.commandBar._currentValidate();
window.chai.expect(editor.powerbox.isOpen).to.be.true;
// Force powerbox validation on the default first choice
await editor.powerbox._pickCommand();
},
contentAfter: '<p>ab<img src="https://download.odoocdn.com/icons/website/static/description/icon.png">[]cd</p>',
});
@@ -1377,9 +1377,9 @@ describe('Copy and paste', () => {
stepFunction: async editor => {
await pasteText(editor, 'https://download.odoocdn.com/icons/website/static/description/icon.png');
// Ensure the powerbox is active
window.chai.expect(editor.commandBar._active).to.be.true;
// Force commandBar validation on the default first choice
await editor.commandBar._currentValidate();
window.chai.expect(editor.powerbox.isOpen).to.be.true;
// Force powerbox validation on the default first choice
await editor.powerbox._pickCommand();
},
contentAfter: '<p>a<span>b<img src="https://download.odoocdn.com/icons/website/static/description/icon.png">[]c</span>d</p>',
});
@@ -1390,9 +1390,9 @@ describe('Copy and paste', () => {
stepFunction: async editor => {
await pasteText(editor, 'https://download.odoocdn.com/icons/website/static/description/icon.png');
// Ensure the powerbox is active
window.chai.expect(editor.commandBar._active).to.be.true;
// Force commandBar validation on the default first choice
await editor.commandBar._currentValidate();
window.chai.expect(editor.powerbox.isOpen).to.be.true;
// Force powerbox validation on the default first choice
await editor.powerbox._pickCommand();
},
contentAfter: '<p>a<a href="http://existing.com">b<img src="https://download.odoocdn.com/icons/website/static/description/icon.png">[]c</a>d</p>',
});
@@ -1407,9 +1407,9 @@ describe('Copy and paste', () => {
stepFunction: async editor => {
await pasteText(editor, 'https://www.youtube.com/watch?v=dQw4w9WgXcQ');
// Ensure the powerbox is active
window.chai.expect(editor.commandBar._active).to.be.true;
// Force commandBar validation on the default first choice
await editor.commandBar._currentValidate();
window.chai.expect(editor.powerbox.isOpen).to.be.true;
// Force powerbox validation on the default first choice
await editor.powerbox._pickCommand();
},
contentAfter: '<p>ab<iframe width="560" height="315" src="https://www.youtube.com/embed/dQw4w9WgXcQ" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen="1"></iframe>[]cd</p>',
});
@@ -1420,9 +1420,9 @@ describe('Copy and paste', () => {
stepFunction: async editor => {
await pasteText(editor, 'https://youtu.be/dQw4w9WgXcQ');
// Ensure the powerbox is active
window.chai.expect(editor.commandBar._active).to.be.true;
// Force commandBar validation on the default first choice
await editor.commandBar._currentValidate();
window.chai.expect(editor.powerbox.isOpen).to.be.true;
// Force powerbox validation on the default first choice
await editor.powerbox._pickCommand();
},
contentAfter: '<p>a<span>b<iframe width="560" height="315" src="https://www.youtube.com/embed/dQw4w9WgXcQ" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen="1"></iframe>[]c</span>d</p>',
});
@@ -1433,7 +1433,7 @@ describe('Copy and paste', () => {
stepFunction: async editor => {
await pasteText(editor, 'https://youtu.be/dQw4w9WgXcQ');
// Ensure the powerbox is not active
window.chai.expect(editor.commandBar._active).to.be.false;
window.chai.expect(editor.powerbox.isOpen).to.be.false;
},
contentAfter: '<p>a<a href="http://existing.com">bhttps://youtu.be/dQw4w9WgXcQ[]c</a>d</p>',
});
@@ -1446,9 +1446,9 @@ describe('Copy and paste', () => {
stepFunction: async editor => {
await pasteText(editor, 'https://youtu.be/dQw4w9WgXcQ');
// Ensure the powerbox is active
window.chai.expect(editor.commandBar._active).to.be.true;
// Force commandBar validation on the default first choice
await editor.commandBar._currentValidate();
window.chai.expect(editor.powerbox.isOpen).to.be.true;
// Force powerbox validation on the default first choice
await editor.powerbox._pickCommand();
},
contentAfter: '<p>ab<iframe width="560" height="315" src="https://www.youtube.com/embed/dQw4w9WgXcQ" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen="1"></iframe>[]cd</p>',
});
@@ -1459,9 +1459,9 @@ describe('Copy and paste', () => {
stepFunction: async editor => {
await pasteText(editor, 'https://www.youtube.com/watch?v=dQw4w9WgXcQ');
// Ensure the powerbox is active
window.chai.expect(editor.commandBar._active).to.be.true;
// Force commandBar validation on the default first choice
await editor.commandBar._currentValidate();
window.chai.expect(editor.powerbox.isOpen).to.be.true;
// Force powerbox validation on the default first choice
await editor.powerbox._pickCommand();
},
contentAfter: '<p>a<span>b<iframe width="560" height="315" src="https://www.youtube.com/embed/dQw4w9WgXcQ" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen="1"></iframe>[]c</span>d</p>',
});
@@ -1472,7 +1472,7 @@ describe('Copy and paste', () => {
stepFunction: async editor => {
await pasteText(editor, 'https://www.youtube.com/watch?v=dQw4w9WgXcQ');
// Ensure the powerbox is not active
window.chai.expect(editor.commandBar._active).to.be.false;
window.chai.expect(editor.powerbox.isOpen).to.be.false;
},
contentAfter: '<p>a<a href="http://existing.com">bhttps://www.youtube.com/watch?v=dQw4w9WgXcQ[]c</a>d</p>',
});