[REF] web_editor: prepare absolute containers for the movable node

- Add the ability to add a container to a tree of absolute containers
- Draw the avatars and the avatar overlaps in their own containers
- In order to clearly understand what draws the user avatar and a
  selection, create the method _drawUserAvatar.

task-2984709

Part-of: odoo/odoo#105179
This commit is contained in:
Nicolas Bayet
2023-09-07 14:45:18 +00:00
parent d0ab79a296
commit e47b07f4ef
2 changed files with 134 additions and 106 deletions
@@ -97,6 +97,8 @@ const HISTORY_SNAPSHOT_BUFFER_TIME = 1000 * 10;
const KEYBOARD_TYPES = { VIRTUAL: 'VIRTUAL', PHYSICAL: 'PHYSICAL', UNKNOWN: 'UKNOWN' };
export const AVATAR_SIZE = 25;
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);
@@ -334,9 +336,22 @@ export class OdooEditor extends EventTarget {
// Collaborator selection and caret display.
this._collabSelectionInfos = new Map();
this._collabSelectionColor = `hsl(${(Math.random() * 360).toFixed(0)}, 75%, 50%)`;
this._collabSelectionsContainer = this.document.createElement('div');
this._collabSelectionsContainer.classList.add('oe-collaboration-selections-container');
this.editable.before(this._collabSelectionsContainer);
this._avatarsOverlaps = {}
// This main container is used to contain a tree of sub containers.
// By having one parent that contains a tree of containers, it is easy
// to change the z-index of any container by changing their place in the
// tree rather than tweaking a z-index number.
this._mainAbsoluteContainer = this.document.createElement('div');
this._mainAbsoluteContainer.classList.add('oe-absolute-container');
this.editable.before(this._mainAbsoluteContainer);
// This container contains the users selections.
this._selectionsContainer = this.makeAbsoluteContainer('oe-selections-container');
// This container contains the users avatars.
this._avatarsContainer = this.makeAbsoluteContainer('oe-avatars-container');
// This container contains the users counter that overlap the users avatars.
this._avatarsCountersContainer = this.makeAbsoluteContainer('oe-avatars-counters-container');
// Promise for extra rendering, collaborative external steps will be
// buffered (delayed) until it is resolved.
@@ -669,7 +684,7 @@ export class OdooEditor extends EventTarget {
this._removeDomListener();
this.powerbox.destroy();
this.powerboxTablePicker.el.remove();
this._collabSelectionsContainer.remove();
this._mainAbsoluteContainer.remove();
this._resizeObserver.disconnect();
clearInterval(this._snapshotInterval);
this._pluginCall('destroy', []);
@@ -820,6 +835,22 @@ export class OdooEditor extends EventTarget {
element.addEventListener(eventName, boundCallback);
}
/**
* Make an absolute container to organise floating elements inside it's own
* box and z-index isolation.
*
* @param {string} containerId An id to add to the container in order to make
* the container more visible in the devtool and potentially
* add css rules for the container and it's children.
*/
makeAbsoluteContainer(containerId) {
const container = this.document.createElement('div');
container.className = `oe-absolute-container`;
container.setAttribute('data-oe-absolute-container-id', containerId);
this.mainAbsoluteContainer.append(container);
return container;
}
_generateId() {
// No need for secure random number.
return Math.floor(Math.random() * Math.pow(2,52)).toString();
@@ -1685,30 +1716,27 @@ export class OdooEditor extends EventTarget {
// -------------------------------------------------------------------------
onExternalMultiselectionUpdate(selection) {
this._multiselectionDisplayClient(selection);
const { clientId } = selection;
if (this._collabSelectionInfos.has(clientId)) {
this._collabSelectionInfos.get(clientId).selection = selection;
const currentInfo = this._collabSelectionInfos.get(clientId);
if (currentInfo) {
currentInfo.selection = selection;
} else {
this._collabSelectionInfos.set(clientId, { selection });
}
this._drawClientSelection(selection);
this._drawClientAvatar(selection);
this._updateAvatarCounters();
}
multiselectionRefresh() {
// Refresh the selection but keep the relevant images to avoid flickering.
const avatars = [...this._collabSelectionInfos.values()].map(info => info.avatarElement);
for (const element of this._collabSelectionsContainer.childNodes) {
const isAvatarElement = element.classList && element.classList.contains('oe-collaboration-caret-avatar');
if (!(isAvatarElement && avatars.includes(element))) {
element.remove();
}
}
for (const { selection } of this._collabSelectionInfos.values()) {
this._multiselectionDisplayClient(selection);
this._drawClientSelection(selection);
this._drawClientAvatar(selection);
}
this._updateAvatarCounters();
}
_multiselectionDisplayClient({ selection, color, clientId, clientAvatarUrl = '', clientName = this.options._t('Anonymous') }) {
_drawClientSelection({ selection, color, clientId, clientName = this.options._t('Anonymous') }) {
let clientRects;
let anchorNode = this.idFind(selection.anchorNodeOid);
@@ -1753,7 +1781,7 @@ export class OdooEditor extends EventTarget {
}
// Draw rects (in case the selection is not collapsed).
const containerRect = this._collabSelectionsContainer.getBoundingClientRect();
const containerRect = this._selectionsContainer.getBoundingClientRect();
const indicators = clientRects.map(({ x, y, width, height }) => {
const rectElement = this.document.createElement('div');
rectElement.style = `
@@ -1783,69 +1811,6 @@ export class OdooEditor extends EventTarget {
caretTopSquare.setAttribute('data-client-name', clientName);
caretElement.append(caretTopSquare);
// Draw user avatar.
const selectionInfo = this._collabSelectionInfos.get(clientId);
let caretAvatar = selectionInfo && selectionInfo.avatarElement;
if (!caretAvatar) {
caretAvatar = this.document.createElement('div');
caretAvatar.className = 'oe-collaboration-caret-avatar';
caretAvatar.style.display = 'none';
const image = this.document.createElement('img');
caretAvatar.append(image);
image.onload = () => caretAvatar.style.removeProperty('display');
image.setAttribute('src', clientAvatarUrl);
caretAvatar.setAttribute('data-selection-client-id', clientId);
this._collabSelectionsContainer.append(caretAvatar);
}
// Make sure data is up to date.
if (selectionInfo) {
selectionInfo.avatarElement = caretAvatar;
selectionInfo.clientName = clientName;
} else {
this._collabSelectionInfos.set(clientId, { avatarElement: caretAvatar, clientName: clientName });
}
const anchorBlockRect = closestBlock(anchorNode).getBoundingClientRect();
const previousTop = caretAvatar.style.top;
const top = anchorBlockRect.y - containerRect.y + 'px';
caretAvatar.style.top = top;
const closestList = closestElement(anchorNode, 'ul, ol'); // Prevent overlap bullets.
const anchorX = closestList ? closestList.getBoundingClientRect().x : anchorBlockRect.x;
const previousLeft = caretAvatar.style.left;
const left = anchorX - containerRect.x - 25 + 'px';
caretAvatar.style.left = left;
// Handle overlapping avatars.
if (previousTop !== top || previousLeft !== left) {
const allAvatarsInDom = [...this._collabSelectionsContainer.children].filter(child => child.classList.contains('oe-collaboration-caret-avatar'));
for (const position of [[previousTop, previousLeft], [top, left]]) {
// Filter clients at that position.
const [currentTop, currentLeft] = position;
const clients = new Map([...this._collabSelectionInfos.entries()].filter(([key, value]) => {
const avatarTop = value.avatarElement && value.avatarElement.style.top;
const avatarLeft = value.avatarElement && value.avatarElement.style.left;
return value.avatarElement && avatarTop === currentTop && avatarLeft === currentLeft;
}));
// Update avatar values for that position.
const avatars = [...clients.values()].map(client => client.avatarElement);
const lastInDom = allAvatarsInDom.find(avatar => avatars.includes(avatar));
const newTitle = [...clients.values()].map(client => client.clientName).join('\n');
for (const client of clients.values()) {
const avatar = client.avatarElement;
// Only show the number of overlapping avatars on the z-top
// element, and then only if there are indeed more than one
// at the same position.
if (clients.size > 1 && avatar === lastInDom) {
avatar.setAttribute('data-overlapping-avatars', clients.size);
} else {
avatar.removeAttribute('data-overlapping-avatars');
}
if (avatar.firstElementChild.getAttribute('title') !== newTitle) {
avatar.firstElementChild.setAttribute('title', newTitle);
}
};
}
}
if (direction === DIRECTIONS.LEFT) {
const rect = clientRects[0];
caretElement.style.height = `${rect.height * 1.2}px`;
@@ -1858,24 +1823,89 @@ export class OdooEditor extends EventTarget {
caretElement.style.left = `${rect.right - containerRect.x}px`;
}
this._multiselectionRemoveClient(clientId);
this._collabSelectionsContainer.append(caretElement, ...indicators);
this._selectionsContainer.append(caretElement, ...indicators);
}
multiselectionRemove(clientId) {
this._collabSelectionInfos.delete(clientId);
this._multiselectionRemoveClient(clientId);
const avatars = [...this._collabSelectionsContainer.children].filter(child => (
child.classList.contains('oe-collaboration-caret-avatar') &&
child.getAttribute('data-selection-client-id') === clientId
));
for (const avatar of avatars) {
avatar.remove();
_drawClientAvatar({ selection, clientId, clientAvatarUrl = '', clientName = this.options._t('Anonymous') }) {
const anchorNode = this.idFind(selection.anchorNodeOid);
const focusNode = this.idFind(selection.focusNodeOid);
if (!anchorNode || !focusNode) {
return;
}
const anchorBlock = closestBlock(anchorNode);
if (!anchorBlock) return;
const containerRect = this._avatarsContainer.getBoundingClientRect();
// Draw user avatar.
const selectionInfo = this._collabSelectionInfos.get(clientId) || {};
let avatarElement = selectionInfo.avatarElement;
if (!avatarElement) {
avatarElement = this.document.createElement('div');
avatarElement.className = 'oe-collaboration-caret-avatar';
avatarElement.style.display = 'none';
const image = this.document.createElement('img');
avatarElement.append(image);
image.onload = () => avatarElement.style.removeProperty('display');
image.setAttribute('src', clientAvatarUrl);
}
// Avoid re-appending the element in the dom.
if (!avatarElement.parentElement) {
this._avatarsContainer.append(avatarElement);
}
// Make sure data is up to date.
selectionInfo.avatarElement = avatarElement;
selectionInfo.clientName = clientName;
selectionInfo.avatarTargetElement = anchorBlock;
this._collabSelectionInfos.set(clientId, selectionInfo);
const anchorBlockRect = anchorBlock.getBoundingClientRect();
const top = anchorBlockRect.y - containerRect.y;
avatarElement.style.top = top + 'px';
const closestList = closestElement(anchorNode, 'ul, ol'); // Prevent overlap bullets.
const anchorX = closestList ? closestList.getBoundingClientRect().x : anchorBlockRect.x;
const left = anchorX - containerRect.x - AVATAR_SIZE;
avatarElement.style.left = left + 'px';
selectionInfo.avatarPositionKey = `${left}|${top}`;
}
_updateAvatarCounters() {
this._avatarsOverlaps = {};
for (const info of this._collabSelectionInfos.values()) {
const key = info.avatarPositionKey;
this._avatarsOverlaps[key] = this._avatarsOverlaps[key] || new Set();
this._avatarsOverlaps[key].add(info);
}
// Render avatars overlap.
this._avatarsCountersContainer.replaceChildren();
for (const [overlapKey, infos] of Object.entries(this._avatarsOverlaps)) {
const size = infos.size;
if (size > 1) {
const [left, top] = overlapKey.split('|').map((n) => parseInt(n, 10));
const div = document.createElement('div');
div.className = 'oe-overlapping-counter';
div.style.left = left + 10 + 'px';
div.style.top = top + 10 + 'px';
div.innerText = size;
this._avatarsCountersContainer.append(div);
}
}
}
multiselectionRemove(clientId) {
const selectionInfo = this._collabSelectionInfos.get(clientId);
if (selectionInfo && selectionInfo.avatarElement) {
selectionInfo.avatarElement.remove();
}
this._multiselectionRemoveClient(clientId)
this._collabSelectionInfos.delete(clientId);
this._updateAvatarCounters();
}
_multiselectionRemoveClient(clientId) {
const elements = this._collabSelectionsContainer.querySelectorAll(
`[data-selection-client-id="${clientId}"]:not(.oe-collaboration-caret-avatar)`,
const elements = this._selectionsContainer.querySelectorAll(
`[data-selection-client-id="${clientId}"]`,
);
for (const element of elements) {
element.remove();
@@ -426,19 +426,17 @@ i.oe-powerbox-commandImg {
width: 100%;
border-radius: 50%;
}
&[data-overlapping-avatars]::after {
content: attr(data-overlapping-avatars);
background-color: green;
color: white;
border-radius: 50%;
font-size: 9px;
padding: 0 4px;
position: absolute;
top: 11px;
right: -5px;
z-index: 1;
}
}
.oe-avatars-counters-container {
pointer-events: none;
}
.oe-overlapping-counter {
position: absolute;
background-color: green;
color: white;
border-radius: 50%;
font-size: 9px;
padding: 0 4px;
}
code.o_inline_code {
background-color: #c5c5c5;