diff --git a/packages/scenes/src/components/VizPanel/VizPanelRenderer.tsx b/packages/scenes/src/components/VizPanel/VizPanelRenderer.tsx index e8c8cee10..6a9f6d5bd 100644 --- a/packages/scenes/src/components/VizPanel/VizPanelRenderer.tsx +++ b/packages/scenes/src/components/VizPanel/VizPanelRenderer.tsx @@ -51,6 +51,7 @@ export function VizPanelRenderer({ model }: SceneComponentProps) { const plugin = model.getPlugin(); const { dragClass, dragClassCancel } = getDragClasses(model); + const dragHooks = getDragHooks(model); const dataObject = sceneGraph.getData(model); const rawData = dataObject.useState(); @@ -192,6 +193,7 @@ export function VizPanelRenderer({ model }: SceneComponentProps) { onFocus={setPanelAttention} onMouseEnter={setPanelAttention} onMouseMove={debouncedMouseMove} + onDragStart={dragHooks.onDragStart} collapsible={collapsible} collapsed={collapsed} onToggleCollapse={model.onToggleCollapse} @@ -257,6 +259,11 @@ function getDragClasses(panel: VizPanel) { return { dragClass: parentLayout.getDragClass?.(), dragClassCancel: parentLayout?.getDragClassCancel?.() }; } +function getDragHooks(panel: VizPanel) { + const parentLayout = sceneGraph.getLayout(panel); + return { onDragStart: parentLayout?.onPointerDown }; +} + /** * Walks up the parent chain until it hits the layout object, trying to find the closest SceneGridItemLike ancestor. * It is not always the direct parent, because the VizPanel can be wrapped in other objects. diff --git a/packages/scenes/src/components/layout/CSSGrid/SceneCSSGridItem.tsx b/packages/scenes/src/components/layout/CSSGrid/SceneCSSGridItem.tsx new file mode 100644 index 000000000..5aeb168bf --- /dev/null +++ b/packages/scenes/src/components/layout/CSSGrid/SceneCSSGridItem.tsx @@ -0,0 +1,58 @@ +import { css } from '@emotion/css'; +import React, { CSSProperties } from 'react'; +import { SceneObjectBase } from '../../../core/SceneObjectBase'; +import { SceneComponentProps, SceneObject, SceneObjectState } from '../../../core/types'; +import { useStyles2 } from '@grafana/ui'; +import { GrafanaTheme2 } from '@grafana/data'; + +export interface SceneCSSGridItemPlacement { + /** + * True when the item should rendered but not visible. + * Useful for conditional display of layout items + */ + isHidden?: boolean; + /** + * Useful for making content span across multiple rows or columns + */ + gridColumn?: CSSProperties['gridColumn']; + gridRow?: CSSProperties['gridRow']; +} + +export interface SceneCSSGridItemState extends SceneCSSGridItemPlacement, SceneObjectState { + body?: SceneObject; +} + +export interface SceneCSSGridItemRenderProps extends SceneComponentProps { + parentState?: SceneCSSGridItemPlacement; +} + +export class SceneCSSGridItem extends SceneObjectBase { + public static Component = SceneCSSGridItemRenderer; +} + +function SceneCSSGridItemRenderer({ model, parentState }: SceneCSSGridItemRenderProps) { + if (!parentState) { + throw new Error('SceneCSSGridItem must be a child of SceneCSSGridLayout'); + } + + const { body, isHidden } = model.useState(); + const styles = useStyles2(getStyles, model.state); + + if (!body || isHidden) { + return null; + } + + return ( +
+ +
+ ); +} + +const getStyles = (_theme: GrafanaTheme2, state: SceneCSSGridItemState) => ({ + wrapper: css({ + gridColumn: state.gridColumn || 'unset', + gridRow: state.gridRow || 'unset', + position: 'relative', // Needed for VizPanel + }), +}); diff --git a/packages/scenes/src/components/layout/CSSGrid/SceneCSSGridLayout.tsx b/packages/scenes/src/components/layout/CSSGrid/SceneCSSGridLayout.tsx index 30d9f1841..7d939c249 100644 --- a/packages/scenes/src/components/layout/CSSGrid/SceneCSSGridLayout.tsx +++ b/packages/scenes/src/components/layout/CSSGrid/SceneCSSGridLayout.tsx @@ -1,10 +1,12 @@ -import { css, CSSObject } from '@emotion/css'; -import React, { ComponentType, CSSProperties, useMemo } from 'react'; +import { css } from '@emotion/css'; +import React, { ComponentType, CSSProperties, useLayoutEffect, useRef } from 'react'; import { SceneObjectBase } from '../../../core/SceneObjectBase'; -import { SceneComponentProps, SceneLayout, SceneObjectState, SceneObject } from '../../../core/types'; -import { config } from '@grafana/runtime'; +import { SceneLayout, SceneObjectState, SceneObject } from '../../../core/types'; import { LazyLoader } from '../LazyLoader'; +import { GrafanaTheme2 } from '@grafana/data'; +import { useStyles2 } from '@grafana/ui'; +import { SceneCSSGridItem, SceneCSSGridItemRenderProps } from './SceneCSSGridItem'; export interface SceneCSSGridLayoutState extends SceneObjectState, SceneCSSGridLayoutOptions { children: Array; @@ -19,6 +21,7 @@ export interface SceneCSSGridLayoutState extends SceneObjectState, SceneCSSGridL md?: SceneCSSGridLayoutOptions; /** True when the items should be lazy loaded */ isLazy?: boolean; + _draggingIndex?: number; } export interface SceneCSSGridLayoutOptions { @@ -45,9 +48,16 @@ export interface SceneCSSGridLayoutOptions { justifyContent?: CSSProperties['justifyContent']; } +const ARROW_KEYS = new Set(['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown']); + export class SceneCSSGridLayout extends SceneObjectBase implements SceneLayout { public static Component = SceneCSSGridLayoutRenderer; + private gridCells: Rect[] = []; + private dragOffset = { x: 0, y: 0 }; + private previewCell: Rect | undefined; + private oldCursor = ''; + public constructor(state: Partial) { super({ rowGap: 1, @@ -57,126 +67,409 @@ export class SceneCSSGridLayout extends SceneObjectBase children: state.children ?? [], ...state, }); + + this.onPointerDown = this.onPointerDown.bind(this); + this.onPointerMove = this.onPointerMove.bind(this); + this.onPointerUp = this.onPointerUp.bind(this); } public isDraggable(): boolean { - return false; + return true; + } + + public getDragClass() { + return `grid-drag-handle-${this.state.key}`; + } + + public getDragClassCancel() { + return 'grid-drag-cancel'; + } + + public onPointerDown(e: React.PointerEvent) { + if (!this.container || this.cannotDrag(e.target)) { + return; + } + + e.preventDefault(); + e.stopPropagation(); + + this.oldCursor = document.body.style.cursor; + document.body.style.cursor = 'move'; + document.body.style.userSelect = 'none'; + document.body.setPointerCapture(e.pointerId); + document.addEventListener('pointermove', this.onPointerMove); + document.addEventListener('pointerup', this.onPointerUp); + + // Find closest grid cell and make note of which cell is closest to the current mouse position (it is the cell we are dragging) + this.gridCells = calculateGridCells(this.container).filter((c) => c.order >= 0); + const mousePos = { x: e.clientX, y: e.clientY }; + const scrollTop = closestScroll(this.container); + this.previewCell = closestCell(this.gridCells, { x: mousePos.x, y: mousePos.y + scrollTop}); + + // get the layout item that occupies the previously found closest cell + this.draggingRef = this.container.children[this.previewCell.order + 1] as HTMLElement; + const elementBox = this.draggingRef.getBoundingClientRect(); + this.dragOffset = { x: e.clientX - elementBox.left, y: e.clientY - elementBox.top }; + + // set the layout item's dimensions to what they were when they were in the grid + const computedStyles = getComputedStyle(this.draggingRef); + const newCoords = { x: mousePos.x - this.dragOffset.x, y: mousePos.y - this.dragOffset.y }; + this.draggingRef.style.width = computedStyles.width; + this.draggingRef.style.height = computedStyles.height; + this.draggingRef.style.transform = `translate(${newCoords.x}px,${newCoords.y}px)`; + this.draggingRef.style.zIndex = '999999'; + this.draggingRef.style.position = 'fixed'; + this.draggingRef.style.top = '0'; + this.draggingRef.style.left = '0'; + + this.movePreviewToCell(this.previewCell.rowIndex, this.previewCell.columnIndex); + + // setting _draggingIndex re-renders the component and sets the various element refs referred to in the onPointerMove/Up handlers + this.setState({ _draggingIndex: this.previewCell.order }); + } + + private onPointerMove(e: PointerEvent) { + // seems to get called after onPointerDown is called and after the component re-renders, so element refs should all be set + // but if there's ever weird behavior it's probably a race condition related to this + e.preventDefault(); + e.stopPropagation(); + + // we're not dragging but this handler was called, maybe left mouse was lifted on a different screen or something + const notDragging = !(e.buttons & 1); + if (notDragging) { + this.onPointerUp(e); + return; + } + + if (this.draggingRef) { + const dragCurrent = { x: e.clientX, y: e.clientY }; + const newX = dragCurrent.x - this.dragOffset.x; + const newY = dragCurrent.y - this.dragOffset.y; + this.draggingRef.style.transform = `translate(${newX}px,${newY}px)`; + + const scrollTop = closestScroll(this.draggingRef); + const closestGridCell = closestCell(this.gridCells, { x: dragCurrent.x, y: dragCurrent.y + scrollTop }); + const closestIndex = closestGridCell.order; + + const newCellEntered = + this.previewCell && + (closestGridCell.columnIndex !== this.previewCell.columnIndex || + closestGridCell.rowIndex !== this.previewCell.rowIndex); + + if (newCellEntered) { + this.moveChild(this.previewCell!.order, closestIndex); + this.previewCell = closestGridCell; + this.movePreviewToCell(this.previewCell.rowIndex, this.previewCell.columnIndex); + this.setState({ _draggingIndex: closestIndex }); + } + } + } + + private onPointerUp(e: PointerEvent) { + e.preventDefault(); + e.stopPropagation(); + + document.body.style.cursor = this.oldCursor; + document.body.style.removeProperty('user-select'); + document.body.releasePointerCapture(e.pointerId); + document.removeEventListener('pointermove', this.onPointerMove); + document.removeEventListener('pointerup', this.onPointerUp); + + clearInlineStyles(this.draggingRef); + clearInlineStyles(this.dropPreview); + + this.setState({ _draggingIndex: undefined }); + } + + public onKeyDown(e: React.KeyboardEvent, itemIndex: number) { + if (this.state._draggingIndex !== undefined) { + return; + } + + if (ARROW_KEYS.has(e.key)) { + this.gridCells = calculateGridCells(this.container!).filter((c) => c.order >= 0); + } + + const cellIndex = this.gridCells.findIndex((c) => c.order === itemIndex); + + if (e.key === 'ArrowLeft') { + if (itemIndex === 0) { + return; + } + + e.preventDefault(); + e.stopPropagation(); + this.moveChild(itemIndex, this.gridCells[cellIndex - 1].order); + } else if (e.key === 'ArrowRight') { + if (itemIndex === this.state.children.length - 1) { + return; + } + + e.preventDefault(); + e.stopPropagation(); + this.moveChild(itemIndex, this.gridCells[cellIndex + 1].order); + } else if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { + e.preventDefault(); + e.stopPropagation(); + + const style = getComputedStyle(this.container!); + const columns = style.gridTemplateColumns.split(' '); + const columnCount = columns.length; + const currentColumn = cellIndex % columnCount; + const rows = style.gridTemplateRows.split(' '); + const rowCount = rows.length; + const currentRow = Math.floor(cellIndex / columnCount); + + let newRow = currentRow; + if (e.key === 'ArrowUp' && currentRow > 0) { + newRow = currentRow - 1; + } else if (e.key === 'ArrowDown' && currentRow < rowCount - 1) { + newRow = currentRow + 1; + } + + const newIndex = newRow * columnCount + currentColumn; + this.moveChild(itemIndex, this.gridCells[newIndex].order); + } + } + + private cannotDrag(el: EventTarget) { + const dragCancelClass = this.getDragClassCancel(); + const dragClass = this.getDragClass(); + + // cancel dragging if the element being interacted with has an ancestor with the drag cancel class set + // or if the drag class isn't set on an ancestor + const cannotDrag = + el instanceof Element && + (el.classList.contains(dragCancelClass) || + el.matches(`.${dragCancelClass} *`) || + (!el.matches(`.${dragClass} *`) && !el.classList.contains(dragClass))); + + return cannotDrag; + } + + private moveChild(from: number, to: number) { + const children = [...this.state.children]; + const childToMove = children.splice(from, 1)[0]; + children.splice(to, 0, childToMove); + this.setState({ children }); + } + + private movePreviewToCell(rowIndex: number, columnIndex: number) { + if (this.dropPreview) { + this.dropPreview.style.position = 'relative'; + this.dropPreview.style.width = '100%'; + this.dropPreview.style.height = '100%'; + this.dropPreview.style.gridRow = `${rowIndex} / span 1`; + this.dropPreview.style.gridColumn = `${columnIndex} / span 1`; + } + } + + private container: HTMLElement | undefined; + public setContainer(el: HTMLElement) { + this.container = el; + } + + private dropPreview: HTMLElement | undefined; + public setPreview(el: HTMLElement) { + this.dropPreview = el; + } + + private draggingRef: HTMLElement | undefined; + public setDraggingRef(el: HTMLElement) { + this.draggingRef = el; } } function SceneCSSGridLayoutRenderer({ model }: SceneCSSGridItemRenderProps) { - const { children, isHidden, isLazy } = model.useState(); - const style = useLayoutStyle(model.state); + const { children, isHidden, isLazy, _draggingIndex } = model.useState(); + const styles = useStyles2(getStyles, model.state); + const previewRef = useRef(null); + const containerRef = useRef(null); + const draggingRef = useRef(null); + + useLayoutEffect(() => { + if (containerRef.current) { + model.setContainer(containerRef.current); + } + + if (previewRef.current) { + model.setPreview(previewRef.current); + } + + if (draggingRef.current) { + model.setDraggingRef(draggingRef.current); + } + }); if (isHidden) { return null; } return ( -
- {children.map((item) => { +
+
+ {children.map((item, i) => { const Component = item.Component as ComponentType>; + const Wrapper = isLazy ? LazyLoader : 'div'; + const isHidden = 'isHidden' in item.state && typeof item.state.isHidden === 'boolean' && item.state.isHidden; - if (isLazy) { - return ( - - - - ); - } - return ; + return ( + model.onKeyDown(e, i)} + className={styles.itemWrapper} + data-order={isHidden ? -1 : i} + > + + + ); })}
); } -export interface SceneCSSGridItemPlacement { - /** - * True when the item should rendered but not visible. - * Useful for conditional display of layout items - */ - isHidden?: boolean; - /** - * Useful for making content span across multiple rows or columns - */ - gridColumn?: CSSProperties['gridColumn']; - gridRow?: CSSProperties['gridRow']; -} +const getStyles = (theme: GrafanaTheme2, state: SceneCSSGridLayoutState) => ({ + dropPreview: css({ + position: 'absolute', + top: 0, + left: 0, + width: 0, + height: 0, + background: theme.colors.primary.transparent, + boxShadow: `0 0 4px ${theme.colors.primary.border}`, + }), + container: css({ + display: 'grid', + position: 'relative', + gridTemplateColumns: state.templateColumns, + gridTemplateRows: state.templateRows || 'unset', + gridAutoRows: state.autoRows || 'unset', + rowGap: theme.spacing(state.rowGap ?? 1), + columnGap: theme.spacing(state.columnGap ?? 1), + justifyItems: state.justifyItems || 'unset', + alignItems: state.alignItems || 'unset', + justifyContent: state.justifyContent || 'unset', + flexGrow: 1, -export interface SceneCSSGridItemState extends SceneCSSGridItemPlacement, SceneObjectState { - body: SceneObject | undefined; -} + [theme.breakpoints.down('md')]: state.md + ? { + gridTemplateRows: state.md.templateRows, + gridTemplateColumns: state.md.templateColumns, + rowGap: state.md.rowGap ? theme.spacing(state.md.rowGap ?? 1) : undefined, + columnGap: state.md.columnGap ? theme.spacing(state.md.rowGap ?? 1) : undefined, + justifyItems: state.md.justifyItems, + alignItems: state.md.alignItems, + justifyContent: state.md.justifyContent, + } + : undefined, + }), + itemWrapper: css({ + display: 'grid' + }) +}); -interface SceneCSSGridItemRenderProps extends SceneComponentProps { - parentState?: SceneCSSGridItemPlacement; +interface Point { + x: number; + y: number; } -export class SceneCSSGridItem extends SceneObjectBase { - public static Component = SceneCSSGridItemRenderer; +interface Rect { + top: number; + left: number; + bottom: number; + right: number; + rowIndex: number; + columnIndex: number; + order: number; } -function SceneCSSGridItemRenderer({ model, parentState }: SceneCSSGridItemRenderProps) { - if (!parentState) { - throw new Error('SceneCSSGridItem must be a child of SceneCSSGridLayout'); +function closestCell(rects: Rect[], point: Point) { + let closest = rects[0]; + let shortestDistance = Number.MAX_SAFE_INTEGER; + for (const rect of rects) { + const topLeft = { x: rect.left, y: rect.top }; + const topRight = { x: rect.right, y: rect.top}; + const bottomLeft = { x: rect.left, y: rect.bottom}; + const bottomRight = { x: rect.right, y: rect.bottom}; + const corners = [topLeft, topRight, bottomLeft, bottomRight]; + + for (const corner of corners) { + const distance = Math.hypot(corner.x - point.x, corner.y - point.y); + if (distance < shortestDistance) { + shortestDistance = distance; + closest = rect; + } + } } - const { body, isHidden } = model.useState(); - const style = useItemStyle(model.state); + return closest; +} - if (!body || isHidden) { - return null; - } +function getGridStyles(gridElement: HTMLElement) { + const gridStyles = getComputedStyle(gridElement); - return ( -
- -
- ); + return { + templateRows: gridStyles.gridTemplateRows.split(' ').map((row) => parseFloat(row)), + templateColumns: gridStyles.gridTemplateColumns.split(' ').map((col) => parseFloat(col)), + rowGap: parseFloat(gridStyles.rowGap), + columnGap: parseFloat(gridStyles.columnGap), + }; } -function useLayoutStyle(state: SceneCSSGridLayoutState) { - return useMemo(() => { - const {} = state; - // only need breakpoints so accessing theme from config instead of context is ok - const style: CSSObject = {}; - const theme = config.theme2; - - style.display = 'grid'; - style.gridTemplateColumns = state.templateColumns; - style.gridTemplateRows = state.templateRows || 'unset'; - style.gridAutoRows = state.autoRows || 'unset'; - style.rowGap = theme.spacing(state.rowGap ?? 1); - style.columnGap = theme.spacing(state.columnGap ?? 1); - style.justifyItems = state.justifyItems || 'unset'; - style.alignItems = state.alignItems || 'unset'; - style.justifyContent = state.justifyContent || 'unset'; - style.flexGrow = 1; - - if (state.md) { - style[theme.breakpoints.down('md')] = { - gridTemplateRows: state.md?.templateRows, - gridTemplateColumns: state.md?.templateColumns, - rowGap: state.md.rowGap ? theme.spacing(state.md?.rowGap ?? 1) : undefined, - columnGap: state.md.columnGap ? theme.spacing(state.md?.rowGap ?? 1) : undefined, - justifyItems: state.md?.justifyItems, - alignItems: state.md?.alignItems, - justifyContent: state.md?.justifyContent, +function calculateGridCells(gridElement: HTMLElement) { + const { templateRows, templateColumns, rowGap, columnGap } = getGridStyles(gridElement); + const gridBoundingBox = gridElement.getBoundingClientRect(); + const scrollTop = closestScroll(gridElement); + const gridOrigin = { x: gridBoundingBox.left, y: gridBoundingBox.top + scrollTop }; + const ids = [...gridElement.children].map((c) => Number.parseInt(c.getAttribute('data-order') ?? '-1', 10)).filter((v) => v >= 0); + + const rects: Rect[] = []; + let yTotal = gridOrigin.y; + for (let rowIndex = 0; rowIndex < templateRows.length; rowIndex++) { + const height = templateRows[rowIndex]; + const row = { + top: yTotal, + bottom: yTotal + height, + }; + yTotal = row.bottom + rowGap; + + let xTotal = gridOrigin.x; + for (let colIndex = 0; colIndex < templateColumns.length; colIndex++) { + const width = templateColumns[colIndex]; + const column = { + left: xTotal, + right: xTotal + width, }; + + xTotal = column.right + columnGap; + rects.push({ + left: column.left, + right: column.right, + top: row.top, + bottom: row.bottom, + rowIndex: rowIndex + 1, + columnIndex: colIndex + 1, + order: ids[rowIndex * templateColumns.length + colIndex], + }); } + } - return css(style); - }, [state]); + return rects; } -function useItemStyle(state: SceneCSSGridItemState) { - return useMemo(() => { - const style: CSSObject = {}; +function clearInlineStyles(el?: HTMLElement) { + if (!el) { + return; + } - style.gridColumn = state.gridColumn || 'unset'; - style.gridRow = state.gridRow || 'unset'; - // Needed for VizPanel - style.position = 'relative'; + el.style.cssText = ''; +} - return css(style); - }, [state]); +function closestScroll(el?: HTMLElement | null): number { + if (el && el.scrollTop > 0) { + return el.scrollTop; + } + + return el ? closestScroll(el.parentElement) : 0; } diff --git a/packages/scenes/src/core/types.ts b/packages/scenes/src/core/types.ts index 666586c61..8d0cb3cc5 100644 --- a/packages/scenes/src/core/types.ts +++ b/packages/scenes/src/core/types.ts @@ -143,6 +143,7 @@ export interface SceneLayout exte isDraggable(): boolean; getDragClass?(): string; getDragClassCancel?(): string; + onPointerDown?(e: React.PointerEvent): void; } export interface SceneTimeRangeState extends SceneObjectState { diff --git a/packages/scenes/src/index.ts b/packages/scenes/src/index.ts index 3b2c4a371..0166be08f 100644 --- a/packages/scenes/src/index.ts +++ b/packages/scenes/src/index.ts @@ -95,7 +95,8 @@ export { type SceneFlexItemState, type SceneFlexItemLike, } from './components/layout/SceneFlexLayout'; -export { SceneCSSGridLayout, SceneCSSGridItem } from './components/layout/CSSGrid/SceneCSSGridLayout'; +export { SceneCSSGridLayout } from './components/layout/CSSGrid/SceneCSSGridLayout'; +export { SceneCSSGridItem } from './components/layout/CSSGrid/SceneCSSGridItem'; export { SceneGridLayout } from './components/layout/grid/SceneGridLayout'; export { SceneGridItem } from './components/layout/grid/SceneGridItem'; export { SceneGridRow } from './components/layout/grid/SceneGridRow';