505 lines
16 KiB
JavaScript
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 };
|
|
},
|
|
});
|