[IMP] core/utils: improve sortable hook with generic draggable hook
X-original-commit: 13745d597d03b552fe5f73ca81143a49efb8aa9f Part-of: odoo/odoo#102153
This commit is contained in:
committed by
Lucas Perais
parent
288623993a
commit
dc7ac937b0
@@ -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<string, boolean>} [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<string, string | number>} 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<any, any>} 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<any, any>} 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];
|
||||
}
|
||||
@@ -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<string, boolean>} [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<string, string | number>} 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<any, any>} 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;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,5 +1,30 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
/**
|
||||
* @typedef Position
|
||||
* @property {number} x
|
||||
* @property {number} y
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {Iterable<HTMLElement>} 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<HTMLElement>} elements
|
||||
* @param {Partial<DOMRect>} 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
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user