[IMP] core/utils: improve sortable hook with generic draggable hook

X-original-commit: 13745d597d03b552fe5f73ca81143a49efb8aa9f
Part-of: odoo/odoo#102153
This commit is contained in:
Julien Mougenot
2022-10-04 20:57:59 +02:00
committed by Lucas Perais
parent 288623993a
commit dc7ac937b0
3 changed files with 607 additions and 487 deletions
@@ -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];
}
+116 -487
View File
@@ -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;
},
});
+63
View File
@@ -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
// -----------------------------------------------------------------------------