odoo18/addons/mail/static/src/utils/common/hooks.js

505 lines
16 KiB
JavaScript

import {
onMounted,
onPatched,
onWillUnmount,
useComponent,
useEffect,
useRef,
useState,
} from "@odoo/owl";
import { browser } from "@web/core/browser/browser";
import { Deferred } from "@web/core/utils/concurrency";
import { makeDraggableHook } from "@web/core/utils/draggable_hook_builder_owl";
import { useService } from "@web/core/utils/hooks";
export function useLazyExternalListener(target, eventName, handler, eventParams) {
const boundHandler = handler.bind(useComponent());
let t;
onMounted(() => {
t = target();
if (!t) {
return;
}
t.addEventListener(eventName, boundHandler, eventParams);
});
onPatched(() => {
const t2 = target();
if (t !== t2) {
if (t) {
t.removeEventListener(eventName, boundHandler, eventParams);
}
if (t2) {
t2.addEventListener(eventName, boundHandler, eventParams);
}
t = t2;
}
});
onWillUnmount(() => {
if (!t) {
return;
}
t.removeEventListener(eventName, boundHandler, eventParams);
});
}
export function onExternalClick(refName, cb) {
let downTarget, upTarget;
const ref = useRef(refName);
function onClick(ev) {
if (ref.el && !ref.el.contains(ev.composedPath()[0])) {
cb(ev, { downTarget, upTarget });
upTarget = downTarget = null;
}
}
function onMousedown(ev) {
downTarget = ev.target;
}
function onMouseup(ev) {
upTarget = ev.target;
}
onMounted(() => {
document.body.addEventListener("mousedown", onMousedown, true);
document.body.addEventListener("mouseup", onMouseup, true);
document.body.addEventListener("click", onClick, true);
});
onWillUnmount(() => {
document.body.removeEventListener("mousedown", onMousedown, true);
document.body.removeEventListener("mouseup", onMouseup, true);
document.body.removeEventListener("click", onClick, true);
});
}
/**
* Hook that allows to determine precisely when refs are (mouse-)hovered.
* Should provide a list of ref names, and can add callbacks when elements are
* hovered-in (onHover), hovered-out (onAway), hovering for some time (onHovering).
*
* @param {string | string[]} refNames name of refs that determine whether this is in state "hovering".
* ref name that end with "*" means it takes parented HTML node into account too. Useful for floating
* menu where dropdown menu container is not accessible.
* @param {Object} param1
* @param {() => void} [param1.onHover] callback when hovering the ref names.
* @param {() => void} [param1.onAway] callback when stop hovering the ref names.
* @param {number, () => void} [param1.onHovering] array where 1st param is duration until start hovering
* and function to be executed at this delay duration after hovering is kept true.
* @param {() => Array} [param1.stateObserver] when provided, function that, when called, returns list of
* reactive state related to presence of targets' el. This is used to help the hook detect when the targets
* are removed from DOM, to properly mark the hovered target as non-hovered.
* @returns {({ isHover: boolean })}
*/
export function useHover(refNames, { onHover, onAway, stateObserver, onHovering } = {}) {
refNames = Array.isArray(refNames) ? refNames : [refNames];
const targets = [];
let wasHovering = false;
let hoveringTimeout;
let awayTimeout;
let lastHoveredTarget;
for (const refName of refNames) {
targets.push({
ref: refName.endsWith("*")
? useRef(refName.substring(0, refName.length - 1))
: useRef(refName),
});
}
const state = useState({
set isHover(newIsHover) {
if (this._isHover !== newIsHover) {
this._isHover = newIsHover;
this._count++;
}
},
get isHover() {
void this._count;
return this._isHover;
},
_count: 0,
_isHover: false,
});
function setHover(hovering) {
if (hovering && !wasHovering) {
state.isHover = true;
clearTimeout(awayTimeout);
clearTimeout(hoveringTimeout);
if (typeof onHover === "function") {
onHover();
}
if (Array.isArray(onHovering)) {
const [delay, cb] = onHovering;
hoveringTimeout = setTimeout(() => {
cb();
}, delay);
}
} else if (!hovering) {
state.isHover = false;
clearTimeout(awayTimeout);
if (typeof onAway === "function") {
awayTimeout = setTimeout(() => {
clearTimeout(hoveringTimeout);
onAway();
}, 200);
}
}
wasHovering = hovering;
}
function onmouseenter(ev) {
if (state.isHover) {
return;
}
for (const target of targets) {
if (!target.ref.el) {
continue;
}
if (target.ref.el.contains(ev.target)) {
setHover(true);
lastHoveredTarget = target;
return;
}
}
}
function onmouseleave(ev) {
if (!state.isHover) {
return;
}
for (const target of targets) {
if (!target.ref.el) {
continue;
}
if (target.ref.el.contains(ev.relatedTarget)) {
return;
}
}
setHover(false);
lastHoveredTarget = null;
}
for (const target of targets) {
useLazyExternalListener(
() => target.ref.el,
"mouseenter",
(ev) => onmouseenter(ev),
true
);
useLazyExternalListener(
() => target.ref.el,
"mouseleave",
(ev) => onmouseleave(ev),
true
);
}
if (stateObserver) {
useEffect(() => {
if (lastHoveredTarget && !lastHoveredTarget.ref.el) {
setHover(false);
lastHoveredTarget = null;
}
}, stateObserver);
}
return state;
}
/**
* Hook that execute the callback function each time the scrollable element hit
* the bottom minus the threshold.
*
* @param {string} refName scrollable t-ref name to observe
* @param {function} callback function to execute when scroll hit the bottom minus the threshold
* @param {number} threshold number of threshold pixel to trigger the callback
*/
export function useOnBottomScrolled(refName, callback, threshold = 1) {
const ref = useRef(refName);
function onScroll() {
if (Math.abs(ref.el.scrollTop + ref.el.clientHeight - ref.el.scrollHeight) < threshold) {
callback();
}
}
onMounted(() => {
ref.el.addEventListener("scroll", onScroll);
});
onWillUnmount(() => {
ref.el.removeEventListener("scroll", onScroll);
});
}
/**
* @param {string} refName
* @param {function} cb
*/
export function useVisible(refName, cb, { ready = true } = {}) {
const ref = useRef(refName);
const state = useState({
isVisible: undefined,
ready,
});
function setValue(value) {
state.isVisible = value;
cb(state.isVisible);
}
const observer = new IntersectionObserver((entries) => {
setValue(entries.at(-1).isIntersecting);
});
useEffect(
(el, ready) => {
if (el && ready) {
observer.observe(el);
return () => {
setValue(undefined);
observer.unobserve(el);
};
}
},
() => [ref.el, state.ready]
);
return state;
}
export function useMessageHighlight(duration = 2000) {
let timeout;
const state = useState({
clearHighlight() {
if (this.highlightedMessageId) {
browser.clearTimeout(timeout);
timeout = null;
this.highlightedMessageId = null;
}
},
/**
* @param {import("models").Message} message
* @param {import("models").Thread} thread
*/
async highlightMessage(message, thread) {
if (thread.notEq(message.thread)) {
return;
}
await thread.loadAround(message.id);
const lastHighlightedMessageId = state.highlightedMessageId;
this.clearHighlight();
if (lastHighlightedMessageId === message.id) {
// Give some time for the state to update.
await new Promise(setTimeout);
}
thread.scrollTop = undefined;
state.highlightedMessageId = message.id;
timeout = browser.setTimeout(() => this.clearHighlight(), duration);
},
scrollPromise: null,
/**
* Scroll the element into view and expose a promise that will resolved
* once the scroll is done.
*
* @param {Element} el
*/
scrollTo(el) {
state.scrollPromise?.resolve();
const scrollPromise = new Deferred();
state.scrollPromise = scrollPromise;
if ("onscrollend" in window) {
document.addEventListener("scrollend", scrollPromise.resolve, {
capture: true,
once: true,
});
} else {
// To remove when safari will support the "scrollend" event.
setTimeout(scrollPromise.resolve, 250);
}
el.scrollIntoView({ behavior: "smooth", block: "center" });
return scrollPromise;
},
highlightedMessageId: null,
});
return state;
}
export function useSelection({ refName, model, preserveOnClickAwayPredicate = () => false }) {
const ui = useState(useService("ui"));
const ref = useRef(refName);
function onSelectionChange() {
const activeElement = ref.el?.getRootNode().activeElement;
if (activeElement && activeElement === ref.el) {
Object.assign(model, {
start: ref.el.selectionStart,
end: ref.el.selectionEnd,
direction: ref.el.selectionDirection,
});
}
}
onExternalClick(refName, async (ev) => {
if (await preserveOnClickAwayPredicate(ev)) {
return;
}
if (!ref.el) {
return;
}
Object.assign(model, {
start: ref.el.value.length,
end: ref.el.value.length,
direction: ref.el.selectionDirection,
});
});
onMounted(() => {
document.addEventListener("selectionchange", onSelectionChange);
document.addEventListener("input", onSelectionChange);
});
onWillUnmount(() => {
document.removeEventListener("selectionchange", onSelectionChange);
document.removeEventListener("input", onSelectionChange);
});
return {
restore() {
ref.el?.setSelectionRange(model.start, model.end, model.direction);
},
moveCursor(position) {
model.start = model.end = position;
if (!ui.isSmall) {
// In mobile, selection seems to adjust correctly.
// Don't programmatically adjust, otherwise it shows soft keyboard!
ref.el.selectionStart = ref.el.selectionEnd = position;
}
},
};
}
export function useMessageEdition() {
const state = useState({
/** @type {import('@mail/core/common/composer').Composer} */
composerOfThread: null,
/** @type {import('@mail/core/common/message_model').Message} */
editingMessage: null,
exitEditMode() {
state.editingMessage = null;
if (state.composerOfThread) {
state.composerOfThread.props.composer.autofocus++;
}
},
});
return state;
}
/**
* @typedef {Object} MessageToReplyTo
* @property {function} cancel
* @property {function} isNotSelected
* @property {function} isSelected
* @property {import("models").Message|null} message
* @property {import("models").Thread|null} thread
* @property {function} toggle
* @returns {MessageToReplyTo}
*/
export function useMessageToReplyTo() {
return useState({
cancel() {
Object.assign(this, { message: null, thread: null });
},
/**
* @param {import("models").Thread} thread
* @param {import("models").Message} message
* @returns {boolean}
*/
isNotSelected(thread, message) {
return thread.eq(this.thread) && message.notEq(this.message);
},
/**
* @param {import("models").Thread} thread
* @param {import("models").Message} message
* @returns {boolean}
*/
isSelected(thread, message) {
return thread.eq(this.thread) && message.eq(this.message);
},
/** @type {import("models").Message|null} */
message: null,
/** @type {import("models").Thread|null} */
thread: null,
/**
* @param {import("models").Thread} thread
* @param {import("models").Message} message
*/
toggle(thread, message) {
if (message.eq(this.message)) {
this.cancel();
} else {
Object.assign(this, { message, thread });
}
},
});
}
export function useSequential() {
let inProgress = false;
let nextFunction;
let nextResolve;
let nextReject;
async function call() {
const resolve = nextResolve;
const reject = nextReject;
const func = nextFunction;
nextResolve = undefined;
nextReject = undefined;
nextFunction = undefined;
inProgress = true;
try {
const data = await func();
resolve(data);
} catch (e) {
reject(e);
}
inProgress = false;
if (nextFunction && nextResolve) {
call();
}
}
return (func) => {
nextResolve?.();
const prom = new Promise((resolve, reject) => {
nextResolve = resolve;
nextReject = reject;
});
nextFunction = func;
if (!inProgress) {
call();
}
return prom;
};
}
export function useDiscussSystray() {
const ui = useState(useService("ui"));
return {
class: "o-mail-DiscussSystray-class",
get contentClass() {
return `d-flex flex-column flex-grow-1 ${
ui.isSmall ? "overflow-auto w-100 mh-100" : ""
}`;
},
get menuClass() {
return `p-0 o-mail-DiscussSystray ${
ui.isSmall
? "o-mail-systrayFullscreenDropdownMenu start-0 w-100 mh-100 d-flex flex-column mt-0 border-0 shadow-lg"
: ""
}`;
},
};
}
export const useMovable = makeDraggableHook({
name: "useMovable",
onWillStartDrag({ ctx, addCleanup, addStyle, getRect }) {
const { height } = getRect(ctx.current.element);
ctx.current.container = document.createElement("div");
addStyle(ctx.current.container, {
position: "fixed",
top: 0,
bottom: `${height}px`,
left: 0,
right: 0,
});
ctx.current.element.after(ctx.current.container);
addCleanup(() => ctx.current.container.remove());
},
onDrop({ ctx, getRect }) {
const { top, left } = getRect(ctx.current.element);
return { top, left };
},
});