[Dashboard] New layout engine (#174132)

Introduces a new performant and simple drag & drop layout engine for Kibana which uses HTML5 and CSS and and has **no external dependencies**.
This commit is contained in:
Devon Thomson 2024-08-15 17:51:43 -04:00 committed by GitHub
parent 1eb62ccfae
commit 7290824e74
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 1105 additions and 0 deletions

2
.github/CODEOWNERS vendored
View file

@ -478,6 +478,8 @@ x-pack/plugins/global_search @elastic/appex-sharedux
x-pack/plugins/global_search_providers @elastic/appex-sharedux
x-pack/test/plugin_functional/plugins/global_search_test @elastic/kibana-core
x-pack/plugins/graph @elastic/kibana-visualizations
examples/grid_example @elastic/kibana-presentation
packages/kbn-grid-layout @elastic/kibana-presentation
x-pack/plugins/grokdebugger @elastic/kibana-management
packages/kbn-grouping @elastic/response-ops
packages/kbn-guided-onboarding @elastic/appex-sharedux

View file

@ -0,0 +1,3 @@
# Grid Example
This plugin is a playground and learning tool that demonstrates the Dashboard layout engine.

View file

@ -0,0 +1,13 @@
{
"type": "plugin",
"id": "@kbn/grid-example-plugin",
"owner": "@elastic/kibana-presentation",
"description": "Temporary example app used to build out the new Dashboard layout system",
"plugin": {
"id": "gridExample",
"server": false,
"browser": true,
"requiredPlugins": ["developerExamples"],
"requiredBundles": []
}
}

View file

@ -0,0 +1,69 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import ReactDOM from 'react-dom';
import { GridLayout, type GridLayoutData } from '@kbn/grid-layout';
import { AppMountParameters } from '@kbn/core-application-browser';
import { EuiPageTemplate, EuiProvider } from '@elastic/eui';
export const GridExample = () => {
return (
<EuiProvider>
<EuiPageTemplate offset={0} restrictWidth={false}>
<EuiPageTemplate.Header iconType={'dashboardApp'} pageTitle="Grid Layout Example" />
<EuiPageTemplate.Section>
<GridLayout
renderPanelContents={(id) => {
return <div style={{ padding: 8 }}>{id}</div>;
}}
getCreationOptions={() => {
const initialLayout: GridLayoutData = [
{
title: 'Large section',
isCollapsed: false,
panels: {
panel1: { column: 0, row: 0, width: 12, height: 6, id: 'panel1' },
panel2: { column: 0, row: 6, width: 8, height: 4, id: 'panel2' },
panel3: { column: 8, row: 6, width: 12, height: 4, id: 'panel3' },
panel4: { column: 0, row: 10, width: 48, height: 4, id: 'panel4' },
panel5: { column: 12, row: 0, width: 36, height: 6, id: 'panel5' },
panel6: { column: 24, row: 6, width: 24, height: 4, id: 'panel6' },
panel7: { column: 20, row: 6, width: 4, height: 2, id: 'panel7' },
panel8: { column: 20, row: 8, width: 4, height: 2, id: 'panel8' },
},
},
{
title: 'Small section',
isCollapsed: false,
panels: { panel9: { column: 0, row: 0, width: 12, height: 6, id: 'panel9' } },
},
{
title: 'Another small section',
isCollapsed: false,
panels: { panel10: { column: 24, row: 0, width: 12, height: 6, id: 'panel10' } },
},
];
return {
gridSettings: { gutterSize: 8, rowHeight: 26, columnCount: 48 },
initialLayout,
};
}}
/>
</EuiPageTemplate.Section>
</EuiPageTemplate>
</EuiProvider>
);
};
export const renderGridExampleApp = (element: AppMountParameters['element']) => {
ReactDOM.render(<GridExample />, element);
return () => ReactDOM.unmountComponentAtNode(element);
};

View file

@ -0,0 +1,11 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import { GridExamplePlugin } from './plugin';
export const plugin = () => new GridExamplePlugin();

View file

@ -0,0 +1,42 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import { AppMountParameters, CoreSetup, CoreStart, Plugin } from '@kbn/core/public';
import { DeveloperExamplesSetup } from '@kbn/developer-examples-plugin/public';
export const GRID_EXAMPLE_APP_ID = 'gridExample';
const gridExampleTitle = 'Grid Example';
interface GridExamplePluginSetupDependencies {
developerExamples: DeveloperExamplesSetup;
}
export class GridExamplePlugin
implements Plugin<void, void, GridExamplePluginSetupDependencies, {}>
{
public setup(core: CoreSetup<{}>, { developerExamples }: GridExamplePluginSetupDependencies) {
core.application.register({
id: GRID_EXAMPLE_APP_ID,
title: gridExampleTitle,
visibleIn: [],
async mount(params: AppMountParameters) {
const { renderGridExampleApp } = await import('./app');
return renderGridExampleApp(params.element);
},
});
developerExamples.register({
appId: GRID_EXAMPLE_APP_ID,
title: gridExampleTitle,
description: `A playground and learning tool that demonstrates the Dashboard layout engine.`,
});
}
public start(core: CoreStart, deps: {}) {}
public stop() {}
}

View file

@ -0,0 +1,14 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types"
},
"include": ["index.ts", "public/**/*.ts", "public/**/*.tsx", "../../typings/**/*"],
"exclude": ["target/**/*"],
"kbn_references": [
"@kbn/grid-layout",
"@kbn/core-application-browser",
"@kbn/core",
"@kbn/developer-examples-plugin",
]
}

View file

@ -524,6 +524,8 @@
"@kbn/global-search-providers-plugin": "link:x-pack/plugins/global_search_providers",
"@kbn/global-search-test-plugin": "link:x-pack/test/plugin_functional/plugins/global_search_test",
"@kbn/graph-plugin": "link:x-pack/plugins/graph",
"@kbn/grid-example-plugin": "link:examples/grid_example",
"@kbn/grid-layout": "link:packages/kbn-grid-layout",
"@kbn/grokdebugger-plugin": "link:x-pack/plugins/grokdebugger",
"@kbn/grouping": "link:packages/kbn-grouping",
"@kbn/guided-onboarding": "link:packages/kbn-guided-onboarding",

View file

@ -0,0 +1,3 @@
# @kbn/grid-layout
Contains a simple drag and drop layout engine for Kibana Dashboards.

View file

@ -0,0 +1,92 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import { EuiPortal, transparentize } from '@elastic/eui';
import { css } from '@emotion/react';
import { useBatchedPublishingSubjects } from '@kbn/presentation-publishing';
import { euiThemeVars } from '@kbn/ui-theme';
import React from 'react';
import { GridRow } from './grid_row';
import { GridLayoutData, GridSettings } from './types';
import { useGridLayoutEvents } from './use_grid_layout_events';
import { useGridLayoutState } from './use_grid_layout_state';
export const GridLayout = ({
getCreationOptions,
renderPanelContents,
}: {
getCreationOptions: () => { initialLayout: GridLayoutData; gridSettings: GridSettings };
renderPanelContents: (panelId: string) => React.ReactNode;
}) => {
const { gridLayoutStateManager, gridSizeRef } = useGridLayoutState({
getCreationOptions,
});
useGridLayoutEvents({ gridLayoutStateManager });
const [gridLayout, runtimeSettings, interactionEvent] = useBatchedPublishingSubjects(
gridLayoutStateManager.gridLayout$,
gridLayoutStateManager.runtimeSettings$,
gridLayoutStateManager.interactionEvent$
);
return (
<div ref={gridSizeRef}>
{gridLayout.map((rowData, rowIndex) => {
return (
<GridRow
rowData={rowData}
key={rowData.title}
rowIndex={rowIndex}
runtimeSettings={runtimeSettings}
activePanelId={interactionEvent?.id}
renderPanelContents={renderPanelContents}
targetRowIndex={interactionEvent?.targetRowIndex}
toggleIsCollapsed={() => {
const currentLayout = gridLayoutStateManager.gridLayout$.value;
currentLayout[rowIndex].isCollapsed = !currentLayout[rowIndex].isCollapsed;
gridLayoutStateManager.gridLayout$.next(currentLayout);
}}
setInteractionEvent={(nextInteractionEvent) => {
if (!nextInteractionEvent) {
gridLayoutStateManager.hideDragPreview();
}
gridLayoutStateManager.interactionEvent$.next(nextInteractionEvent);
}}
ref={(element) => (gridLayoutStateManager.rowRefs.current[rowIndex] = element)}
/>
);
})}
<EuiPortal>
<div
css={css`
top: 0;
left: 0;
width: 100vw;
height: 100vh;
position: fixed;
overflow: hidden;
pointer-events: none;
z-index: ${euiThemeVars.euiZModal};
`}
>
<div
ref={gridLayoutStateManager.dragPreviewRef}
css={css`
pointer-events: none;
z-index: ${euiThemeVars.euiZModal};
border-radius: ${euiThemeVars.euiBorderRadius};
background-color: ${transparentize(euiThemeVars.euiColorSuccess, 0.2)};
transition: opacity 100ms linear;
position: absolute;
`}
/>
</div>
</EuiPortal>
</div>
);
};

View file

@ -0,0 +1,158 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import {
EuiIcon,
EuiPanel,
euiFullHeight,
transparentize,
useEuiOverflowScroll,
} 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<PanelInteractionEvent, 'targetRowIndex'>) => void;
}) => {
const panelRef = useRef<HTMLDivElement>(null);
const ghostRef = useRef<HTMLDivElement>(null);
const thisPanelActive = activePanelId === panelData.id;
const interactionStart = useCallback(
(type: 'drag' | 'resize', e: React.DragEvent) => {
if (!panelRef.current || !ghostRef.current) return;
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.dropEffect = 'move';
e.dataTransfer.setDragImage(ghostRef.current, 0, 0);
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 (
<div
ref={panelRef}
css={css`
grid-column-start: ${panelData.column + 1};
grid-column-end: ${panelData.column + 1 + panelData.width};
grid-row-start: ${panelData.row + 1};
grid-row-end: ${panelData.row + 1 + panelData.height};
`}
>
<EuiPanel
hasShadow={false}
hasBorder={true}
css={css`
padding: 0;
position: relative;
height: 100%;
border: ${thisPanelActive
? `${euiThemeVars.euiBorderWidthThin} dashed ${euiThemeVars.euiColorSuccess}`
: 'auto'};
:hover .resizeHandle {
opacity: ${Boolean(activePanelId) ? 0 : 1};
}
:hover .dragHandle {
opacity: ${Boolean(activePanelId) ? 0 : 1};
}
`}
>
{/* Hidden dragging ghost */}
<div
css={css`
top: 0;
left: 0;
opacity: 0;
width: 100%;
height: 100%;
position: absolute;
pointer-events: none;
`}
ref={ghostRef}
/>
{/* drag handle */}
<div
draggable="true"
className="dragHandle"
css={css`
opacity: 0;
display: flex;
cursor: move;
position: absolute;
align-items: center;
justify-content: center;
top: -${euiThemeVars.euiSizeL};
width: ${euiThemeVars.euiSizeL};
height: ${euiThemeVars.euiSizeL};
z-index: ${euiThemeVars.euiZLevel3};
margin-left: ${euiThemeVars.euiSizeS};
border: 1px solid ${euiThemeVars.euiBorderColor};
background-color: ${euiThemeVars.euiColorEmptyShade};
border-radius: ${euiThemeVars.euiBorderRadius} ${euiThemeVars.euiBorderRadius} 0 0;
`}
onDragStart={(e: React.DragEvent<HTMLDivElement>) => interactionStart('drag', e)}
>
<EuiIcon type="grabOmnidirectional" />
</div>
{/* Resize handle */}
<div
draggable="true"
className="resizeHandle"
onDragStart={(e) => interactionStart('resize', e)}
css={css`
right: 0;
bottom: 0;
opacity: 0;
margin: -2px;
position: absolute;
width: ${euiThemeVars.euiSizeL};
height: ${euiThemeVars.euiSizeL};
transition: opacity 0.2s, border 0.2s;
border-radius: 7px 0 7px 0;
border-bottom: 2px solid ${euiThemeVars.euiColorSuccess};
border-right: 2px solid ${euiThemeVars.euiColorSuccess};
:hover {
background-color: ${transparentize(euiThemeVars.euiColorSuccess, 0.05)};
cursor: se-resize;
}
`}
/>
<div
css={css`
${euiFullHeight()}
${useEuiOverflowScroll('y', false)}
${useEuiOverflowScroll('x', false)}
`}
>
{renderPanelContents(panelData.id)}
</div>
</EuiPanel>
</div>
);
};

View file

@ -0,0 +1,124 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import { EuiButtonIcon, EuiFlexGroup, EuiSpacer, EuiTitle, transparentize } from '@elastic/eui';
import { css } from '@emotion/react';
import { euiThemeVars } from '@kbn/ui-theme';
import React, { forwardRef, useMemo } from 'react';
import { GridPanel } from './grid_panel';
import { GridRowData, PanelInteractionEvent, RuntimeGridSettings } from './types';
const gridColor = transparentize(euiThemeVars.euiColorSuccess, 0.2);
const getGridBackgroundCSS = (settings: RuntimeGridSettings) => {
const { gutterSize, columnPixelWidth, rowHeight } = settings;
return css`
background-position: top -${gutterSize / 2}px left -${gutterSize / 2}px;
background-size: ${columnPixelWidth + gutterSize}px ${rowHeight + gutterSize}px;
background-image: linear-gradient(to right, ${gridColor} 1px, transparent 1px),
linear-gradient(to bottom, ${gridColor} 1px, transparent 1px);
`;
};
export const GridRow = forwardRef<
HTMLDivElement,
{
rowIndex: number;
rowData: GridRowData;
toggleIsCollapsed: () => void;
activePanelId: string | undefined;
targetRowIndex: number | undefined;
runtimeSettings: RuntimeGridSettings;
renderPanelContents: (panelId: string) => React.ReactNode;
setInteractionEvent: (interactionData?: PanelInteractionEvent) => void;
}
>(
(
{
rowData,
rowIndex,
activePanelId,
targetRowIndex,
runtimeSettings,
toggleIsCollapsed,
renderPanelContents,
setInteractionEvent,
},
gridRef
) => {
const { gutterSize, columnCount, rowHeight } = runtimeSettings;
const isGridTargeted = activePanelId && targetRowIndex === rowIndex;
// calculate row count based on the number of rows needed to fit all panels
const rowCount = useMemo(() => {
const maxRow = Object.values(rowData.panels).reduce((acc, panel) => {
return Math.max(acc, panel.row + panel.height);
}, 0);
return maxRow || 1;
}, [rowData]);
return (
<>
{rowIndex !== 0 && (
<>
<EuiSpacer size="s" />
<EuiFlexGroup gutterSize="s">
<EuiButtonIcon
color="text"
iconType={rowData.isCollapsed ? 'arrowRight' : 'arrowDown'}
onClick={toggleIsCollapsed}
/>
<EuiTitle size="xs">
<h2>{rowData.title}</h2>
</EuiTitle>
</EuiFlexGroup>
<EuiSpacer size="s" />
</>
)}
{!rowData.isCollapsed && (
<div
ref={gridRef}
css={css`
display: grid;
gap: ${gutterSize}px;
justify-items: stretch;
grid-template-columns: repeat(
${columnCount},
calc((100% - ${gutterSize * (columnCount - 1)}px) / ${columnCount})
);
grid-template-rows: repeat(${rowCount}, ${rowHeight}px);
background-color: ${isGridTargeted
? transparentize(euiThemeVars.euiColorSuccess, 0.05)
: 'transparent'};
transition: background-color 300ms linear;
${isGridTargeted && getGridBackgroundCSS(runtimeSettings)}
`}
>
{Object.values(rowData.panels).map((panelData) => (
<GridPanel
key={panelData.id}
panelData={panelData}
activePanelId={activePanelId}
renderPanelContents={renderPanelContents}
setInteractionEvent={(partialInteractionEvent) => {
if (partialInteractionEvent) {
setInteractionEvent({
...partialInteractionEvent,
targetRowIndex: rowIndex,
});
return;
}
setInteractionEvent();
}}
/>
))}
</div>
)}
</>
);
}
);

View file

@ -0,0 +1,107 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import { GridPanelData, GridRowData } from './types';
const collides = (panelA: GridPanelData, panelB: GridPanelData) => {
if (panelA.id === panelB.id) return false; // same panel
if (panelA.column + panelA.width <= panelB.column) return false; // panel a is left of panel b
if (panelA.column >= panelB.column + panelB.width) return false; // panel a is right of panel b
if (panelA.row + panelA.height <= panelB.row) return false; // panel a is above panel b
if (panelA.row >= panelB.row + panelB.height) return false; // panel a is below panel b
return true; // boxes overlap
};
const getAllCollisionsWithPanel = (
panelToCheck: GridPanelData,
gridLayout: GridRowData,
keysInOrder: string[]
): GridPanelData[] => {
const collidingPanels: GridPanelData[] = [];
for (const key of keysInOrder) {
const comparePanel = gridLayout.panels[key];
if (comparePanel.id === panelToCheck.id) continue;
if (collides(panelToCheck, comparePanel)) {
collidingPanels.push(comparePanel);
}
}
return collidingPanels;
};
const getKeysInOrder = (rowData: GridRowData, draggedId?: string): string[] => {
const panelKeys = Object.keys(rowData.panels);
return panelKeys.sort((panelKeyA, panelKeyB) => {
const panelA = rowData.panels[panelKeyA];
const panelB = rowData.panels[panelKeyB];
// sort by row first
if (panelA.row > panelB.row) return 1;
if (panelA.row < panelB.row) return -1;
// if rows are the same. Is either panel being dragged?
if (panelA.id === draggedId) return -1;
if (panelB.id === draggedId) return 1;
// if rows are the same and neither panel is being dragged, sort by column
if (panelA.column > panelB.column) return 1;
if (panelA.column < panelB.column) return -1;
// fall back
return 1;
});
};
const compactGridRow = (originalLayout: GridRowData) => {
const nextRowData = { ...originalLayout, panels: { ...originalLayout.panels } };
// compact all vertical space.
const sortedKeysAfterMove = getKeysInOrder(nextRowData);
for (const panelKey of sortedKeysAfterMove) {
const panel = nextRowData.panels[panelKey];
// try moving panel up one row at a time until it collides
while (panel.row > 0) {
const collisions = getAllCollisionsWithPanel(
{ ...panel, row: panel.row - 1 },
nextRowData,
sortedKeysAfterMove
);
if (collisions.length !== 0) break;
panel.row -= 1;
}
}
return nextRowData;
};
export const resolveGridRow = (
originalRowData: GridRowData,
dragRequest?: GridPanelData
): GridRowData => {
const nextRowData = { ...originalRowData, panels: { ...originalRowData.panels } };
// Apply drag request
if (dragRequest) {
nextRowData.panels[dragRequest.id] = dragRequest;
}
// return nextRowData;
// push all panels down if they collide with another panel
const sortedKeys = getKeysInOrder(nextRowData, dragRequest?.id);
for (const key of sortedKeys) {
const panel = nextRowData.panels[key];
const collisions = getAllCollisionsWithPanel(panel, nextRowData, sortedKeys);
for (const collision of collisions) {
const rowOverlap = panel.row + panel.height - collision.row;
if (rowOverlap > 0) {
collision.row += rowOverlap;
}
}
}
const compactedGrid = compactGridRow(nextRowData);
return compactedGrid;
};

View file

@ -0,0 +1,97 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import { BehaviorSubject } from 'rxjs';
export interface GridCoordinate {
column: number;
row: number;
}
export interface GridRect extends GridCoordinate {
width: number;
height: number;
}
export interface GridPanelData extends GridRect {
id: string;
}
export interface GridRowData {
title: string;
isCollapsed: boolean;
panels: {
[key: string]: GridPanelData;
};
}
export type GridLayoutData = GridRowData[];
export interface GridSettings {
gutterSize: number;
rowHeight: number;
columnCount: number;
}
/**
* The runtime settings for the grid, including the pixel width of each column
* which is calculated on the fly based on the grid settings and the width of
* the containing element.
*/
export type RuntimeGridSettings = GridSettings & { columnPixelWidth: number };
export interface GridLayoutStateManager {
hideDragPreview: () => void;
updatePreviewElement: (rect: {
top: number;
left: number;
bottom: number;
right: number;
}) => void;
gridLayout$: BehaviorSubject<GridLayoutData>;
runtimeSettings$: BehaviorSubject<RuntimeGridSettings>;
rowRefs: React.MutableRefObject<Array<HTMLDivElement | null>>;
dragPreviewRef: React.MutableRefObject<HTMLDivElement | null>;
interactionEvent$: BehaviorSubject<PanelInteractionEvent | undefined>;
}
/**
* The information required to start a panel interaction.
*/
export interface PanelInteractionEvent {
/**
* The type of interaction being performed.
*/
type: 'drag' | 'resize';
/**
* The id of the panel being interacted with.
*/
id: string;
/**
* The index of the grid row this panel interaction is targeting.
*/
targetRowIndex: number;
/**
* The pixel rect of the panel being interacted with.
*/
panelDiv: HTMLDivElement;
/**
* The pixel offsets from where the mouse was at drag start to the
* edges of the panel
*/
mouseOffsets: {
top: number;
left: number;
right: number;
bottom: number;
};
}

View file

@ -0,0 +1,205 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import { useEffect, useRef } from 'react';
import { resolveGridRow } from './resolve_grid_row';
import { GridPanelData, GridLayoutStateManager } from './types';
export const isGridDataEqual = (a?: GridPanelData, b?: GridPanelData) => {
return (
a?.id === b?.id &&
a?.column === b?.column &&
a?.row === b?.row &&
a?.width === b?.width &&
a?.height === b?.height
);
};
export const useGridLayoutEvents = ({
gridLayoutStateManager,
}: {
gridLayoutStateManager: GridLayoutStateManager;
}) => {
const dragEnterCount = useRef(0);
const lastRequestedPanelPosition = useRef<GridPanelData | undefined>(undefined);
// -----------------------------------------------------------------------------------------
// Set up drag events
// -----------------------------------------------------------------------------------------
useEffect(() => {
const { runtimeSettings$, interactionEvent$, gridLayout$ } = gridLayoutStateManager;
const dragOver = (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
const gridRowElements = gridLayoutStateManager.rowRefs.current;
const previewElement = gridLayoutStateManager.dragPreviewRef.current;
const interactionEvent = interactionEvent$.value;
const isResize = interactionEvent?.type === 'resize';
const currentLayout = gridLayout$.value;
const currentGridData = (() => {
if (!interactionEvent) return;
for (const row of currentLayout) {
if (row.panels[interactionEvent.id]) return row.panels[interactionEvent.id];
}
})();
if (
!runtimeSettings$.value ||
!interactionEvent ||
!previewElement ||
!gridRowElements ||
!currentGridData
) {
return;
}
const mouseTargetPixel = { x: e.clientX, y: e.clientY };
const panelRect = interactionEvent.panelDiv.getBoundingClientRect();
const previewRect = {
left: isResize ? panelRect.left : mouseTargetPixel.x - interactionEvent.mouseOffsets.left,
top: isResize ? panelRect.top : mouseTargetPixel.y - interactionEvent.mouseOffsets.top,
bottom: mouseTargetPixel.y - interactionEvent.mouseOffsets.bottom,
right: mouseTargetPixel.x - interactionEvent.mouseOffsets.right,
};
gridLayoutStateManager.updatePreviewElement(previewRect);
// find the grid that the preview rect is over
const previewBottom =
previewRect.top + gridLayoutStateManager.runtimeSettings$.value.rowHeight;
const lastRowIndex = interactionEvent?.targetRowIndex;
const targetRowIndex = (() => {
if (isResize) return lastRowIndex;
let highestOverlap = -Infinity;
let highestOverlapRowIndex = -1;
gridRowElements.forEach((row, index) => {
if (!row) return;
const rowRect = row.getBoundingClientRect();
const overlap =
Math.min(previewBottom, rowRect.bottom) - Math.max(previewRect.top, rowRect.top);
if (overlap > highestOverlap) {
highestOverlap = overlap;
highestOverlapRowIndex = index;
}
});
return highestOverlapRowIndex;
})();
const hasChangedGridRow = targetRowIndex !== lastRowIndex;
// re-render when the target row changes
if (hasChangedGridRow) {
interactionEvent$.next({
...interactionEvent,
targetRowIndex,
});
}
// calculate the requested grid position
const { columnCount, gutterSize, rowHeight, columnPixelWidth } = runtimeSettings$.value;
const targetedGridRow = gridRowElements[targetRowIndex];
const targetedGridLeft = targetedGridRow?.getBoundingClientRect().left ?? 0;
const targetedGridTop = targetedGridRow?.getBoundingClientRect().top ?? 0;
const maxColumn = isResize ? columnCount : columnCount - currentGridData.width;
const localXCoordinate = isResize
? previewRect.right - targetedGridLeft
: previewRect.left - targetedGridLeft;
const localYCoordinate = isResize
? previewRect.bottom - targetedGridTop
: previewRect.top - targetedGridTop;
const targetColumn = Math.min(
Math.max(Math.round(localXCoordinate / (columnPixelWidth + gutterSize)), 0),
maxColumn
);
const targetRow = Math.max(Math.round(localYCoordinate / (rowHeight + gutterSize)), 0);
const requestedGridData = { ...currentGridData };
if (isResize) {
requestedGridData.width = Math.max(targetColumn - requestedGridData.column, 1);
requestedGridData.height = Math.max(targetRow - requestedGridData.row, 1);
} else {
requestedGridData.column = targetColumn;
requestedGridData.row = targetRow;
}
// resolve the new grid layout
if (
hasChangedGridRow ||
!isGridDataEqual(requestedGridData, lastRequestedPanelPosition.current)
) {
lastRequestedPanelPosition.current = { ...requestedGridData };
// remove the panel from the row it's currently in.
const nextLayout = currentLayout.map((row, rowIndex) => {
const { [interactionEvent.id]: interactingPanel, ...otherPanels } = row.panels;
return { ...row, panels: { ...otherPanels } };
});
// resolve destination grid
const destinationGrid = nextLayout[targetRowIndex];
const resolvedDestinationGrid = resolveGridRow(destinationGrid, requestedGridData);
nextLayout[targetRowIndex] = resolvedDestinationGrid;
// resolve origin grid
if (hasChangedGridRow) {
const originGrid = nextLayout[lastRowIndex];
const resolvedOriginGrid = resolveGridRow(originGrid);
nextLayout[lastRowIndex] = resolvedOriginGrid;
}
gridLayout$.next(nextLayout);
}
};
const onDrop = (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!interactionEvent$.value) return;
interactionEvent$.next(undefined);
gridLayoutStateManager.hideDragPreview();
dragEnterCount.current = 0;
};
const onDragEnter = (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!interactionEvent$.value) return;
dragEnterCount.current++;
};
const onDragLeave = (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!interactionEvent$.value) return;
dragEnterCount.current--;
if (dragEnterCount.current === 0) {
interactionEvent$.next(undefined);
gridLayoutStateManager.hideDragPreview();
dragEnterCount.current = 0;
}
};
window.addEventListener('drop', onDrop);
window.addEventListener('dragover', dragOver);
window.addEventListener('dragenter', onDragEnter);
window.addEventListener('dragleave', onDragLeave);
return () => {
window.removeEventListener('drop', dragOver);
window.removeEventListener('dragover', dragOver);
window.removeEventListener('dragenter', onDragEnter);
window.removeEventListener('dragleave', onDragLeave);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
};

View file

@ -0,0 +1,94 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import { debounce } from 'lodash';
import { useMemo, useRef } from 'react';
import { BehaviorSubject } from 'rxjs';
import useResizeObserver from 'use-resize-observer/polyfilled';
import {
GridLayoutData,
GridLayoutStateManager,
GridSettings,
PanelInteractionEvent,
RuntimeGridSettings,
} from './types';
export const useGridLayoutState = ({
getCreationOptions,
}: {
getCreationOptions: () => { initialLayout: GridLayoutData; gridSettings: GridSettings };
}): {
gridLayoutStateManager: GridLayoutStateManager;
gridSizeRef: (instance: HTMLDivElement | null) => void;
} => {
const rowRefs = useRef<Array<HTMLDivElement | null>>([]);
const dragPreviewRef = useRef<HTMLDivElement | null>(null);
const { gridLayoutStateManager, onWidthChange } = useMemo(() => {
const { initialLayout, gridSettings } = getCreationOptions();
const gridLayout$ = new BehaviorSubject<GridLayoutData>(initialLayout);
const interactionEvent$ = new BehaviorSubject<PanelInteractionEvent | undefined>(undefined);
const runtimeSettings$ = new BehaviorSubject<RuntimeGridSettings>({
...gridSettings,
columnPixelWidth: 0,
});
// debounce width changes to avoid re-rendering too frequently when the browser is resizing
const widthChange = debounce((elementWidth: number) => {
const columnPixelWidth =
(elementWidth - gridSettings.gutterSize * (gridSettings.columnCount - 1)) /
gridSettings.columnCount;
runtimeSettings$.next({ ...gridSettings, columnPixelWidth });
}, 250);
return {
gridLayoutStateManager: {
rowRefs,
gridLayout$,
dragPreviewRef,
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';
},
},
onWidthChange: widthChange,
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const { ref: gridSizeRef } = useResizeObserver<HTMLDivElement>({
onResize: (dimensions) => {
if (dimensions.width) {
onWidthChange(dimensions.width);
}
},
});
return { gridLayoutStateManager, gridSizeRef };
};

View file

@ -0,0 +1,10 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
export { GridLayout } from './grid/grid_layout';
export type { GridLayoutData, GridPanelData, GridRowData, GridSettings } from './grid/types';

View file

@ -0,0 +1,13 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
module.exports = {
preset: '@kbn/test',
rootDir: '../..',
roots: ['<rootDir>/packages/kbn-grid-layout'],
};

View file

@ -0,0 +1,5 @@
{
"type": "shared-browser",
"id": "@kbn/grid-layout",
"owner": "@elastic/kibana-presentation"
}

View file

@ -0,0 +1,6 @@
{
"name": "@kbn/grid-layout",
"private": true,
"version": "1.0.0",
"license": "SSPL-1.0 OR Elastic License 2.0"
}

View file

@ -0,0 +1,23 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types",
"types": [
"jest",
"node",
"react",
"@emotion/react/types/css-prop"
]
},
"include": [
"**/*.ts",
"**/*.tsx",
],
"exclude": [
"target/**/*"
],
"kbn_references": [
"@kbn/presentation-publishing",
"@kbn/ui-theme",
]
}

View file

@ -950,6 +950,10 @@
"@kbn/global-search-test-plugin/*": ["x-pack/test/plugin_functional/plugins/global_search_test/*"],
"@kbn/graph-plugin": ["x-pack/plugins/graph"],
"@kbn/graph-plugin/*": ["x-pack/plugins/graph/*"],
"@kbn/grid-example-plugin": ["examples/grid_example"],
"@kbn/grid-example-plugin/*": ["examples/grid_example/*"],
"@kbn/grid-layout": ["packages/kbn-grid-layout"],
"@kbn/grid-layout/*": ["packages/kbn-grid-layout/*"],
"@kbn/grokdebugger-plugin": ["x-pack/plugins/grokdebugger"],
"@kbn/grokdebugger-plugin/*": ["x-pack/plugins/grokdebugger/*"],
"@kbn/grouping": ["packages/kbn-grouping"],

View file

@ -5191,6 +5191,14 @@
version "0.0.0"
uid ""
"@kbn/grid-example-plugin@link:examples/grid_example":
version "0.0.0"
uid ""
"@kbn/grid-layout@link:packages/kbn-grid-layout":
version "0.0.0"
uid ""
"@kbn/grokdebugger-plugin@link:x-pack/plugins/grokdebugger":
version "0.0.0"
uid ""