diff --git a/packages/kbn-grid-layout/grid/grid_height_smoother.tsx b/packages/kbn-grid-layout/grid/grid_height_smoother.tsx index 960fe4f52e735..7693fac72918a 100644 --- a/packages/kbn-grid-layout/grid/grid_height_smoother.tsx +++ b/packages/kbn-grid-layout/grid/grid_height_smoother.tsx @@ -24,7 +24,7 @@ export const GridHeightSmoother = ({ gridLayoutStateManager.interactionEvent$, ]).subscribe(([dimensions, interactionEvent]) => { if (!smoothHeightRef.current) return; - if (!interactionEvent) { + if (!interactionEvent || interactionEvent.type === 'drop') { smoothHeightRef.current.style.height = `${dimensions.height}px`; return; } diff --git a/packages/kbn-grid-layout/grid/grid_layout.tsx b/packages/kbn-grid-layout/grid/grid_layout.tsx index 7587e57e9dd1c..c6bbd94dabe56 100644 --- a/packages/kbn-grid-layout/grid/grid_layout.tsx +++ b/packages/kbn-grid-layout/grid/grid_layout.tsx @@ -7,10 +7,11 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { useBatchedPublishingSubjects } from '@kbn/presentation-publishing'; import React from 'react'; + +import { useBatchedPublishingSubjects } from '@kbn/presentation-publishing'; + import { GridHeightSmoother } from './grid_height_smoother'; -import { GridOverlay } from './grid_overlay'; import { GridRow } from './grid_row'; import { GridLayoutData, GridSettings } from './types'; import { useGridLayoutEvents } from './use_grid_layout_events'; @@ -49,17 +50,17 @@ export const GridLayout = ({ key={rowData.title} rowIndex={rowIndex} runtimeSettings={runtimeSettings} - activePanelId={interactionEvent?.id} renderPanelContents={renderPanelContents} targetRowIndex={interactionEvent?.targetRowIndex} + gridLayoutStateManager={gridLayoutStateManager} toggleIsCollapsed={() => { const currentLayout = gridLayoutStateManager.gridLayout$.value; currentLayout[rowIndex].isCollapsed = !currentLayout[rowIndex].isCollapsed; gridLayoutStateManager.gridLayout$.next(currentLayout); }} setInteractionEvent={(nextInteractionEvent) => { - if (!nextInteractionEvent) { - gridLayoutStateManager.hideDragPreview(); + if (nextInteractionEvent?.type === 'drop') { + gridLayoutStateManager.activePanel$.next(undefined); } gridLayoutStateManager.interactionEvent$.next(nextInteractionEvent); }} @@ -69,10 +70,6 @@ export const GridLayout = ({ })} - ); }; diff --git a/packages/kbn-grid-layout/grid/grid_overlay.tsx b/packages/kbn-grid-layout/grid/grid_overlay.tsx deleted file mode 100644 index 958618d2f8f38..0000000000000 --- a/packages/kbn-grid-layout/grid/grid_overlay.tsx +++ /dev/null @@ -1,135 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import { EuiPortal, EuiText, transparentize } from '@elastic/eui'; -import { css } from '@emotion/react'; -import { i18n } from '@kbn/i18n'; -import { euiThemeVars } from '@kbn/ui-theme'; -import React, { useRef, useState } from 'react'; -import { GridLayoutStateManager, PanelInteractionEvent } from './types'; - -type ScrollDirection = 'up' | 'down'; - -const scrollLabels: { [key in ScrollDirection]: string } = { - up: i18n.translate('kbnGridLayout.overlays.scrollUpLabel', { defaultMessage: 'Scroll up' }), - down: i18n.translate('kbnGridLayout.overlays.scrollDownLabel', { defaultMessage: 'Scroll down' }), -}; - -const scrollOnInterval = (direction: ScrollDirection) => { - const interval = setInterval(() => { - window.scroll({ - top: window.scrollY + (direction === 'down' ? 50 : -50), - behavior: 'smooth', - }); - }, 100); - return interval; -}; - -const ScrollOnHover = ({ direction, hide }: { hide: boolean; direction: ScrollDirection }) => { - const [isActive, setIsActive] = useState(false); - const scrollInterval = useRef(null); - const stopScrollInterval = () => { - if (scrollInterval.current) { - clearInterval(scrollInterval.current); - } - }; - - return ( -
{ - setIsActive(true); - scrollInterval.current = scrollOnInterval(direction); - }} - onMouseLeave={() => { - setIsActive(false); - stopScrollInterval(); - }} - css={css` - width: 100%; - position: fixed; - display: flex; - align-items: center; - flex-direction: column; - justify-content: center; - opacity: ${hide ? 0 : 1}; - transition: opacity 100ms linear; - padding: ${euiThemeVars.euiSizeM}; - ${direction === 'down' ? 'bottom: 0;' : 'top: 0;'} - `} - > - {direction === 'up' && ( -
- )} -
- - {scrollLabels[direction]} - -
-
- ); -}; - -export const GridOverlay = ({ - interactionEvent, - gridLayoutStateManager, -}: { - interactionEvent?: PanelInteractionEvent; - gridLayoutStateManager: GridLayoutStateManager; -}) => { - return ( - -
- - -
-
- - ); -}; diff --git a/packages/kbn-grid-layout/grid/grid_panel.tsx b/packages/kbn-grid-layout/grid/grid_panel.tsx index 7bad4d1af037c..fbe34c4b68e15 100644 --- a/packages/kbn-grid-layout/grid/grid_panel.tsx +++ b/packages/kbn-grid-layout/grid/grid_panel.tsx @@ -7,63 +7,38 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ +import React, { forwardRef } from 'react'; + import { EuiIcon, EuiPanel, euiFullHeight, transparentize, useEuiOverflowScroll, + useEuiTheme, } from '@elastic/eui'; import { css } from '@emotion/react'; import { euiThemeVars } from '@kbn/ui-theme'; -import React, { useCallback, useRef } from 'react'; + import { GridPanelData, PanelInteractionEvent } from './types'; -export const GridPanel = ({ - activePanelId, - panelData, - renderPanelContents, - setInteractionEvent, -}: { - panelData: GridPanelData; - activePanelId: string | undefined; - renderPanelContents: (panelId: string) => React.ReactNode; - setInteractionEvent: (interactionData?: Omit) => void; -}) => { - const panelRef = useRef(null); +export const GridPanel = forwardRef< + HTMLDivElement, + { + panelData: GridPanelData; + activePanelId: string | undefined; + renderPanelContents: (panelId: string) => React.ReactNode; + interactionStart: ( + type: PanelInteractionEvent['type'], + e: React.MouseEvent + ) => void; + } +>(({ activePanelId, panelData, renderPanelContents, interactionStart }, panelRef) => { + const { euiTheme } = useEuiTheme(); const thisPanelActive = activePanelId === panelData.id; - const interactionStart = useCallback( - (type: 'drag' | 'resize', e: React.MouseEvent) => { - if (!panelRef.current) return; - e.preventDefault(); - e.stopPropagation(); - const panelRect = panelRef.current.getBoundingClientRect(); - setInteractionEvent({ - type, - id: panelData.id, - panelDiv: panelRef.current, - mouseOffsets: { - top: e.clientY - panelRect.top, - left: e.clientX - panelRect.left, - right: e.clientX - panelRect.right, - bottom: e.clientY - panelRect.bottom, - }, - }); - }, - [panelData.id, setInteractionEvent] - ); - return ( -
+
interactionStart('drag', e)} + onMouseUp={(e) => interactionStart('drop', e)} >
@@ -109,6 +93,7 @@ export const GridPanel = ({
interactionStart('resize', e)} + onMouseUp={(e) => interactionStart('drop', e)} css={css` right: 0; bottom: 0; @@ -139,4 +124,4 @@ export const GridPanel = ({
); -}; +}); diff --git a/packages/kbn-grid-layout/grid/grid_row.tsx b/packages/kbn-grid-layout/grid/grid_row.tsx index b2a9375c390b1..917f661c91740 100644 --- a/packages/kbn-grid-layout/grid/grid_row.tsx +++ b/packages/kbn-grid-layout/grid/grid_row.tsx @@ -7,13 +7,21 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ +import React, { forwardRef, useMemo, useRef } from 'react'; + import { EuiButtonIcon, EuiFlexGroup, EuiSpacer, EuiTitle, transparentize } from '@elastic/eui'; import { css } from '@emotion/react'; import { i18n } from '@kbn/i18n'; import { euiThemeVars } from '@kbn/ui-theme'; -import React, { forwardRef, useMemo } from 'react'; +import { useStateFromPublishingSubject } from '@kbn/presentation-publishing'; + import { GridPanel } from './grid_panel'; -import { GridRowData, PanelInteractionEvent, RuntimeGridSettings } from './types'; +import { + GridLayoutStateManager, + GridRowData, + PanelInteractionEvent, + RuntimeGridSettings, +} from './types'; const gridColor = transparentize(euiThemeVars.euiColorSuccess, 0.2); const getGridBackgroundCSS = (settings: RuntimeGridSettings) => { @@ -32,28 +40,31 @@ export const GridRow = forwardRef< rowIndex: number; rowData: GridRowData; toggleIsCollapsed: () => void; - activePanelId: string | undefined; targetRowIndex: number | undefined; runtimeSettings: RuntimeGridSettings; renderPanelContents: (panelId: string) => React.ReactNode; setInteractionEvent: (interactionData?: PanelInteractionEvent) => void; + gridLayoutStateManager: GridLayoutStateManager; } >( ( { rowData, rowIndex, - activePanelId, targetRowIndex, runtimeSettings, toggleIsCollapsed, renderPanelContents, setInteractionEvent, + gridLayoutStateManager, }, gridRef ) => { + const dragPreviewRef = useRef(null); + const activePanel = useStateFromPublishingSubject(gridLayoutStateManager.activePanel$); + const { gutterSize, columnCount, rowHeight } = runtimeSettings; - const isGridTargeted = activePanelId && targetRowIndex === rowIndex; + const isGridTargeted = activePanel?.id && targetRowIndex === rowIndex; // calculate row count based on the number of rows needed to fit all panels const rowCount = useMemo(() => { @@ -107,20 +118,58 @@ export const GridRow = forwardRef< { - if (partialInteractionEvent) { - setInteractionEvent({ - ...partialInteractionEvent, - targetRowIndex: rowIndex, - }); - return; + interactionStart={(type, e) => { + e.preventDefault(); + e.stopPropagation(); + const panelRef = gridLayoutStateManager.panelRefs.current[rowIndex][panelData.id]; + if (!panelRef) return; + + const panelRect = panelRef.getBoundingClientRect(); + setInteractionEvent({ + type, + id: panelData.id, + panelDiv: panelRef, + targetRowIndex: rowIndex, + mouseOffsets: { + top: e.clientY - panelRect.top, + left: e.clientX - panelRect.left, + right: e.clientX - panelRect.right, + bottom: e.clientY - panelRect.bottom, + }, + }); + }} + ref={(element) => { + if (!gridLayoutStateManager.panelRefs.current[rowIndex]) { + gridLayoutStateManager.panelRefs.current[rowIndex] = {}; } - setInteractionEvent(); + gridLayoutStateManager.panelRefs.current[rowIndex][panelData.id] = element; }} /> ))} + + {/* render the drag preview if this row is currently being targetted */} + {isGridTargeted && ( +
+ )}
)} diff --git a/packages/kbn-grid-layout/grid/types.ts b/packages/kbn-grid-layout/grid/types.ts index 3d63c9f6ea01a..3a88eeb33baba 100644 --- a/packages/kbn-grid-layout/grid/types.ts +++ b/packages/kbn-grid-layout/grid/types.ts @@ -46,21 +46,25 @@ export interface GridSettings { */ export type RuntimeGridSettings = GridSettings & { columnPixelWidth: number }; -export interface GridLayoutStateManager { - hideDragPreview: () => void; - updatePreviewElement: (rect: { +export interface ActivePanel { + id: string; + position: { top: number; left: number; bottom: number; right: number; - }) => void; + }; +} +export interface GridLayoutStateManager { gridDimensions$: BehaviorSubject; gridLayout$: BehaviorSubject; runtimeSettings$: BehaviorSubject; - rowRefs: React.MutableRefObject>; - dragPreviewRef: React.MutableRefObject; + activePanel$: BehaviorSubject; interactionEvent$: BehaviorSubject; + + rowRefs: React.MutableRefObject>; + panelRefs: React.MutableRefObject>; } /** @@ -70,7 +74,7 @@ export interface PanelInteractionEvent { /** * The type of interaction being performed. */ - type: 'drag' | 'resize'; + type: 'drag' | 'resize' | 'drop'; /** * The id of the panel being interacted with. diff --git a/packages/kbn-grid-layout/grid/use_grid_layout_events.ts b/packages/kbn-grid-layout/grid/use_grid_layout_events.ts index 34fa84eeb131e..dfbd013d3642a 100644 --- a/packages/kbn-grid-layout/grid/use_grid_layout_events.ts +++ b/packages/kbn-grid-layout/grid/use_grid_layout_events.ts @@ -8,8 +8,9 @@ */ import { useEffect, useRef } from 'react'; + import { resolveGridRow } from './resolve_grid_row'; -import { GridPanelData, GridLayoutStateManager } from './types'; +import { GridLayoutStateManager, GridPanelData } from './types'; export const isGridDataEqual = (a?: GridPanelData, b?: GridPanelData) => { return ( @@ -35,12 +36,11 @@ export const useGridLayoutEvents = ({ useEffect(() => { const { runtimeSettings$, interactionEvent$, gridLayout$ } = gridLayoutStateManager; const calculateUserEvent = (e: Event) => { - if (!interactionEvent$.value) return; + if (!interactionEvent$.value || interactionEvent$.value.type === 'drop') return; e.preventDefault(); e.stopPropagation(); const gridRowElements = gridLayoutStateManager.rowRefs.current; - const previewElement = gridLayoutStateManager.dragPreviewRef.current; const interactionEvent = interactionEvent$.value; const isResize = interactionEvent?.type === 'resize'; @@ -53,7 +53,7 @@ export const useGridLayoutEvents = ({ } })(); - if (!runtimeSettings$.value || !previewElement || !gridRowElements || !currentGridData) { + if (!runtimeSettings$.value || !gridRowElements || !currentGridData) { return; } @@ -68,7 +68,7 @@ export const useGridLayoutEvents = ({ bottom: mouseTargetPixel.y - interactionEvent.mouseOffsets.bottom, right: mouseTargetPixel.x - interactionEvent.mouseOffsets.right, }; - gridLayoutStateManager.updatePreviewElement(previewRect); + gridLayoutStateManager.activePanel$.next({ id: interactionEvent.id, position: previewRect }); // find the grid that the preview rect is over const previewBottom = @@ -154,29 +154,19 @@ export const useGridLayoutEvents = ({ const resolvedOriginGrid = resolveGridRow(originGrid); nextLayout[lastRowIndex] = resolvedOriginGrid; } + gridLayout$.next(nextLayout); } }; - const onMouseUp = (e: MouseEvent) => { - if (!interactionEvent$.value) return; - e.preventDefault(); - e.stopPropagation(); - - interactionEvent$.next(undefined); - gridLayoutStateManager.hideDragPreview(); - }; - const onMouseMove = (e: MouseEvent) => { mouseClientPosition.current = { x: e.clientX, y: e.clientY }; calculateUserEvent(e); }; - document.addEventListener('mouseup', onMouseUp); document.addEventListener('mousemove', onMouseMove); document.addEventListener('scroll', calculateUserEvent); return () => { - document.removeEventListener('mouseup', onMouseUp); document.removeEventListener('mousemove', onMouseMove); document.removeEventListener('scroll', calculateUserEvent); }; diff --git a/packages/kbn-grid-layout/grid/use_grid_layout_state.ts b/packages/kbn-grid-layout/grid/use_grid_layout_state.ts index e10ce12fe5b6b..cdb99a9ebbfd0 100644 --- a/packages/kbn-grid-layout/grid/use_grid_layout_state.ts +++ b/packages/kbn-grid-layout/grid/use_grid_layout_state.ts @@ -7,10 +7,12 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ +import { i18n } from '@kbn/i18n'; import { useEffect, useMemo, useRef } from 'react'; -import { BehaviorSubject, debounceTime } from 'rxjs'; +import { BehaviorSubject, combineLatest, debounceTime, map, retry } from 'rxjs'; import useResizeObserver, { type ObservedSize } from 'use-resize-observer/polyfilled'; import { + ActivePanel, GridLayoutData, GridLayoutStateManager, GridSettings, @@ -27,7 +29,7 @@ export const useGridLayoutState = ({ setDimensionsRef: (instance: HTMLDivElement | null) => void; } => { const rowRefs = useRef>([]); - const dragPreviewRef = useRef(null); + const panelRefs = useRef>([]); // eslint-disable-next-line react-hooks/exhaustive-deps const { initialLayout, gridSettings } = useMemo(() => getCreationOptions(), []); @@ -36,6 +38,7 @@ export const useGridLayoutState = ({ const gridLayout$ = new BehaviorSubject(initialLayout); const gridDimensions$ = new BehaviorSubject({ width: 0, height: 0 }); const interactionEvent$ = new BehaviorSubject(undefined); + const activePanel$ = new BehaviorSubject(undefined); const runtimeSettings$ = new BehaviorSubject({ ...gridSettings, columnPixelWidth: 0, @@ -43,41 +46,21 @@ export const useGridLayoutState = ({ return { rowRefs, + panelRefs, gridLayout$, - dragPreviewRef, + activePanel$, gridDimensions$, runtimeSettings$, interactionEvent$, - updatePreviewElement: (previewRect: { - top: number; - bottom: number; - left: number; - right: number; - }) => { - if (!dragPreviewRef.current) return; - dragPreviewRef.current.style.opacity = '1'; - dragPreviewRef.current.style.left = `${previewRect.left}px`; - dragPreviewRef.current.style.top = `${previewRect.top}px`; - dragPreviewRef.current.style.width = `${Math.max( - previewRect.right - previewRect.left, - runtimeSettings$.value.columnPixelWidth - )}px`; - dragPreviewRef.current.style.height = `${Math.max( - previewRect.bottom - previewRect.top, - runtimeSettings$.value.rowHeight - )}px`; - }, - hideDragPreview: () => { - if (!dragPreviewRef.current) return; - dragPreviewRef.current.style.opacity = '0'; - }, }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { - // debounce width changes to avoid unnecessary column width recalculation. - const subscription = gridLayoutStateManager.gridDimensions$ + /** + * debounce width changes to avoid unnecessary column width recalculation. + */ + const resizeSubscription = gridLayoutStateManager.gridDimensions$ .pipe(debounceTime(250)) .subscribe((dimensions) => { const elementWidth = dimensions.width ?? 0; @@ -86,7 +69,116 @@ export const useGridLayoutState = ({ gridSettings.columnCount; gridLayoutStateManager.runtimeSettings$.next({ ...gridSettings, columnPixelWidth }); }); - return () => subscription.unsubscribe(); + + /** + * on layout change, update the styles of every panel so that it renders as expected + */ + const onLayoutChangeSubscription = combineLatest([ + gridLayoutStateManager.gridLayout$, + gridLayoutStateManager.activePanel$, + ]) + .pipe( + map(([gridLayout, activePanel]) => { + // wait for all panel refs to be ready before continuing + for (let rowIndex = 0; rowIndex < gridLayout.length; rowIndex++) { + const currentRow = gridLayout[rowIndex]; + Object.keys(currentRow.panels).forEach((key) => { + const panelRef = gridLayoutStateManager.panelRefs.current[rowIndex][key]; + if (!panelRef && !currentRow.isCollapsed) { + throw new Error( + i18n.translate('kbnGridLayout.panelRefNotFoundError', { + defaultMessage: 'Panel reference does not exist', // the retry will catch this error + }) + ); + } + }); + } + return { gridLayout, activePanel }; + }), + retry({ delay: 10 }) // retry until panel references all exist + ) + .subscribe(({ gridLayout, activePanel }) => { + const runtimeSettings = gridLayoutStateManager.runtimeSettings$.getValue(); + const currentInteractionEvent = gridLayoutStateManager.interactionEvent$.getValue(); + + for (let rowIndex = 0; rowIndex < gridLayout.length; rowIndex++) { + if (activePanel && rowIndex !== currentInteractionEvent?.targetRowIndex) { + /** + * If there is an interaction event happening but the current row is not being targetted, it + * does not need to be re-rendered; so, skip setting the panel styles of this row. + * + * If there is **no** interaction event, then this is the initial render so the styles of every + * panel should be initialized; so, don't skip setting the panel styles. + */ + continue; + } + + // re-render the targetted row + const currentRow = gridLayout[rowIndex]; + Object.keys(currentRow.panels).forEach((key) => { + const panel = currentRow.panels[key]; + const panelRef = gridLayoutStateManager.panelRefs.current[rowIndex][key]; + if (!panelRef) { + return; + } + + const isResize = currentInteractionEvent?.type === 'resize'; + if (panel.id === activePanel?.id) { + // if the current panel is active, give it fixed positioning depending on the interaction event + const { position: draggingPosition } = activePanel; + + if (isResize) { + // if the current panel is being resized, ensure it is not shrunk past the size of a single cell + panelRef.style.width = `${Math.max( + draggingPosition.right - draggingPosition.left, + runtimeSettings.columnPixelWidth + )}px`; + panelRef.style.height = `${Math.max( + draggingPosition.bottom - draggingPosition.top, + runtimeSettings.rowHeight + )}px`; + + // undo any "lock to grid" styles **except** for the top left corner, which stays locked + panelRef.style.gridColumnStart = `${panel.column + 1}`; + panelRef.style.gridRowStart = `${panel.row + 1}`; + panelRef.style.gridColumnEnd = ``; + panelRef.style.gridRowEnd = ``; + } else { + // if the current panel is being dragged, render it with a fixed position + size + panelRef.style.position = 'fixed'; + panelRef.style.left = `${draggingPosition.left}px`; + panelRef.style.top = `${draggingPosition.top}px`; + panelRef.style.width = `${draggingPosition.right - draggingPosition.left}px`; + panelRef.style.height = `${draggingPosition.bottom - draggingPosition.top}px`; + + // undo any "lock to grid" styles + panelRef.style.gridColumnStart = ``; + panelRef.style.gridRowStart = ``; + panelRef.style.gridColumnEnd = ``; + panelRef.style.gridRowEnd = ``; + } + } else { + // if the panel is not being dragged and/or resized, undo any fixed position styles + panelRef.style.position = ''; + panelRef.style.left = ``; + panelRef.style.top = ``; + panelRef.style.width = ``; + panelRef.style.height = ``; + + // and render the panel locked to the grid + panelRef.style.gridColumnStart = `${panel.column + 1}`; + panelRef.style.gridColumnEnd = `${panel.column + 1 + panel.width}`; + panelRef.style.gridRowStart = `${panel.row + 1}`; + panelRef.style.gridRowEnd = `${panel.row + 1 + panel.height}`; + } + }); + } + }); + + return () => { + resizeSubscription.unsubscribe(); + onLayoutChangeSubscription.unsubscribe(); + }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []);