UNPKG

@react-querybuilder/dnd

Version:

Drag-and-drop-enabled version of react-querybuilder

679 lines (669 loc) 21.9 kB
import * as React from "react"; import { createContext, useContext, useEffect, useMemo, useRef, useState } from "react"; import { QueryBuilderContext, TestID, add, defaultControlElements, findPath, getParentPath, group, insert, isAncestor, lc, messages, pathsAreEqual, preferAnyProp, preferProp, standardClassnames, useMergedContext } from "react-querybuilder"; //#region src/isHotkeyPressed.ts /** * Adapted from * https://github.com/JohannesKlauss/react-hotkeys-hook/blob/bc55a281f1d212d09de786aeb5cd236c58d9531d/src/isHotkeyPressed.ts * and * https://github.com/JohannesKlauss/react-hotkeys-hook/blob/bc55a281f1d212d09de786aeb5cd236c58d9531d/src/parseHotkey.ts */ const reservedModifierKeywords = new Set([ "shift", "alt", "meta", "mod", "ctrl" ]); const mappedKeys = { esc: "escape", return: "enter", ".": "period", ",": "comma", "-": "slash", " ": "space", "`": "backquote", "#": "backslash", "+": "bracketright", ShiftLeft: "shift", ShiftRight: "shift", AltLeft: "alt", AltRight: "alt", MetaLeft: "meta", MetaRight: "meta", OSLeft: "meta", OSRight: "meta", ControlLeft: "ctrl", ControlRight: "ctrl" }; const mapKey = (key) => lc((key && mappedKeys[key] || key || "").trim()).replace(/key|digit|numpad|arrow/, ""); const isHotkeyModifier = (key) => reservedModifierKeywords.has(key); const keyAliases = { "⌘": "meta", cmd: "meta", command: "meta", "⊞": "meta", win: "meta", windows: "meta", "⇧": "shift", "⌥": "alt", "⌃": "ctrl", control: "ctrl" }; (() => { if (typeof document !== "undefined") { document.addEventListener("keydown", (e) => { if (e.key === void 0) return; pushToCurrentlyPressedKeys([mapKey(e.key), mapKey(e.code)]); }); document.addEventListener("keyup", (e) => { if (e.key === void 0) return; removeFromCurrentlyPressedKeys([mapKey(e.key), mapKey(e.code)]); }); } if (typeof window !== "undefined") window.addEventListener("blur", () => { currentlyPressedKeys.clear(); }); })(); const currentlyPressedKeys = /* @__PURE__ */ new Set(); const isReadonlyArray = (value) => Array.isArray(value); const isHotkeyPressed = (key, splitKey = ",") => (isReadonlyArray(key) ? key : key.split(splitKey)).every((hotkey) => { const hk = lc(hotkey.trim()); return currentlyPressedKeys.has(keyAliases[hk] ?? hk); }); const pushToCurrentlyPressedKeys = (key) => { const hotkeyArray = Array.isArray(key) ? key : [key]; if (currentlyPressedKeys.has("meta")) { for (const key$1 of currentlyPressedKeys) if (!isHotkeyModifier(key$1)) currentlyPressedKeys.delete(lc(key$1)); } for (const hotkey of hotkeyArray) currentlyPressedKeys.add(lc(hotkey)); }; const removeFromCurrentlyPressedKeys = (key) => { const hotkeyArray = Array.isArray(key) ? key : [key]; if (key === "meta") currentlyPressedKeys.clear(); else for (const hotkey of hotkeyArray) currentlyPressedKeys.delete(lc(hotkey)); }; //#endregion //#region src/QueryBuilderDndContext.ts const { rule, ruleGroup, combinatorSelector } = defaultControlElements; /** * @group Components */ const QueryBuilderDndContext = createContext({ baseControls: { rule, ruleGroup, combinatorSelector } }); //#endregion //#region src/InlineCombinatorDnD.tsx /** * The drag-and-drop-enabled inline combinator component. * * @group Components */ const InlineCombinatorDnD = ({ component: CombinatorSelectorComponent, ...props }) => { const { canDrop, useDrop, copyModeModifierKey, groupModeModifierKey } = useContext(QueryBuilderDndContext); const { dropRef, dropMonitorId, isOver } = useInlineCombinatorDnD({ ...props, component: CombinatorSelectorComponent, useDrop, canDrop, copyModeModifierKey, groupModeModifierKey }); const wrapperClassName = [ props.schema.suppressStandardClassnames || standardClassnames.betweenRules, isOver && !props.schema.classNames.dndOver || false, isOver && !props.schema.suppressStandardClassnames && standardClassnames.dndOver || false ].filter((c) => typeof c === "string").join(" "); return /* @__PURE__ */ React.createElement("div", { key: "dnd", ref: dropRef, className: wrapperClassName, "data-dropmonitorid": dropMonitorId, "data-testid": TestID.inlineCombinator }, /* @__PURE__ */ React.createElement(CombinatorSelectorComponent, { ...props, testID: TestID.combinators })); }; /** * @group Hooks */ const useInlineCombinatorDnD = (params) => { const dropRef = useRef(null); const { path, canDrop, schema, useDrop, rules, copyModeModifierKey = "alt", groupModeModifierKey = "ctrl" } = params; const hoveringItem = (rules ?? [])[path.at(-1) - 1]; const [{ isOver, dropMonitorId, dropEffect, dropNotAllowed }, drop] = useDrop(() => ({ accept: ["rule", "ruleGroup"], canDrop: (dragging) => { const { path: itemPath } = dragging; if (isHotkeyPressed(groupModeModifierKey) || dragging && typeof canDrop === "function" && !canDrop({ dragging, hovering: { ...hoveringItem, path, qbId: schema.qbId } })) return false; const parentHoverPath = getParentPath(path); const parentItemPath = getParentPath(itemPath); const hoverIndex = path.at(-1); const itemIndex = itemPath.at(-1); return !(isAncestor(itemPath, path) || pathsAreEqual(itemPath, path) || pathsAreEqual(parentHoverPath, parentItemPath) && hoverIndex - 1 === itemIndex || schema.independentCombinators && pathsAreEqual(parentHoverPath, parentItemPath) && hoverIndex === itemIndex - 1); }, collect: (monitor) => ({ dropNotAllowed: monitor.isOver() && !monitor.canDrop(), isOver: monitor.canDrop() && monitor.isOver(), dropMonitorId: monitor.getHandlerId() ?? "", dropEffect: isHotkeyPressed(copyModeModifierKey) ? "copy" : "move", groupItems: isHotkeyPressed(groupModeModifierKey) }), drop: () => { const { qbId, getQuery, dispatchQuery } = schema; const dropEffect$1 = isHotkeyPressed(copyModeModifierKey) ? "copy" : "move"; return { type: "inlineCombinator", path, qbId, getQuery, dispatchQuery, groupItems: isHotkeyPressed(groupModeModifierKey), dropEffect: dropEffect$1 }; } }), [ canDrop, hoveringItem, path, schema ]); drop(dropRef); return { dropRef, dropMonitorId, isOver, dropEffect, dropNotAllowed }; }; //#endregion //#region src/isTouchDevice.ts /* istanbul ignore file */ const isTouchDevice = () => typeof window !== "undefined" && "ontouchstart" in window || typeof navigator !== "undefined" && navigator.maxTouchPoints > 0; //#endregion //#region src/getEmptyImage.ts let emptyImage; const getEmptyImage = () => { if (!emptyImage) { emptyImage = new Image(); emptyImage.src = "data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=="; } return emptyImage; }; //#endregion //#region src/useDragCommon.ts /** * @group Hooks */ const useDragCommon = ({ type, path, disabled, actions, schema, useDrag, copyModeModifierKey, groupModeModifierKey, hideDefaultDragPreview }) => useDrag(() => ({ type, item: () => ({ ...findPath(path, schema.getQuery()), path, qbId: schema.qbId }), canDrag: !disabled, previewOptions: { captureDraggingState: !!hideDefaultDragPreview }, collect: (monitor) => ({ isDragging: !disabled && monitor.isDragging(), dragMonitorId: monitor.getHandlerId() ?? "" }), end: (item, monitor) => { const dropResult = monitor.getDropResult(); if (!dropResult) return; const dropEffect = isHotkeyPressed(copyModeModifierKey) ? "copy" : "move"; const groupItems = isHotkeyPressed(groupModeModifierKey); const parentHoverPath = getParentPath(dropResult.path); const hoverIndex = dropResult.path.at(-1); const destinationPath = groupItems ? dropResult.path : dropResult.type === "ruleGroup" ? [...dropResult.path, 0] : dropResult.type === "inlineCombinator" ? [...parentHoverPath, hoverIndex] : [...parentHoverPath, hoverIndex + 1]; if (schema.qbId === dropResult.qbId) if (groupItems) actions.groupRule(item.path, destinationPath, dropEffect === "copy"); else actions.moveRule(item.path, destinationPath, dropEffect === "copy"); else { const otherBuilderQuery = dropResult.getQuery(); // istanbul ignore else if (otherBuilderQuery) { if (groupItems) dropResult.dispatchQuery(group(add(otherBuilderQuery, item, []), [otherBuilderQuery.rules.length], destinationPath, { clone: false })); else dropResult.dispatchQuery(insert(otherBuilderQuery, item, destinationPath)); // istanbul ignore else if (dropEffect !== "copy") actions.onRuleRemove(item.path); } } } }), [ actions.groupRule, actions.moveRule, disabled, path ]); //#endregion //#region src/RuleDnD.tsx /** * Rule component for drag-and-drop. Renders the provided rule component * ({@link react-querybuilder!Rule Rule} by default), but forwards the * drag-and-drop context. * * @group Components */ const RuleDnD = (props) => { const rqbDndContext = useContext(QueryBuilderDndContext); const { canDrop, useDrag, useDrop, copyModeModifierKey, groupModeModifierKey, hideDefaultDragPreview } = rqbDndContext; const disabled = !!props.parentDisabled || !!props.disabled; const dndRefs = useRuleDnD({ ...props, disabled, useDrag, useDrop, canDrop, copyModeModifierKey, groupModeModifierKey, hideDefaultDragPreview }); const { rule: BaseRuleComponent } = rqbDndContext.baseControls; return /* @__PURE__ */ React.createElement(QueryBuilderDndContext.Provider, { value: rqbDndContext }, /* @__PURE__ */ React.createElement(BaseRuleComponent, { ...props, ...dndRefs })); }; const accept$1 = ["rule", "ruleGroup"]; /** * @group Hooks */ const useRuleDnD = (params) => { const dndRef = useRef(null); const dragRef = useRef(null); const { path, rule: rule$1, disabled, schema, actions, useDrag, useDrop, canDrop, copyModeModifierKey = "alt", groupModeModifierKey = "ctrl", hideDefaultDragPreview } = params; const [{ isDragging, dragMonitorId }, drag, preview] = useDragCommon({ type: "rule", path, disabled, independentCombinators: schema.independentCombinators, schema, actions, useDrag, copyModeModifierKey, groupModeModifierKey, hideDefaultDragPreview }); const [{ isOver, dropMonitorId, dropEffect, groupItems, dropNotAllowed }, drop] = useDrop(() => ({ accept: accept$1, canDrop: (dragging) => { if (isHotkeyPressed(groupModeModifierKey) && disabled || dragging && typeof canDrop === "function" && !canDrop({ dragging, hovering: { ...rule$1, path, qbId: schema.qbId } })) return false; if (schema.qbId !== dragging.qbId) return true; const parentHoverPath = getParentPath(path); const parentItemPath = getParentPath(dragging.path); const hoverIndex = path.at(-1); const itemIndex = dragging.path.at(-1); return !(isAncestor(dragging.path, path) || pathsAreEqual(path, dragging.path) || !isHotkeyPressed(groupModeModifierKey) && pathsAreEqual(parentHoverPath, parentItemPath) && (hoverIndex === itemIndex - 1 || schema.independentCombinators && hoverIndex === itemIndex - 2)); }, collect: (monitor) => ({ dropNotAllowed: monitor.isOver() && !monitor.canDrop(), isOver: monitor.canDrop() && monitor.isOver(), dropMonitorId: monitor.getHandlerId() ?? "", dropEffect: isHotkeyPressed(copyModeModifierKey) ? "copy" : "move", groupItems: isHotkeyPressed(groupModeModifierKey) }), drop: () => { const { qbId, getQuery, dispatchQuery } = schema; const dropEffect$1 = isHotkeyPressed(copyModeModifierKey) ? "copy" : "move"; return { type: "rule", path, qbId, getQuery, dispatchQuery, groupItems: isHotkeyPressed(groupModeModifierKey), dropEffect: dropEffect$1 }; } }), [ disabled, actions.moveRule, path, canDrop, rule$1, schema ]); React.useEffect(() => { drag(dragRef); drop(dndRef); preview(hideDefaultDragPreview ? getEmptyImage() : dndRef); }, [ drag, drop, hideDefaultDragPreview, preview ]); return { isDragging, dragMonitorId, isOver, dropMonitorId, dndRef, dragRef, dropEffect, groupItems, dropNotAllowed }; }; //#endregion //#region src/RuleGroupDnD.tsx /** * Rule group component for drag-and-drop. Renders the provided rule group component * ({@link react-querybuilder!RuleGroup RuleGroup} by default), but forwards the drag-and-drop * context so that child rules and groups will render within the appropriate drag-and-drop wrappers. * * @group Components */ const RuleGroupDnD = (props) => { const { canDrop, baseControls: { ruleGroup: BaseRuleGroupComponent }, useDrag, useDrop, copyModeModifierKey, groupModeModifierKey, hideDefaultDragPreview } = useContext(QueryBuilderDndContext); const dndRefs = useRuleGroupDnD({ ...props, disabled: !!props.parentDisabled || !!props.disabled, useDrag, useDrop, canDrop, copyModeModifierKey, groupModeModifierKey, hideDefaultDragPreview }); return /* @__PURE__ */ React.createElement(BaseRuleGroupComponent, { ...props, ...dndRefs }); }; const accept = ["rule", "ruleGroup"]; /** * @group Hooks */ const useRuleGroupDnD = (params) => { const previewRef = useRef(null); const dragRef = useRef(null); const dropRef = useRef(null); const { disabled, path, ruleGroup: ruleGroup$1, schema, actions, useDrag, useDrop, canDrop, copyModeModifierKey = "alt", groupModeModifierKey = "ctrl", hideDefaultDragPreview } = params; const [{ isDragging, dragMonitorId }, drag, preview] = useDragCommon({ type: "ruleGroup", path, disabled, independentCombinators: schema.independentCombinators, schema, actions, useDrag, copyModeModifierKey, groupModeModifierKey, hideDefaultDragPreview }); const [{ isOver, dropMonitorId, dropEffect, groupItems, dropNotAllowed }, drop] = useDrop(() => ({ accept, canDrop: (dragging) => { if (disabled || dragging && typeof canDrop === "function" && !canDrop({ dragging, hovering: { ...ruleGroup$1, path, qbId: schema.qbId } })) return false; if (schema.qbId !== dragging.qbId) return true; const parentItemPath = getParentPath(dragging.path); const itemIndex = dragging.path.at(-1); return !(isAncestor(dragging.path, path) || pathsAreEqual(path, parentItemPath) && itemIndex === 0 || pathsAreEqual(path, dragging.path)); }, collect: (monitor) => ({ dropNotAllowed: monitor.isOver() && !monitor.canDrop(), isOver: monitor.canDrop() && monitor.isOver(), dropMonitorId: monitor.getHandlerId() ?? "", dropEffect: isHotkeyPressed(copyModeModifierKey) ? "copy" : "move", groupItems: isHotkeyPressed(groupModeModifierKey) }), drop: () => { const { qbId, getQuery, dispatchQuery } = schema; const dropEffect$1 = isHotkeyPressed(copyModeModifierKey) ? "copy" : "move"; return { type: "ruleGroup", path, qbId, getQuery, dispatchQuery, groupItems: isHotkeyPressed(groupModeModifierKey), dropEffect: dropEffect$1 }; } }), [ disabled, actions.groupRule, actions.moveRule, path, canDrop, ruleGroup$1, schema ]); React.useEffect(() => { if (path.length > 0) { drag(dragRef); preview(hideDefaultDragPreview ? getEmptyImage() : previewRef); } drop(dropRef); }, [ drag, drop, hideDefaultDragPreview, path.length, preview ]); return { isDragging, dragMonitorId, isOver, dropMonitorId, previewRef, dragRef, dropRef, dropEffect, groupItems, dropNotAllowed }; }; //#endregion //#region src/QueryBuilderDnD.tsx const emptyObject = {}; /** * Context provider to enable drag-and-drop. If the application already implements * `react-dnd`, use {@link QueryBuilderDndWithoutProvider} instead. * * @group Components */ const QueryBuilderDnD = (props) => { const { controlClassnames, controlElements, debugMode, enableDragAndDrop: enableDragAndDropProp, enableMountQueryChange, translations } = props; const rqbContext = useMergedContext({ controlClassnames, controlElements, debugMode, enableDragAndDrop: enableDragAndDropProp ?? true, enableMountQueryChange, translations: translations ?? {} }); const { enableDragAndDrop } = rqbContext; const dnd = useReactDnD(props.dnd); const key = enableDragAndDrop && dnd ? "dnd" : "no-dnd"; const { DndProvider, ReactDndBackend } = dnd ?? emptyObject; const contextWithoutDnD = useMemo(() => ({ ...rqbContext, enableDragAndDrop: false, debugMode }), [rqbContext, debugMode]); const contextWithDnD = useMemo(() => ({ ...rqbContext, enableDragAndDrop, debugMode }), [ rqbContext, debugMode, enableDragAndDrop ]); if (!enableDragAndDrop || !dnd || !DndProvider || !ReactDndBackend) return /* @__PURE__ */ React.createElement(QueryBuilderContext.Provider, { key, value: contextWithoutDnD }, props.children); return /* @__PURE__ */ React.createElement(DndProvider, { key, backend: ReactDndBackend, debugMode }, /* @__PURE__ */ React.createElement(QueryBuilderContext.Provider, { key, value: contextWithDnD }, /* @__PURE__ */ React.createElement(QueryBuilderDndWithoutProvider, { dnd, canDrop: props.canDrop, copyModeModifierKey: props.copyModeModifierKey, groupModeModifierKey: props.groupModeModifierKey, hideDefaultDragPreview: props.hideDefaultDragPreview }, props.children))); }; /** * Context provider to enable drag-and-drop. Only use this provider if the application * already implements `react-dnd`, otherwise use {@link QueryBuilderDnD}. * * @group Components */ const QueryBuilderDndWithoutProvider = (props) => { const rqbContext = useContext(QueryBuilderContext); const rqbDndContext = useContext(QueryBuilderDndContext); const dnd = useReactDnD(props.dnd); const copyModeModifierKey = preferAnyProp(void 0, props.copyModeModifierKey, rqbDndContext.copyModeModifierKey); const groupModeModifierKey = preferAnyProp(void 0, props.groupModeModifierKey, rqbDndContext.groupModeModifierKey); const enableDragAndDrop = preferProp(true, props.enableDragAndDrop, rqbContext.enableDragAndDrop); const debugMode = preferProp(false, props.debugMode, rqbContext.debugMode); const hideDefaultDragPreview = preferProp(false, props.hideDefaultDragPreview, rqbDndContext.hideDefaultDragPreview); const canDrop = preferAnyProp(void 0, props.canDrop, rqbDndContext.canDrop); const key = enableDragAndDrop && dnd ? "dnd" : "no-dnd"; const baseControls = useMemo(() => ({ rule: props.controlElements?.rule ?? rqbContext.controlElements?.rule ?? rqbDndContext.baseControls.rule, ruleGroup: props.controlElements?.ruleGroup ?? rqbContext.controlElements?.ruleGroup ?? rqbDndContext.baseControls.ruleGroup, combinatorSelector: props.controlElements?.combinatorSelector ?? rqbContext.controlElements?.combinatorSelector ?? rqbDndContext.baseControls.combinatorSelector }), [ props.controlElements?.combinatorSelector, props.controlElements?.rule, props.controlElements?.ruleGroup, rqbContext.controlElements?.combinatorSelector, rqbContext.controlElements?.rule, rqbContext.controlElements?.ruleGroup, rqbDndContext.baseControls.combinatorSelector, rqbDndContext.baseControls.rule, rqbDndContext.baseControls.ruleGroup ]); const newContext = useMemo(() => ({ ...rqbContext, enableDragAndDrop, debugMode, controlElements: { ...rqbContext.controlElements, ruleGroup: RuleGroupDnD, rule: RuleDnD, inlineCombinator: InlineCombinatorDnD } }), [ debugMode, enableDragAndDrop, rqbContext ]); const { DndContext, useDrag, useDrop } = dnd ?? {}; const dndContextValue = useMemo(() => ({ baseControls, canDrop, copyModeModifierKey, groupModeModifierKey, hideDefaultDragPreview, useDrag, useDrop }), [ baseControls, canDrop, copyModeModifierKey, groupModeModifierKey, hideDefaultDragPreview, useDrag, useDrop ]); const contextWithoutDnD = useMemo(() => ({ ...rqbContext, enableDragAndDrop: false, debugMode }), [rqbContext, debugMode]); if (!enableDragAndDrop || !DndContext) return /* @__PURE__ */ React.createElement(QueryBuilderContext.Provider, { key, value: contextWithoutDnD }, props.children); return /* @__PURE__ */ React.createElement(DndContext.Consumer, { key }, () => /* @__PURE__ */ React.createElement(QueryBuilderContext.Provider, { key, value: newContext }, /* @__PURE__ */ React.createElement(QueryBuilderDndContext.Provider, { value: dndContextValue }, props.children))); }; let didWarnEnabledDndWithoutReactDnD = false; /** * @group Hooks */ const useReactDnD = (dndParam) => { const [dnd, setDnd] = useState(dndParam ?? null); useEffect(() => { let didCancel = false; const getDnD = async () => { const [reactDnD, reactDndHTML5Be, reactDndTouchBe] = await Promise.all([ "", "-html5-backend", "-touch-backend" ].map((pn) => import( /* @vite-ignore */ `react-dnd${pn}` ).catch(() => null))); // istanbul ignore else if (!didCancel) { if (reactDnD) { // istanbul ignore next if (reactDndHTML5Be && (!reactDndTouchBe || reactDndTouchBe && !isTouchDevice())) setDnd(() => ({ ...reactDnD, ...reactDndHTML5Be, ...reactDndTouchBe, ReactDndBackend: reactDndHTML5Be.HTML5Backend })); else if (reactDndTouchBe) setDnd(() => ({ ...reactDnD, ...reactDndTouchBe, ...reactDndHTML5Be, ReactDndBackend: reactDndTouchBe.TouchBackend })); } else if (process.env.NODE_ENV !== "production" && !didWarnEnabledDndWithoutReactDnD) { console.error(messages.errorEnabledDndWithoutReactDnD); didWarnEnabledDndWithoutReactDnD = true; } } }; if (!dnd) getDnD(); return () => { didCancel = true; }; }, [dnd]); // istanbul ignore next if (dnd && !dnd.ReactDndBackend) dnd.ReactDndBackend = isTouchDevice() ? dnd.TouchBackend ?? dnd.HTML5Backend : dnd.HTML5Backend ?? dnd.TouchBackend; return dnd; }; //#endregion export { InlineCombinatorDnD, QueryBuilderDnD, QueryBuilderDndWithoutProvider, RuleDnD, RuleGroupDnD, useInlineCombinatorDnD, useReactDnD, useRuleDnD, useRuleGroupDnD }; //# sourceMappingURL=react-querybuilder_dnd.mjs.map