From 12cf22805ee90998cdfd02410db3865afa2a416d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Grabowski?= Date: Wed, 30 Oct 2024 15:14:01 +0100 Subject: [PATCH] AI Assistant --- .../js/scripts/helpers/config.loader.js | 2 + .../public/js/scripts/helpers/react.helper.js | 21 +++ .../public/scss/ui/modules/_common.scss | 2 + .../ui/modules/common/_draggable.dialog.scss | 9 + .../scss/ui/modules/common/_popup.menu.scss | 87 +++++++++ .../draggable-dialog/draggable.dialog.js | 121 ++++++++++++ .../modules/common/popup-menu/popup.menu.js | 172 ++++++++++++++++++ 7 files changed, 414 insertions(+) create mode 100644 src/bundle/Resources/public/js/scripts/helpers/react.helper.js create mode 100644 src/bundle/Resources/public/scss/ui/modules/common/_draggable.dialog.scss create mode 100644 src/bundle/Resources/public/scss/ui/modules/common/_popup.menu.scss create mode 100644 src/bundle/ui-dev/src/modules/common/draggable-dialog/draggable.dialog.js create mode 100644 src/bundle/ui-dev/src/modules/common/popup-menu/popup.menu.js diff --git a/src/bundle/Resources/public/js/scripts/helpers/config.loader.js b/src/bundle/Resources/public/js/scripts/helpers/config.loader.js index 7fbfb1a3f5..5b2886bd82 100644 --- a/src/bundle/Resources/public/js/scripts/helpers/config.loader.js +++ b/src/bundle/Resources/public/js/scripts/helpers/config.loader.js @@ -11,6 +11,7 @@ import * as middleEllipsis from './middle.ellipsis'; import * as notification from './notification.helper'; import * as objectInstances from './object.instances'; import * as pagination from './pagination.helper'; +import * as react from './react.helper'; import * as request from './request.helper'; import * as system from './system.helper'; import * as table from './table.helper'; @@ -34,6 +35,7 @@ import * as user from './user.helper'; ibexa.addConfig('helpers.notification', notification); ibexa.addConfig('helpers.objectInstances', objectInstances); ibexa.addConfig('helpers.pagination', pagination); + ibexa.addConfig('helpers.react', react); ibexa.addConfig('helpers.request', request); ibexa.addConfig('helpers.system', system); ibexa.addConfig('helpers.table', table); diff --git a/src/bundle/Resources/public/js/scripts/helpers/react.helper.js b/src/bundle/Resources/public/js/scripts/helpers/react.helper.js new file mode 100644 index 0000000000..f3b7746c29 --- /dev/null +++ b/src/bundle/Resources/public/js/scripts/helpers/react.helper.js @@ -0,0 +1,21 @@ +const createDynamicRoot = (contextDOMElement = window.document.body, id) => { + const rootDOMElement = document.createElement('div'); + + rootDOMElement.classList.add('ibexa-react-root'); + + if (id) { + rootDOMElement.id = id; + } + + contextDOMElement.appendChild(rootDOMElement); + + const reactRoot = window.ReactDOM.createRoot(rootDOMElement); + + return { reactRoot, rootDOMElement }; +}; + +const removeDynamicRoot = (rootDOMElement) => { + rootDOMElement.remove(); +}; + +export { createDynamicRoot, removeDynamicRoot }; diff --git a/src/bundle/Resources/public/scss/ui/modules/_common.scss b/src/bundle/Resources/public/scss/ui/modules/_common.scss index d916cc360d..e9eee25a80 100644 --- a/src/bundle/Resources/public/scss/ui/modules/_common.scss +++ b/src/bundle/Resources/public/scss/ui/modules/_common.scss @@ -5,3 +5,5 @@ @import 'common/user.name'; @import 'common/taggify'; @import 'common/spinner'; +@import 'common/draggable.dialog'; +@import 'common/popup.menu'; diff --git a/src/bundle/Resources/public/scss/ui/modules/common/_draggable.dialog.scss b/src/bundle/Resources/public/scss/ui/modules/common/_draggable.dialog.scss new file mode 100644 index 0000000000..8aaf740a99 --- /dev/null +++ b/src/bundle/Resources/public/scss/ui/modules/common/_draggable.dialog.scss @@ -0,0 +1,9 @@ +.c-draggable-dialog { + position: fixed; + z-index: 10000; + + &__draggable { + cursor: grab; + user-select: none; + } +} diff --git a/src/bundle/Resources/public/scss/ui/modules/common/_popup.menu.scss b/src/bundle/Resources/public/scss/ui/modules/common/_popup.menu.scss new file mode 100644 index 0000000000..773aeea498 --- /dev/null +++ b/src/bundle/Resources/public/scss/ui/modules/common/_popup.menu.scss @@ -0,0 +1,87 @@ +.c-popup-menu { + display: none; + flex-direction: column; + gap: calculateRem(1px); + padding: calculateRem(8px) 0; + background: $ibexa-color-white; + border: calculateRem(1px) solid $ibexa-color-light; + border-radius: $ibexa-border-radius; + box-shadow: calculateRem(4px) calculateRem(22px) calculateRem(67px) 0 rgba($ibexa-color-info, 0.2); + position: fixed; + z-index: 1060; + + &--visible { + display: flex; + } + + &__search { + margin-bottom: calculateRem(4px); + padding: 0 calculateRem(8px); + + &--hidden { + display: none; + } + } + + &__search-input { + border-radius: $ibexa-border-radius; + } + + &__groups { + max-height: calculateRem(390px); + overflow-y: auto; + } + + &__group:not(:last-child) { + &::after { + content: ''; + border-top: calculateRem(1px) solid $ibexa-color-light; + display: flex; + width: calc(100% - calculateRem(16px)); + margin: calculateRem(1px) calculateRem(8px) 0; + } + } + + &__item { + display: flex; + align-items: center; + min-width: calculateRem(150px); + padding: 0 calculateRem(8px); + transition: all $ibexa-admin-transition-duration $ibexa-admin-transition; + } + + &__item-content { + position: relative; + display: flex; + align-items: center; + align-items: baseline; + width: 100%; + cursor: pointer; + padding: calculateRem(9px); + color: $ibexa-color-dark; + font-size: calculateRem(14px); + text-align: left; + text-decoration: none; + border: none; + border-radius: $ibexa-border-radius; + transition: all $ibexa-admin-transition-duration $ibexa-admin-transition; + + &:hover { + background-color: $ibexa-color-light-300; + color: $ibexa-color-black; + text-decoration: none; + } + + &[disabled], + &:disabled, + &--disabled { + pointer-events: none; + cursor: not-allowed; + opacity: 0.2; + + &:hover { + background-color: initial; + } + } + } +} diff --git a/src/bundle/ui-dev/src/modules/common/draggable-dialog/draggable.dialog.js b/src/bundle/ui-dev/src/modules/common/draggable-dialog/draggable.dialog.js new file mode 100644 index 0000000000..21f3a21ade --- /dev/null +++ b/src/bundle/ui-dev/src/modules/common/draggable-dialog/draggable.dialog.js @@ -0,0 +1,121 @@ +import React, { useRef, createContext, useState, useEffect } from 'react'; +import PropTypes from 'prop-types'; + +import { getRootDOMElement } from '@ibexa-admin-ui/src/bundle/Resources/public/js/scripts/helpers/context.helper'; +import { createCssClassNames } from '../helpers/css.class.names'; + +export const DraggableContext = createContext(); + +const DraggableDialog = ({ children, initialCoords }) => { + const rootDOMElement = getRootDOMElement(); + const containerRef = useRef(); + const dragOffsetPosition = useRef({ x: 0, y: 0 }); + const containerSize = useRef({ width: 0, height: 0 }); + const [isDragging, setIsDragging] = useState(false); + const [coords, setCoords] = useState(initialCoords); + const containerAttrs = { + ref: containerRef, + className: 'c-draggable-dialog', + style: { + top: coords.y, + left: coords.x, + }, + }; + const getMousePosition = (event) => ({ x: event.x, y: event.y }); + const setContainerCoords = (event) => { + const mouseCoords = getMousePosition(event); + let x = mouseCoords.x - dragOffsetPosition.current.x; + let y = mouseCoords.y - dragOffsetPosition.current.y; + let newDragOffsetX; + let newDragOffsetY; + + if (x < 0) { + x = 0; + newDragOffsetX = mouseCoords.x; + } else if (x + containerSize.current.width > window.innerWidth) { + x = window.innerWidth - containerSize.current.width; + newDragOffsetX = mouseCoords.x - x; + } + + if (y < 0) { + y = 0; + newDragOffsetY = mouseCoords.y; + } else if (y + containerSize.current.height > window.innerHeight) { + y = window.innerHeight - containerSize.current.height; + newDragOffsetY = mouseCoords.y - y; + } + + if (newDragOffsetX) { + dragOffsetPosition.current.x = newDragOffsetX; + } + + if (newDragOffsetY) { + dragOffsetPosition.current.y = newDragOffsetY; + } + + setCoords({ + x, + y, + }); + }; + const startDragging = (event) => { + const { x: containerX, y: containerY, width, height } = containerRef.current.getBoundingClientRect(); + const mouseCoords = getMousePosition(event.nativeEvent); + + dragOffsetPosition.current = { + x: mouseCoords.x - containerX, + y: mouseCoords.y - containerY, + }; + + containerSize.current = { + width, + height, + }; + + setContainerCoords(event.nativeEvent); + + setIsDragging(true); + }; + const stopDragging = () => { + setIsDragging(false); + }; + const handleDragging = (event) => { + setContainerCoords(event); + }; + + useEffect(() => { + if (isDragging) { + rootDOMElement.addEventListener('mousemove', handleDragging); + rootDOMElement.addEventListener('mouseup', stopDragging); + } + + return () => { + rootDOMElement.removeEventListener('mousemove', handleDragging); + rootDOMElement.removeEventListener('mouseup', stopDragging); + }; + }, [isDragging]); + + return ( + +
{children}
+
+ ); +}; + +DraggableDialog.propTypes = { + initialCoords: PropTypes.shape({ + x: PropTypes.number.isRequired, + y: PropTypes.number.isRequired, + }).isRequired, + children: PropTypes.node, +}; + +DraggableDialog.defaultProps = { + children: null, +}; + +export default DraggableDialog; diff --git a/src/bundle/ui-dev/src/modules/common/popup-menu/popup.menu.js b/src/bundle/ui-dev/src/modules/common/popup-menu/popup.menu.js new file mode 100644 index 0000000000..f7c8ae6d51 --- /dev/null +++ b/src/bundle/ui-dev/src/modules/common/popup-menu/popup.menu.js @@ -0,0 +1,172 @@ +import React, { useState, useEffect, useRef, useLayoutEffect } from 'react'; +import ReactDOM from 'react-dom'; +import PropTypes from 'prop-types'; + +import { getRootDOMElement, getTranslator } from '@ibexa-admin-ui/src/bundle/Resources/public/js/scripts/helpers/context.helper'; +import Icon from '@ibexa-admin-ui/src/bundle/ui-dev/src/modules/common/icon/icon'; + +import { createCssClassNames } from '../helpers/css.class.names'; +// import Icon from '../icon/icon'; +// import { getTranslator } from '@ibexa-admin-ui/src/bundle/Resources/public/js/scripts/helpers/context.helper'; + +const { document } = window; +const MIN_SEARCH_ITEMS_DEFAULT = 5; +const MIN_ITEMS_LIST_HEIGHT = 150; +const ITEMS_LIST_WIDGET_MARGIN = 8; +const ITEMS_LIST_SITE_MARGIN = ITEMS_LIST_WIDGET_MARGIN + 4; +const RESTRICTED_AREA_ITEMS_CONTAINER = 190; + +const PopupMenu = ({ positionOffset, items, scrollContainer, referenceElement, onItemClick }) => { + const rootDOMElement = getRootDOMElement(); + const Translator = getTranslator(); + const containerRef = useRef(); + const [isVisible, setIsVisible] = useState(true); + const [itemsListStyles, setItemsListStyles] = useState({ + left: 0, + top: 0, + }); + const [filterText, setFilterText] = useState(''); + const popupMenuClassName = createCssClassNames({ + 'c-popup-menu': true, + 'c-popup-menu--visible': isVisible, + }); + const searchPlaceholder = Translator.trans(/*@Desc("Search...")*/ 'popup_menu.placeholder', {}, 'popup_menu'); + const updateFilterValue = (event) => setFilterText(event.target.value); + const resetInputValue = () => setFilterText(''); + const showItem = (item) => { + if (filterText.length < 3) { + return true; + } + + const itemLabelLowerCase = item.label.toLowerCase(); + const filterTextLowerCase = filterText.toLowerCase(); + + return itemLabelLowerCase.indexOf(filterTextLowerCase) === 0; + }; + const renderGroup = (group) => { + const groupClassName = createCssClassNames({ + 'c-popup-menu__group': true, + }); + + return
{group.items.map(renderItem)}
; + }; + const renderItem = (item) => { + if (!showItem(item)) { + return null; + } + + const itemClassName = createCssClassNames({ + 'c-popup-menu__item': true, + }); + + return ( +
+ +
+ ); + }; + const calculateAndSetItemsListStyles = () => { + const itemsStyles = {}; + const { top: referenceTop, left: referenceLeft } = referenceElement.getBoundingClientRect(); + const { height: containerHeight } = containerRef.current.getBoundingClientRect(); + const bottom = referenceTop + containerHeight; + + itemsStyles.top = referenceTop; + + if (window.innerHeight - bottom > MIN_ITEMS_LIST_HEIGHT) { + const { x: offsetX, y: offsetY } = positionOffset(referenceElement, 'bottom'); + + itemsStyles.top = referenceTop + offsetY; + itemsStyles.left = referenceLeft + offsetX; + itemsStyles.maxHeight = window.innerHeight - bottom; + } else { + const { x: offsetX, y: offsetY } = positionOffset(referenceElement, 'top'); + + console.log(offsetY); + + itemsStyles.top = referenceTop + offsetY; + itemsStyles.left = referenceLeft + offsetX; + itemsStyles.maxHeight = referenceTop; + itemsStyles.transform = 'translateY(-100%)'; + } + + setItemsListStyles(itemsStyles); + }; + + useEffect(() => { + calculateAndSetItemsListStyles(); + + if (!isVisible) { + return; + } + + const onInteractionOutside = (event) => { + if (containerRef.current.contains(event.target) || referenceElement.contains(event.target)) { + return; + } + + setIsVisible(false); + }; + + window.document.body.addEventListener('click', onInteractionOutside, false); + scrollContainer.addEventListener('scroll', calculateAndSetItemsListStyles, false); + + return () => { + window.document.body.removeEventListener('click', onInteractionOutside); + scrollContainer.removeEventListener('scroll', calculateAndSetItemsListStyles); + + setItemsListStyles({}); + }; + }, [isVisible]); + + return ( +
+
+
+ +
+ + +
+
+
+
{items.map(renderGroup)}
+
+ ); +}; + +PopupMenu.propTypes = { + referenceElement: PropTypes.isRequired, + positionOffset: PropTypes.func, + scrollContainer: PropTypes.node, + onItemClick: PropTypes.func, +}; + +PopupMenu.defaultProps = { + positionOffset: () => ({ x: 0, y: 0 }), + scrollContainer: window.document.body, + onItemClick: () => {}, +}; + +export default PopupMenu;