diff --git a/addons/web/static/src/core/utils/draggable_hook_builder.js b/addons/web/static/src/core/utils/draggable_hook_builder.js new file mode 100644 index 00000000000..21a7e2d1780 --- /dev/null +++ b/addons/web/static/src/core/utils/draggable_hook_builder.js @@ -0,0 +1,428 @@ +/** @odoo-module **/ + +import { clamp } from "@web/core/utils/numbers"; +import { debounce } from "@web/core/utils/timing"; + +/** + * @typedef Position + * @property {number} x + * @property {number} y + */ + +/** + * @typedef DraggableHookRunningContext + * @property {{ el: HTMLElement | null }} ref + * @property {string | null} [elementSelector=null] + * @property {string | null} [ignoreSelector=null] + * @property {string | null} [fullSelector=null] + * @property {string | null} [cursor=null] + * @property {HTMLElement | null} [currentContainerEl=null] + * @property {DOMRect | null} [currentContainerRect=null] + * @property {HTMLElement | null} [currentElement=null] + * @property {DOMRect | null} [currentElementRect=null] + * @property {boolean} [enabled=false] + * @property {Position} [mouse={ x: 0, y: 0 }] + * @property {Position} [offset={ x: 0, y: 0 }] + */ + +const { useEffect, useEnv, useExternalListener, onWillUnmount, reactive } = owl; + +const DEFAULT_ACCEPTED_PARAMS = { + enable: ["boolean", "function"], + ref: ["object"], + elements: ["string"], + handle: ["string", "function"], + ignore: ["string", "function"], + cursor: ["string"], +}; +const LEFT_CLICK = 0; +const MANDATORY_PARAMS = ["ref", "elements"]; + +/** + * Cancels the default behavior and propagation of a given event. + * @param {Event} ev + */ +function cancelEvent(ev) { + ev.stopPropagation(); + ev.stopImmediatePropagation(); + ev.preventDefault(); +} + +/** + * Converts a CSS pixel value to a number, removing the 'px' part. + * @param {string} val + * @returns {number} + */ +function cssValueToNumber(val) { + return Number(val.slice(0, -2)); +} + +export function makeDraggableHook(hookParams = {}) { + const hookName = hookParams.name || "useAnonymousDraggable"; + const allAcceptedParams = { ...DEFAULT_ACCEPTED_PARAMS, ...hookParams.acceptedParams }; + + /** + * @param {SortableParams} params + * @returns {[string, string | boolean][]} + */ + const computeParams = (params) => { + const computedParams = { enable: true }; + for (const prop in allAcceptedParams) { + if (prop in params) { + computedParams[prop] = params[prop]; + if (typeof params[prop] === "function") { + computedParams[prop] = computedParams[prop](); + } + } + } + return Object.entries(computedParams); + }; + + /** + * Basic error builder for the hook. + * @param {string} reason + * @returns {Error} + */ + const makeError = (reason) => new Error(`Error in hook ${hookName}: ${reason}.`); + + return { + [hookName](params) { + /** + * Adds an event listener to be cleaned up after the next drag sequence + * has stopped. An additionnal `timeout` param allows the handler to be + * delayed after a timeout. + * @param {EventTarget} el + * @param {string} event + * @param {(...args: any[]) => any} callback + * @param {boolean | Record} [options] + */ + const addListener = (el, event, callback, options) => { + el.addEventListener(event, callback, options); + cleanups.push(() => el.removeEventListener(event, callback, options)); + }; + + /** + * Adds style to an element to be cleaned up after the next drag sequence has + * stopped. + * @param {HTMLElement} el + * @param {Record} style + */ + const addStyle = (el, style) => { + const originalStyle = el.getAttribute("style"); + cleanups.push(() => + originalStyle + ? el.setAttribute("style", originalStyle) + : el.removeAttribute("style") + ); + for (const key in style) { + el.style[key] = style[key]; + } + }; + + /** + * Main entry function to start a drag sequence. + */ + const dragStart = () => { + state.dragging = true; + + // Calculates the bounding rectangles of the current element, and of the + // container element (`parentElement` or `ref.el`). + const containerStyle = getComputedStyle(ctx.currentContainerEl); + const [pleft, pright, ptop, pbottom] = [ + "padding-left", + "padding-right", + "padding-top", + "padding-bottom", + ].map((prop) => cssValueToNumber(containerStyle.getPropertyValue(prop))); + + ctx.currentElementRect = ctx.currentElement.getBoundingClientRect(); + ctx.currentContainerRect = ctx.currentContainerEl.getBoundingClientRect(); + const { x, y, width, height } = ctx.currentElementRect; + + // Reduces the container's dimensions according to its padding. + ctx.currentContainerRect.x += pleft; + ctx.currentContainerRect.width -= pleft + pright; + ctx.currentContainerRect.y += ptop; + ctx.currentContainerRect.height -= ptop + pbottom; + + // Binds handlers on eligible elements + for (const siblingEl of ctx.ref.el.querySelectorAll(ctx.elementSelector)) { + if (siblingEl !== ctx.currentElement) { + addStyle(siblingEl, { "pointer-events": "auto" }); + } + } + + // Adjusts the offset + ctx.offset.x -= x; + ctx.offset.y -= y; + + addStyle(ctx.currentElement, { + position: "fixed", + "pointer-events": "none", + "z-index": 1000, + width: `${width}px`, + height: `${height}px`, + left: `${x}px`, + top: `${y}px`, + }); + + const bodyStyle = { + "pointer-events": "none", + "user-select": "none", + }; + if (ctx.cursor) { + bodyStyle.cursor = ctx.cursor; + } + + addStyle(document.body, bodyStyle); + + execBuildHandler("onDragStart"); + }; + + /** + * Main exit function to stop a drag sequence. Note that it can be called + * even if a drag sequence did not start yet to perform a cleanup of all + * current context variables. + * @param {boolean} cancelled + * @param {boolean} [inErrorState] can be set to true when an error + * occurred to avoid falling into an infinite loop if the error + * originated from one of the handlers. + */ + const dragEnd = (cancelled, inErrorState) => { + if (state.dragging) { + if (!inErrorState) { + execBuildHandler("onDragEnd"); + if (!cancelled) { + execBuildHandler("onDrop"); + } + } + } + + execBuildHandler("onCleanup"); + + // Performs all registered clean-ups. + while (cleanups.length) { + cleanups.pop()(); + } + + ctx.currentElement = null; + ctx.currentElementRect = null; + + ctx.currentContainerEl = null; + ctx.currentContainerRect = null; + + state.dragging = false; + }; + + /** + * Executes a handler from the `hookParams`. + * @param {string} fnName + * @param {Record} arg + */ + const execBuildHandler = (fnName, arg) => { + if (typeof hookParams[fnName] === "function") { + hookParams[fnName]({ ctx, helpers: buildHelpers, ...arg }); + } + }; + + /** + * Safely executes a handler from the `params`, so that the drag sequence can + * be interrupted if an error occurs. + * @param {string} callbackName + * @param {Record} arg + */ + const execHandler = (callbackName, arg) => { + if (typeof params[callbackName] === "function") { + try { + params[callbackName]({ ...ctx.mouse, ...arg }); + } catch (err) { + dragEnd(true, true); + throw err; + } + } + }; + + /** + * Window "keydown" event handler. + * @param {KeyboardEvent} ev + */ + const onKeydown = (ev) => { + if (!ctx.enabled || !state.dragging) { + return; + } + switch (ev.key) { + case "Escape": + case "Tab": { + cancelEvent(ev); + dragEnd(true); + } + } + }; + + /** + * Global (= ref) "mousedown" event handler. + * @param {MouseEvent} ev + */ + const onMousedown = (ev) => { + updateMousePosition(ev); + + // A drag sequence can still be in progress if the mouseup occurred + // outside of the window. + dragEnd(true); + + if ( + ev.button !== LEFT_CLICK || + !ctx.enabled || + !ev.target.closest(ctx.fullSelector) || + (ctx.ignoreSelector && ev.target.closest(ctx.ignoreSelector)) + ) { + return; + } + + ctx.currentElement = ev.target.closest(ctx.elementSelector); + ctx.currentContainerEl = ctx.ref.el; + + Object.assign(ctx.offset, ctx.mouse); + + execBuildHandler("onWillStartDrag"); + }; + + /** + * Window "mousemove" event handler. + * @param {MouseEvent} ev + */ + const onMousemove = (ev) => { + updateMousePosition(ev); + + if (!ctx.enabled || !ctx.currentElement) { + return; + } + if (!state.dragging) { + dragStart(); + } + if (state.dragging) { + // Updates the position of the dragged element. + ctx.currentElement.style.left = `${clamp( + ctx.mouse.x - ctx.offset.x, + ctx.currentContainerRect.x, + ctx.currentContainerRect.x + + ctx.currentContainerRect.width - + ctx.currentElementRect.width + )}px`; + ctx.currentElement.style.top = `${clamp( + ctx.mouse.y - ctx.offset.y, + ctx.currentContainerRect.y, + ctx.currentContainerRect.y + + ctx.currentContainerRect.height - + ctx.currentElementRect.height + )}px`; + + execBuildHandler("onDrag"); + } + }; + + /** + * Window "mouseup" event handler. + * @param {MouseEvent} ev + */ + const onMouseup = (ev) => { + updateMousePosition(ev); + dragEnd(false); + }; + + /** + * Updates the current mouse position from a given event. + * @param {MouseEvent} ev + */ + const updateMousePosition = (ev) => { + ctx.mouse.x = ev.clientX; + ctx.mouse.y = ev.clientY; + }; + + // Component infos + const env = useEnv(); + const state = reactive({ dragging: false }); + + // Basic error handling asserting that the parameters are valid. + for (const prop in allAcceptedParams) { + if (params[prop] && !allAcceptedParams[prop].includes(typeof params[prop])) { + throw makeError(`invalid type for property "${prop}" in parameters`); + } else if (!params[prop] && MANDATORY_PARAMS.includes(prop)) { + throw makeError(`missing required property "${prop}" in parameters`); + } + } + + // Build helpers + const buildHelpers = { addListener, addStyle, execHandler }; + + /** @type {(() => any)[]} */ + const cleanups = []; + + /** @type {DraggableHookRunningContext} */ + const ctx = { + ref: params.ref, + ignoreSelector: null, + fullSelector: null, + cursor: null, + currentContainerEl: null, + currentContainerRect: null, + currentElement: null, + currentElementRect: null, + enabled: false, + mouse: { x: 0, y: 0 }, + offset: { x: 0, y: 0 }, + }; + + execBuildHandler("onBuildSetup"); + + // Effect depending on the params to update them. + useEffect( + (...deps) => { + const actualParams = Object.fromEntries(deps); + ctx.enabled = Boolean(ctx.ref.el && !env.isSmall && actualParams.enable); + if (!ctx.enabled) { + return; + } + + // Selectors + ctx.elementSelector = actualParams.elements; + if (!ctx.elementSelector) { + throw makeError( + `no value found by "elements" selector: ${ctx.elementSelector}` + ); + } + const allSelectors = [ctx.elementSelector]; + ctx.cursor = actualParams.cursor || null; + if (actualParams.handle) { + allSelectors.push(actualParams.handle); + } + if (actualParams.ignore) { + ctx.ignoreSelector = actualParams.ignore; + } + ctx.fullSelector = allSelectors.join(" "); + + execBuildHandler("onComputeParams", { params: actualParams }); + }, + () => computeParams(params) + ); + // Effect depending on the `ref.el` to add triggering mouse events listener. + useEffect( + (el) => { + if (el) { + el.addEventListener("mousedown", onMousedown); + return () => el.removeEventListener("mousedown", onMousedown); + } + }, + () => [ctx.ref.el] + ); + // Other global mouse event listeners. + const debouncedMousemove = debounce(onMousemove, "animationFrame", true); + useExternalListener(window, "mousemove", debouncedMousemove); + useExternalListener(window, "mouseup", onMouseup); + useExternalListener(window, "keydown", onKeydown, true); + onWillUnmount(() => dragEnd(true)); + + return state; + }, + }[hookName]; +} diff --git a/addons/web/static/src/core/utils/sortable.js b/addons/web/static/src/core/utils/sortable.js index 84422f776ba..62f023f5fc4 100644 --- a/addons/web/static/src/core/utils/sortable.js +++ b/addons/web/static/src/core/utils/sortable.js @@ -1,9 +1,6 @@ /** @odoo-module **/ -import { clamp } from "@web/core/utils/numbers"; -import { debounce } from "@web/core/utils/timing"; - -const { useEffect, useEnv, useExternalListener, onWillUnmount } = owl; +import { makeDraggableHook } from "@web/core/utils/draggable_hook_builder"; /** * @typedef SortableParams @@ -57,512 +54,144 @@ const { useEffect, useEnv, useExternalListener, onWillUnmount } = owl; * @property {HTMLElement | null} parent */ -const LEFT_CLICK = 0; -const MANDATORY_SORTABLE_PARAMS = ["ref", "elements"]; -const SORTABLE_PARAMS = { - enable: ["boolean", "function"], - ref: ["object"], - elements: ["string"], - groups: ["string", "function"], - handle: ["string", "function"], - ignore: ["string", "function"], - connectGroups: ["boolean", "function"], - cursor: ["string"], -}; +export const useSortable = makeDraggableHook({ + name: "useSortable", + acceptedParams: { + groups: ["string", "function"], + connectGroups: ["boolean", "function"], + }, -/** - * Cancels the default behavior and propagation of a given event. - * @param {Event} ev - */ -function cancelEvent(ev) { - ev.stopPropagation(); - ev.stopImmediatePropagation(); - ev.preventDefault(); -} + // Build steps + onBuildSetup({ ctx }) { + Object.assign(ctx, { + connectGroups: false, + currentGroup: null, + ghostElement: null, + groupSelector: null, + }); + }, + onComputeParams({ ctx, params }) { + // Group selector + ctx.groupSelector = params.groups || null; + if (ctx.groupSelector) { + ctx.fullSelector = [ctx.groupSelector, ctx.fullSelector].join(" "); + } -/** - * @param {SortableParams} params - * @returns {[string, string | boolean][]} - */ -function computeParams(params) { - const computedParams = { enable: true }; - for (const prop in SORTABLE_PARAMS) { - if (prop in params) { - computedParams[prop] = params[prop]; - if (typeof params[prop] === "function") { - computedParams[prop] = computedParams[prop](); + // Connection accross groups + ctx.connectGroups = params.connectGroups; + }, + + // Runtime steps + onDragStart({ ctx, helpers }) { + /** + * Element "mouseenter" event handler. + * @param {MouseEvent} ev + */ + const onElementMouseenter = (ev) => { + const element = ev.currentTarget; + if ( + ctx.connectGroups || + !ctx.groupSelector || + ctx.currentGroup === element.closest(ctx.groupSelector) + ) { + const pos = ctx.ghostElement.compareDocumentPosition(element); + if (pos === 2 /* BEFORE */) { + element.before(ctx.ghostElement); + } else if (pos === 4 /* AFTER */) { + element.after(ctx.ghostElement); + } } - } - } - return Object.entries(computedParams); -} + helpers.execHandler("onElementEnter", { element }); + }; -/** - * Converts a CSS pixel value to a number, removing the 'px' part. - * @param {string} val - * @returns {number} - */ -function cssValueToNumber(val) { - return Number(val.slice(0, -2)); -} + /** + * Element "mouseleave" event handler. + * @param {MouseEvent} ev + */ + const onElementMouseleave = (ev) => { + const element = ev.currentTarget; + helpers.execHandler("onElementLeave", { element }); + }; -/** - * Basic error builder for the sortable hook. - * @param {string} reason - * @returns {Error} - */ -function sortableError(reason) { - return new Error(`Unable to use sortable feature: ${reason}.`); -} + /** + * Group "mouseenter" event handler. + * @param {MouseEvent} ev + */ + const onGroupMouseenter = (ev) => { + const group = ev.currentTarget; + group.appendChild(ctx.ghostElement); + helpers.execHandler("onGroupEnter", { group }); + }; -/** - * Sortable feature hook. - * - * This hook needs 2 things to work: - * - * 1) a `ref` object (@see owl.useRef) which will be used as the root element to - * calculate boundaries of dragged elements; - * - * 2) an `elements` selector string or function that will determine which elements - * are sortable in the reference element. - * - * All other parameters are optional and define the constraints of the dragged elements - * (and the appearance of the cursor during a dragging sequence), or the different - * available handlers triggered during the drag sequence. - * @see SortableParams - * - * @param {SortableParams} params - */ -export function useSortable(params) { - const env = useEnv(); - const { ref } = params; - /** @type {(() => any)[]} */ - const cleanups = []; + /** + * Group "mouseleave" event handler. + * @param {MouseEvent} ev + */ + const onGroupMouseleave = (ev) => { + const group = ev.currentTarget; + helpers.execHandler("onGroupLeave", { group }); + }; - // Basic error handling asserting that the parameters are valid. - for (const prop in SORTABLE_PARAMS) { - if (params[prop] && !SORTABLE_PARAMS[prop].includes(typeof params[prop])) { - throw sortableError(`invalid type for property "${prop}" in parameters`); - } else if (!params[prop] && MANDATORY_SORTABLE_PARAMS.includes(prop)) { - throw sortableError(`missing required property "${prop}" in parameters`); - } - } - - /** - * Stores the current element selector. - * @type {string | null} - */ - let groupSelector = null; - /** - * Stores the current group selector (optional). - * @type {string | null} - */ - let elementSelector = null; - /** - * Stores the full selector used to initiate a drag sequence. - * @type {string | null} - */ - let ignoreSelector = null; - /** - * Stores the full selector used to initiate a drag sequence. - * @type {string | null} - */ - let fullSelector = null; - - /** - * Stores the style of the cursor, if defined. - * @type {string | null} - */ - let cursor = null; - /** - * Stores whether the sortable elements can be dragged in different groups. - * @type {boolean} - */ - let connectGroups = false; - /** - * Stores the position and dimensions of the confining element (ref or - * parent). - * @type {DOMRect | null} - */ - let currentContainerRect = null; - - /** - * Stores the current dragged element. - * @type {HTMLElement | null} - */ - let currentElement = null; - /** - * Stores the dimensions and position of the dragged element. - * @type {DOMRect | null} - */ - let currentElementRect = null; - /** - * Stores the group in which the current element originated. - * @type {HTMLElement | null} - */ - let currentGroup = null; - /** - * Stores the ghost element taking place of the actual dragged element. - * @type {HTMLElement | null} - */ - let ghost = null; - - /** - * Stores whether a drag sequence can be initiated. - * This is determined by both the given ref being in the document and the - * `setup` function returning the required params (namely: `elements`). - * @type {boolean} - */ - let enabled = false; - /** - * Stores whether a drag sequence has been initiated. - * @type {boolean} - */ - let started = false; - - /** - * These 2 variables store the initial offset between the initial mousedown - * position and the top-left corner of the dragged element. - */ - /** @type {number} */ - let offsetX = 0; - /** @type {number} */ - let offsetY = 0; - - /** - * Adds an event listener to be cleaned up after the next drag sequence - * has stopped. An additionnal `timeout` param allows the handler to be - * delayed after a timeout. - * @param {EventTarget} el - * @param {string} event - * @param {(...args: any[]) => any} callback - * @param {boolean | Record} [options] - */ - const addListener = (el, event, callback, options) => { - el.addEventListener(event, callback, options); - cleanups.push(() => el.removeEventListener(event, callback, options)); - }; - - /** - * Adds style to an element to be cleaned up after the next drag sequence has - * stopped. - * @param {HTMLElement} el - * @param {Record} style - */ - const addStyle = (el, style) => { - const originalStyle = el.getAttribute("style"); - cleanups.push(() => - originalStyle ? el.setAttribute("style", originalStyle) : el.removeAttribute("style") - ); - for (const key in style) { - el.style[key] = style[key]; - } - }; - - /** - * Safely executes a handler from the `params`, so that the drag sequence can - * be interrupted if an error occurs. - * @param {string} callbackName - * @param {Record} arg - */ - const execHandler = (callbackName, arg) => { - if (typeof params[callbackName] === "function") { - try { - params[callbackName](arg); - } catch (err) { - dragStop(true, true); - throw err; - } - } - }; - - /** - * Element "mouseenter" event handler. - * @param {MouseEvent} ev - */ - const onElementMouseenter = (ev) => { - const element = ev.currentTarget; - if (connectGroups || !groupSelector || currentGroup === element.closest(groupSelector)) { - const pos = ghost.compareDocumentPosition(element); - if (pos === 2 /* BEFORE */) { - element.before(ghost); - } else if (pos === 4 /* AFTER */) { - element.after(ghost); - } - } - execHandler("onElementEnter", { element }); - }; - - /** - * Element "mouseleave" event handler. - * @param {MouseEvent} ev - */ - const onElementMouseleave = (ev) => { - const element = ev.currentTarget; - execHandler("onElementLeave", { element }); - }; - - /** - * Group "mouseenter" event handler. - * @param {MouseEvent} ev - */ - const onGroupMouseenter = (ev) => { - const group = ev.currentTarget; - group.appendChild(ghost); - execHandler("onGroupEnter", { group }); - }; - - /** - * Group "mouseleave" event handler. - * @param {MouseEvent} ev - */ - const onGroupMouseleave = (ev) => { - const group = ev.currentTarget; - execHandler("onGroupLeave", { group }); - }; - - /** - * Window "keydown" event handler. - * @param {KeyboardEvent} ev - */ - const onKeydown = (ev) => { - if (!enabled || !started) { - return; - } - switch (ev.key) { - case "Escape": - case "Tab": { - cancelEvent(ev); - dragStop(true); - } - } - }; - - /** - * Global (= ref) "mousedown" event handler. - * @param {MouseEvent} ev - */ - const onMousedown = (ev) => { - // A drag sequence can still be in progress if the mouseup occurred - // outside of the window. - dragStop(true); - - if ( - ev.button !== LEFT_CLICK || - !enabled || - !ev.target.closest(fullSelector) || - (ignoreSelector && ev.target.closest(ignoreSelector)) - ) { - return; - } - - currentElement = ev.target.closest(elementSelector); - currentGroup = groupSelector && ev.target.closest(groupSelector); - offsetX = ev.clientX; - offsetY = ev.clientY; - }; - - /** - * Window "mousemove" event handler. - * @param {MouseEvent} ev - */ - const onMousemove = (ev) => { - if (!enabled || !currentElement) { - return; - } - if (started) { - // Updates the position of the dragged element. - currentElement.style.left = `${clamp( - ev.clientX - offsetX, - currentContainerRect.x, - currentContainerRect.x + currentContainerRect.width - currentElementRect.width - )}px`; - currentElement.style.top = `${clamp( - ev.clientY - offsetY, - currentContainerRect.y, - currentContainerRect.y + currentContainerRect.height - currentElementRect.height - )}px`; - } else { - dragStart(); - } - }; - - /** - * Window "mouseup" event handler. - */ - const onMouseup = () => dragStop(false); - - /** - * Main entry function to start a drag sequence. - */ - const dragStart = () => { - started = true; - - // Calculates the bounding rectangles of the current element, and of the - // container element (`parentElement` or `ref.el`). - const container = connectGroups || !groupSelector ? ref.el : currentGroup; - const containerStyle = getComputedStyle(container); - const [pleft, pright, ptop, pbottom] = [ - "padding-left", - "padding-right", - "padding-top", - "padding-bottom", - ].map((prop) => cssValueToNumber(containerStyle.getPropertyValue(prop))); - - currentElementRect = currentElement.getBoundingClientRect(); - currentContainerRect = container.getBoundingClientRect(); - const { x, y, width, height } = currentElementRect; - - // Reduces the container's dimensions according to its padding. - currentContainerRect.x += pleft; - currentContainerRect.width -= pleft + pright; - currentContainerRect.y += ptop; - currentContainerRect.height -= ptop + pbottom; + const { width, height } = ctx.currentElementRect; // Prepares the ghost element - ghost = currentElement.cloneNode(false); - ghost.style = `visibility: hidden; display: block; width: ${width}px; height:${height}px;`; - cleanups.push(() => ghost.remove()); + ctx.ghostElement = ctx.currentElement.cloneNode(false); + ctx.ghostElement.style = `visibility: hidden; display: block; width: ${width}px; height:${height}px;`; // Binds handlers on eligible groups, if the elements are not confined to // their parents and a 'groupSelector' has been provided. - if (connectGroups && groupSelector) { - for (const siblingGroup of ref.el.querySelectorAll(groupSelector)) { - addListener(siblingGroup, "mouseenter", onGroupMouseenter); - addListener(siblingGroup, "mouseleave", onGroupMouseleave); - addStyle(siblingGroup, { "pointer-events": "auto" }); + if (ctx.connectGroups && ctx.groupSelector) { + for (const siblingGroup of ctx.ref.el.querySelectorAll(ctx.groupSelector)) { + helpers.addListener(siblingGroup, "mouseenter", onGroupMouseenter); + helpers.addListener(siblingGroup, "mouseleave", onGroupMouseleave); + helpers.addStyle(siblingGroup, { "pointer-events": "auto" }); } } // Binds handlers on eligible elements - for (const siblingElement of ref.el.querySelectorAll(elementSelector)) { - if (siblingElement !== currentElement && siblingElement !== ghost) { - addListener(siblingElement, "mouseenter", onElementMouseenter); - addListener(siblingElement, "mouseleave", onElementMouseleave); - addStyle(siblingElement, { "pointer-events": "auto" }); + for (const siblingEl of ctx.ref.el.querySelectorAll(ctx.elementSelector)) { + if (siblingEl !== ctx.currentElement && siblingEl !== ctx.ghostElement) { + helpers.addListener(siblingEl, "mouseenter", onElementMouseenter); + helpers.addListener(siblingEl, "mouseleave", onElementMouseleave); } } - execHandler("onStart", { element: currentElement, group: currentGroup }); - // Ghost is initially added right after the current element. - currentElement.after(ghost); + ctx.currentElement.after(ctx.ghostElement); - // Adjusts the offset - offsetX -= x; - offsetY -= y; - - addStyle(currentElement, { - position: "fixed", - "pointer-events": "none", - "z-index": 1000, - width: `${width}px`, - height: `${height}px`, - left: `${x}px`, - top: `${y}px`, - }); - - const bodyStyle = { - "pointer-events": "none", - "user-select": "none", - }; - if (cursor) { - bodyStyle.cursor = cursor; + // Calls "onStart" handler + helpers.execHandler("onStart", { element: ctx.currentElement, group: ctx.currentGroup }); + }, + onDragEnd({ ctx, helpers }) { + helpers.execHandler("onStop", { element: ctx.currentElement, group: ctx.currentGroup }); + }, + onDrop({ ctx, helpers }) { + const previous = ctx.ghostElement.previousElementSibling; + const next = ctx.ghostElement.nextElementSibling; + if (previous !== ctx.currentElement && next !== ctx.currentElement) { + helpers.execHandler("onDrop", { + element: ctx.currentElement, + group: ctx.currentGroup, + previous, + next, + parent: ctx.groupSelector && ctx.ghostElement.closest(ctx.groupSelector), + }); } - addStyle(document.body, bodyStyle); - }; - - /** - * Main exit function to stop a drag sequence. Note that it can be called - * even if a drag sequence did not start yet to perform a cleanup of all - * current context variables. - * @param {boolean} cancelled - * @param {boolean} [inErrorState] can be set to true when an error - * occurred to avoid falling into an infinite loop if the error - * originated from one of the handlers. - */ - const dragStop = (cancelled, inErrorState) => { - if (started) { - if (!inErrorState) { - execHandler("onStop", { element: currentElement, group: currentGroup }); - const previous = ghost.previousElementSibling; - const next = ghost.nextElementSibling; - if (!cancelled && previous !== currentElement && next !== currentElement) { - execHandler("onDrop", { - element: currentElement, - group: currentGroup, - previous, - next, - parent: groupSelector && ghost.closest(groupSelector), - }); - } + }, + onWillStartDrag({ ctx }) { + if (ctx.groupSelector) { + ctx.currentGroup = ctx.currentElement.closest(ctx.groupSelector); + if (!ctx.connectGroups) { + ctx.currentContainerEl = ctx.currentGroup; } } - - // Performes all registered clean-ups. - while (cleanups.length) { - cleanups.pop()(); + }, + onCleanup({ ctx }) { + if (ctx.ghostElement) { + ctx.ghostElement.remove(); } - currentContainerRect = null; - - currentElement = null; - currentElementRect = null; - currentGroup = null; - ghost = null; - - started = false; - }; - - // OWL HOOKS - - // Effect depending on the params to update them. - useEffect( - (...deps) => { - const actualParams = Object.fromEntries(deps); - enabled = Boolean(ref.el && !env.isSmall && actualParams.enable); - if (!enabled) { - return; - } - - // Selectors - elementSelector = actualParams.elements; - groupSelector = actualParams.groups || null; - if (!elementSelector) { - throw sortableError(`no value found by "elements" selector: ${elementSelector}`); - } - const allSelectors = [elementSelector]; - cursor = actualParams.cursor; - if (groupSelector) { - allSelectors.unshift(groupSelector); - } - if (actualParams.handle) { - allSelectors.push(actualParams.handle); - } - if (actualParams.ignore) { - ignoreSelector = actualParams.ignore; - } - fullSelector = allSelectors.join(" "); - - // Connection accross groups - connectGroups = actualParams.connectGroups; - }, - () => computeParams(params) - ); - // Effect depending on the `ref.el` to add triggering mouse events listener. - useEffect( - (el) => { - if (el) { - el.addEventListener("mousedown", onMousedown); - return () => el.removeEventListener("mousedown", onMousedown); - } - }, - () => [ref.el] - ); - // Other global mouse event listeners. - const debouncedMousemove = debounce(onMousemove, "animationFrame", true); - useExternalListener(window, "mousemove", debouncedMousemove); - useExternalListener(window, "mouseup", onMouseup); - useExternalListener(window, "keydown", onKeydown, true); - onWillUnmount(() => dragStop(true)); -} + ctx.currentGroup = null; + ctx.ghostElement = null; + }, +}); diff --git a/addons/web/static/src/core/utils/ui.js b/addons/web/static/src/core/utils/ui.js index 0126c5ee641..891f0a70b58 100644 --- a/addons/web/static/src/core/utils/ui.js +++ b/addons/web/static/src/core/utils/ui.js @@ -1,5 +1,30 @@ /** @odoo-module **/ +/** + * @typedef Position + * @property {number} x + * @property {number} y + */ + +/** + * @param {Iterable} elements + * @param {Position} targetPos + * @returns {HTMLElement | null} + */ +export function closest(elements, targetPos) { + let closestEl = null; + let closestDistance = Infinity; + for (const el of elements) { + const rect = el.getBoundingClientRect(); + const distance = getQuadrance(rect, targetPos); + if (!closestEl || distance < closestDistance) { + closestEl = el; + closestDistance = distance; + } + } + return closestEl; +} + /** * rough approximation of a visible element. not perfect (does not take into * account opacity = 0 for example), but good enough for our purpose @@ -68,6 +93,26 @@ export function _legacyIsVisible(el) { return _isVisible; } +/** + * @param {DOMRect} rect + * @param {Position} pos + * @returns {number} + */ +export function getQuadrance(rect, pos) { + let q = 0; + if (pos.x < rect.x) { + q += (rect.x - pos.x) ** 2; + } else if (rect.x + rect.width < pos.x) { + q += (pos.x - (rect.x + rect.width)) ** 2; + } + if (pos.y < rect.y) { + q += (rect.y - pos.y) ** 2; + } else if (rect.y + rect.height < pos.y) { + q += (pos.y - (rect.y + rect.height)) ** 2; + } + return q; +} + /** * @param {Element} activeElement * @param {String} selector @@ -85,6 +130,24 @@ export function getVisibleElements(activeElement, selector) { return visibleElements; } +/** + * @param {Iterable} elements + * @param {Partial} targetRect + * @returns {HTMLElement[]} + */ +export function touching(elements, targetRect) { + const r1 = { x: 0, y: 0, width: 0, height: 0, ...targetRect }; + return [...elements].filter((el) => { + const r2 = el.getBoundingClientRect(); + return ( + r2.x + r2.width >= r1.x && + r2.x <= r1.x + r1.width && + r2.y + r2.height >= r1.y && + r2.y <= r1.y + r1.height + ); + }); +} + // ----------------------------------------------------------------------------- // Get Tabable Elements // -----------------------------------------------------------------------------