From d5f970a77531400e1adb5d07e83805a26de22870 Mon Sep 17 00:00:00 2001 From: "Mr.Dr.Professor Patrick" Date: Wed, 20 Nov 2024 16:08:27 +0100 Subject: [PATCH] feat(D3 plugin): add `splitTooltip` property (#539) * refactor: add typed SplitPane * feat(D3 plugin): add splitTooltip property * chore: remove Gravity Charts stories from development mode * chore: remove temporary property from hc stories * fix: review fixes --- .storybook/main.ts | 16 +- package-lock.json | 88 ++-- package.json | 5 +- src/components/SplitPane/Pane.tsx | 46 ++ src/components/SplitPane/Resizer.tsx | 65 +++ src/components/SplitPane/SplitPane.tsx | 416 ++++++++++++++++++ .../SplitPane}/StyledSplitPane.scss | 4 +- .../SplitPane}/StyledSplitPane.tsx | 7 +- src/components/SplitPane/constants.ts | 4 + src/components/SplitPane/index.ts | 25 ++ src/components/SplitPane/types.ts | 3 + src/constants/index.ts | 1 + src/constants/misc.ts | 28 ++ src/hooks/index.ts | 1 + src/hooks/misc.ts | 11 + src/plugins/d3/renderer/D3Widget.tsx | 9 +- .../__stories__/SplitTooltip.stories.tsx | 53 +++ .../d3/renderer/__stories__/StoryWrapper.tsx | 27 ++ .../renderer/withSplitPane/TooltipContent.tsx | 31 ++ .../withSplitPane/useWithSplitPaneState.ts | 74 ++++ .../renderer/withSplitPane/withSplitPane.tsx | 185 ++++++++ .../withSplitPane/withSplitPane.tsx | 2 +- src/types/widget.ts | 10 +- src/utils/index.ts | 1 + src/utils/misc.ts | 10 + 25 files changed, 1071 insertions(+), 51 deletions(-) create mode 100644 src/components/SplitPane/Pane.tsx create mode 100644 src/components/SplitPane/Resizer.tsx create mode 100644 src/components/SplitPane/SplitPane.tsx rename src/{plugins/highcharts/renderer/components/StyledSplitPane => components/SplitPane}/StyledSplitPane.scss (95%) rename src/{plugins/highcharts/renderer/components/StyledSplitPane => components/SplitPane}/StyledSplitPane.tsx (88%) create mode 100644 src/components/SplitPane/constants.ts create mode 100644 src/components/SplitPane/index.ts create mode 100644 src/components/SplitPane/types.ts create mode 100644 src/constants/misc.ts create mode 100644 src/hooks/index.ts create mode 100644 src/hooks/misc.ts create mode 100644 src/plugins/d3/renderer/__stories__/SplitTooltip.stories.tsx create mode 100644 src/plugins/d3/renderer/__stories__/StoryWrapper.tsx create mode 100644 src/plugins/d3/renderer/withSplitPane/TooltipContent.tsx create mode 100644 src/plugins/d3/renderer/withSplitPane/useWithSplitPaneState.ts create mode 100644 src/plugins/d3/renderer/withSplitPane/withSplitPane.tsx create mode 100644 src/utils/misc.ts diff --git a/.storybook/main.ts b/.storybook/main.ts index 61de4301..b004e66c 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -15,11 +15,17 @@ const config: StorybookConfig = { {name: '@storybook/addon-essentials', options: {backgrounds: false}}, './theme-addon/register.tsx', ], - refs: { - 'gravity-charts': { - title: 'Gravity Charts', - url: 'https://preview.gravity-ui.com/charts', - }, + refs: (_config, {configType}) => { + if (configType !== 'PRODUCTION') { + return {} as Record; + } + + return { + 'gravity-charts': { + title: 'Gravity Charts', + url: 'https://preview.gravity-ui.com/charts', + }, + }; }, }; diff --git a/package-lock.json b/package-lock.json index caf7b012..580f5335 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,13 +10,12 @@ "license": "MIT", "dependencies": { "@bem-react/classname": "^1.6.0", - "@gravity-ui/charts": "^0.2.0", + "@gravity-ui/charts": "^0.4.0", "@gravity-ui/date-utils": "^2.1.0", "@gravity-ui/i18n": "^1.0.0", "@gravity-ui/yagr": "^4.3.4", "afterframe": "^1.0.2", "lodash": "^4.17.21", - "react-split-pane": "^0.1.92", "tslib": "^2.6.2" }, "devDependencies": { @@ -27,7 +26,7 @@ "@gravity-ui/prettier-config": "^1.1.0", "@gravity-ui/stylelint-config": "^4.0.1", "@gravity-ui/tsconfig": "^1.0.0", - "@gravity-ui/uikit": "^6.0.0", + "@gravity-ui/uikit": "^6.35.2", "@jest/types": "^29.6.3", "@playwright/experimental-ct-react17": "^1.41.1", "@storybook/addon-actions": "^7.6.14", @@ -3012,9 +3011,9 @@ } }, "node_modules/@gravity-ui/charts": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@gravity-ui/charts/-/charts-0.2.0.tgz", - "integrity": "sha512-/UzRu15UExTguNa4VnpkciM6ns8alvBHpEicw+X4WPeThNCQ5ryGOvUcXAMcNjLpswYZTSY0nOpOpBTjuMA2eQ==", + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@gravity-ui/charts/-/charts-0.4.0.tgz", + "integrity": "sha512-8KFDrhSc/MTFwhqnvTBbkxeVdsz2AXXbuLbn3dfgt2MEnXqwT508vfNc8c814A5Dfdz5JKeXSsuhdQccnqCJIw==", "dependencies": { "@bem-react/classname": "^1.6.0", "@gravity-ui/date-utils": "^2.5.4", @@ -3282,18 +3281,19 @@ "license": "MIT" }, "node_modules/@gravity-ui/uikit": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@gravity-ui/uikit/-/uikit-6.0.0.tgz", - "integrity": "sha512-16xgkTI646tZoAT5pnw7Ge2Q4e9c8DWYZw0w11X8A9hyuYaNjd0isUxpD4Beywi4ELKeFv3t2IXCn/RTU7/qVw==", + "version": "6.35.2", + "resolved": "https://registry.npmjs.org/@gravity-ui/uikit/-/uikit-6.35.2.tgz", + "integrity": "sha512-fwt9IRDS2E9MEs9l/3b5SRdrVvYs7W4bfs4VPi0qjvO5p/55N7OfVO4mvvxSbAh+CkmySi514rwhzfKbUpO62Q==", "dev": true, "dependencies": { "@bem-react/classname": "^1.6.0", - "@gravity-ui/i18n": "^1.2.0", + "@gravity-ui/i18n": "^1.6.0", "@gravity-ui/icons": "^2.8.1", "@popperjs/core": "^2.11.8", "blueimp-md5": "^2.19.0", "focus-trap": "^7.5.4", "lodash": "^4.17.21", + "rc-slider": "^10.6.2", "react-beautiful-dnd": "^13.1.1", "react-copy-to-clipboard": "^5.1.0", "react-popper": "^2.3.0", @@ -3304,8 +3304,8 @@ "tslib": "^2.6.2" }, "peerDependencies": { - "react": "^16.0.0 || ^17.0.0 || ^18.0.0", - "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0" + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "node_modules/@gravity-ui/yagr": { @@ -10214,6 +10214,12 @@ "node": ">=0.10.0" } }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "dev": true + }, "node_modules/clean-css": { "version": "5.3.2", "dev": true, @@ -19094,6 +19100,7 @@ }, "node_modules/js-tokens": { "version": "4.0.0", + "dev": true, "license": "MIT" }, "node_modules/js-yaml": { @@ -19919,6 +19926,7 @@ }, "node_modules/loose-envify": { "version": "1.4.0", + "dev": true, "license": "MIT", "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" @@ -20991,6 +20999,7 @@ }, "node_modules/object-assign": { "version": "4.1.1", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -22152,6 +22161,7 @@ }, "node_modules/prop-types": { "version": "15.8.1", + "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.4.0", @@ -22161,6 +22171,7 @@ }, "node_modules/prop-types/node_modules/react-is": { "version": "16.13.1", + "dev": true, "license": "MIT" }, "node_modules/proxy-addr": { @@ -22420,6 +22431,38 @@ "node": ">= 0.8" } }, + "node_modules/rc-slider": { + "version": "10.6.2", + "resolved": "https://registry.npmjs.org/rc-slider/-/rc-slider-10.6.2.tgz", + "integrity": "sha512-FjkoFjyvUQWcBo1F3RgSglky3ar0+qHLM41PlFVYB4Bj3RD8E/Mv7kqMouLFBU+3aFglMzzctAIWRwajEuueSw==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.5", + "rc-util": "^5.36.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-util": { + "version": "5.43.0", + "resolved": "https://registry.npmjs.org/rc-util/-/rc-util-5.43.0.tgz", + "integrity": "sha512-AzC7KKOXFqAdIBqdGWepL9Xn7cm3vnAmjlHqUnoQaTMZYhM4VlXGLkkHHxj/BZ7Td0+SOPKB4RGPboBVKT9htw==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.18.3", + "react-is": "^18.2.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, "node_modules/react": { "version": "17.0.2", "dev": true, @@ -22567,6 +22610,7 @@ }, "node_modules/react-lifecycles-compat": { "version": "3.0.4", + "dev": true, "license": "MIT" }, "node_modules/react-popper": { @@ -22642,26 +22686,6 @@ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" } }, - "node_modules/react-split-pane": { - "version": "0.1.92", - "license": "MIT", - "dependencies": { - "prop-types": "^15.7.2", - "react-lifecycles-compat": "^3.0.4", - "react-style-proptype": "^3.2.2" - }, - "peerDependencies": { - "react": "^16.0.0-0", - "react-dom": "^16.0.0-0" - } - }, - "node_modules/react-style-proptype": { - "version": "3.2.2", - "license": "MIT", - "dependencies": { - "prop-types": "^15.5.4" - } - }, "node_modules/react-transition-group": { "version": "4.4.5", "dev": true, diff --git a/package.json b/package.json index d8e358e7..4980bc69 100644 --- a/package.json +++ b/package.json @@ -47,13 +47,12 @@ ], "dependencies": { "@bem-react/classname": "^1.6.0", - "@gravity-ui/charts": "^0.2.0", + "@gravity-ui/charts": "^0.4.0", "@gravity-ui/date-utils": "^2.1.0", "@gravity-ui/i18n": "^1.0.0", "@gravity-ui/yagr": "^4.3.4", "afterframe": "^1.0.2", "lodash": "^4.17.21", - "react-split-pane": "^0.1.92", "tslib": "^2.6.2" }, "devDependencies": { @@ -64,7 +63,7 @@ "@gravity-ui/prettier-config": "^1.1.0", "@gravity-ui/stylelint-config": "^4.0.1", "@gravity-ui/tsconfig": "^1.0.0", - "@gravity-ui/uikit": "^6.0.0", + "@gravity-ui/uikit": "^6.35.2", "@jest/types": "^29.6.3", "@playwright/experimental-ct-react17": "^1.41.1", "@storybook/addon-actions": "^7.6.14", diff --git a/src/components/SplitPane/Pane.tsx b/src/components/SplitPane/Pane.tsx new file mode 100644 index 00000000..b297cf97 --- /dev/null +++ b/src/components/SplitPane/Pane.tsx @@ -0,0 +1,46 @@ +// Copyright 2015 tomkp +// Copyright 2022 YANDEX LLC + +import React from 'react'; + +import type {SplitLayoutType} from './types'; + +type Props = { + className?: string; + children?: React.ReactNode; + size?: number | string; + split?: SplitLayoutType; + style?: React.CSSProperties; + eleRef?: (node: HTMLDivElement) => void; +}; + +export class Pane extends React.PureComponent { + render() { + const {children, className, split, style: styleProps, size, eleRef} = this.props; + const classes = ['Pane', split, className]; + + let style: React.CSSProperties = { + flex: 1, + position: 'relative', + outline: 'none', + }; + + if (size !== undefined) { + if (split === 'vertical') { + style.width = size; + } else { + style.height = size; + style.display = 'flex'; + } + style.flex = 'none'; + } + + style = Object.assign({}, style, styleProps || {}); + + return ( +
+ {children} +
+ ); + } +} diff --git a/src/components/SplitPane/Resizer.tsx b/src/components/SplitPane/Resizer.tsx new file mode 100644 index 00000000..b51146bb --- /dev/null +++ b/src/components/SplitPane/Resizer.tsx @@ -0,0 +1,65 @@ +// Copyright 2015 tomkp +// Copyright 2022 YANDEX LLC + +import React from 'react'; + +import type {SplitLayoutType} from './types'; + +export const RESIZER_DEFAULT_CLASSNAME = 'Resizer'; + +type Props = { + onMouseDown: React.MouseEventHandler; + onTouchStart: React.TouchEventHandler; + onTouchEnd: React.TouchEventHandler; + className?: string; + split?: SplitLayoutType; + style?: React.CSSProperties; + resizerClassName?: string; + onClick?: React.MouseEventHandler; + onDoubleClick?: React.MouseEventHandler; +}; + +export class Resizer extends React.Component { + render() { + const { + className, + onClick, + onDoubleClick, + onMouseDown, + onTouchEnd, + onTouchStart, + resizerClassName = RESIZER_DEFAULT_CLASSNAME, + split, + style, + } = this.props; + const classes = [resizerClassName, split, className]; + + return ( + onMouseDown(event)} + onTouchStart={(event) => { + onTouchStart(event); + }} + onTouchEnd={(event) => { + event.preventDefault(); + onTouchEnd(event); + }} + onClick={(event) => { + if (onClick) { + event.preventDefault(); + onClick(event); + } + }} + onDoubleClick={(event) => { + if (onDoubleClick) { + event.preventDefault(); + onDoubleClick(event); + } + }} + /> + ); + } +} diff --git a/src/components/SplitPane/SplitPane.tsx b/src/components/SplitPane/SplitPane.tsx new file mode 100644 index 00000000..f4e72fdc --- /dev/null +++ b/src/components/SplitPane/SplitPane.tsx @@ -0,0 +1,416 @@ +// Copyright 2015 tomkp +// Copyright 2022 YANDEX LLC + +/* eslint-disable radix */ +/* eslint-disable complexity */ + +import React from 'react'; + +import {Pane} from './Pane'; +import {RESIZER_DEFAULT_CLASSNAME, Resizer} from './Resizer'; +import type {SplitLayoutType} from './types'; + +export type SplitPaneProps = { + allowResize?: boolean; + children?: React.ReactNode; + className?: string; + primary?: 'first' | 'second'; + minSize?: number; + maxSize?: number; + defaultSize?: number; + size?: number | string; + split?: SplitLayoutType; + onDragStarted?: () => void; + onDragFinished?: (size?: number | string) => void; + onChange?: (size: number) => void; + onResizerClick?: React.MouseEventHandler; + onResizerDoubleClick?: React.MouseEventHandler; + style?: React.CSSProperties; + resizerStyle?: React.CSSProperties; + paneClassName?: string; + pane1ClassName?: string; + pane2ClassName?: string; + paneStyle?: React.CSSProperties; + pane1Style?: React.CSSProperties; + pane2Style?: React.CSSProperties; + resizerClassName?: string; + step?: number; +}; + +type DefaultProps = Required< + Pick< + SplitPaneProps, + | 'allowResize' + | 'minSize' + | 'primary' + | 'split' + | 'paneClassName' + | 'pane1ClassName' + | 'pane2ClassName' + > +>; + +type Props = Omit< + SplitPaneProps, + | 'allowResize' + | 'minSize' + | 'primary' + | 'split' + | 'paneClassName' + | 'pane1ClassName' + | 'pane2ClassName' +> & + DefaultProps; + +type State = { + active: boolean; + position: number; + resized: boolean; + draggedSize?: number | string; + pane1Size?: number | string; + pane2Size?: number | string; + instanceProps: { + size?: number | string; + }; +}; + +function unFocus() { + window.getSelection()?.removeAllRanges(); +} + +function getDefaultSize( + defaultSize?: number, + minSize?: number, + maxSize?: number, + draggedSize?: number | string, +) { + if (typeof draggedSize === 'number') { + const min = typeof minSize === 'number' ? minSize : 0; + const max = typeof maxSize === 'number' && maxSize >= 0 ? maxSize : Infinity; + return Math.max(min, Math.min(max, draggedSize)); + } + if (defaultSize !== undefined) { + return defaultSize; + } + return minSize; +} + +function removeNullChildren(children: React.ReactNode) { + return React.Children.toArray(children).filter((c) => c); +} + +// TODO: https://github.com/gravity-ui/charts/issues/14 +export class SplitPane extends React.Component { + static defaultProps: DefaultProps = { + allowResize: true, + minSize: 50, + primary: 'first', + split: 'vertical', + paneClassName: '', + pane1ClassName: '', + pane2ClassName: '', + }; + + static getDerivedStateFromProps(nextProps: Props, prevState: State) { + return SplitPane.getSizeUpdate(nextProps, prevState); + } + + // we have to check values since gDSFP is called on every render and more in StrictMode + static getSizeUpdate(props: Props, state: State) { + const newState = {} as State; + const {instanceProps} = state; + + if (instanceProps.size === props.size && props.size !== undefined) { + return {}; + } + + const newSize = + props.size === undefined + ? getDefaultSize(props.defaultSize, props.minSize, props.maxSize, state.draggedSize) + : props.size; + + if (props.size !== undefined) { + newState.draggedSize = newSize; + } + + const isPanel1Primary = props.primary === 'first'; + + newState[isPanel1Primary ? 'pane1Size' : 'pane2Size'] = newSize; + newState[isPanel1Primary ? 'pane2Size' : 'pane1Size'] = undefined; + + newState.instanceProps = {size: props.size}; + + return newState; + } + + splitPane: HTMLDivElement | null = null; + private pane1: HTMLDivElement | null = null; + private pane2: HTMLDivElement | null = null; + + constructor(props: Props) { + super(props); + + // order of setting panel sizes. + // 1. size + // 2. getDefaultSize(defaultSize, minsize, maxSize) + + const {size, defaultSize, minSize, maxSize, primary} = props; + + const initialSize = + size === undefined ? getDefaultSize(defaultSize, minSize, maxSize) : size; + + this.state = { + active: false, + resized: false, + position: 0, + draggedSize: 0, + pane1Size: primary === 'first' ? initialSize : undefined, + pane2Size: primary === 'second' ? initialSize : undefined, + + // these are props that are needed in static functions. ie: gDSFP + instanceProps: { + size, + }, + }; + } + + componentDidMount() { + document.addEventListener('mouseup', this.onMouseUp); + document.addEventListener('mousemove', this.onMouseMove); + document.addEventListener('touchmove', this.onTouchMove); + this.setState(SplitPane.getSizeUpdate(this.props, this.state)); + } + + componentWillUnmount() { + document.removeEventListener('mouseup', this.onMouseUp); + document.removeEventListener('mousemove', this.onMouseMove); + document.removeEventListener('touchmove', this.onTouchMove); + } + + render() { + const { + allowResize, + children, + className, + onResizerClick, + onResizerDoubleClick, + paneClassName, + pane1ClassName, + pane2ClassName, + paneStyle, + pane1Style: pane1StyleProps, + pane2Style: pane2StyleProps, + resizerClassName, + resizerStyle, + split, + style: styleProps, + } = this.props; + + const {pane1Size, pane2Size} = this.state; + + const disabledClass = allowResize ? '' : 'disabled'; + const resizerClassNamesIncludingDefault = resizerClassName + ? `${resizerClassName} ${RESIZER_DEFAULT_CLASSNAME}` + : resizerClassName; + + const notNullChildren = removeNullChildren(children); + + const style: React.CSSProperties = { + display: 'flex', + flex: 1, + height: '100%', + position: 'absolute', + outline: 'none', + overflow: 'hidden', + MozUserSelect: 'text', + WebkitUserSelect: 'text', + msUserSelect: 'text', + userSelect: 'text', + ...styleProps, + }; + + if (split === 'vertical') { + Object.assign(style, { + flexDirection: 'row', + left: 0, + right: 0, + }); + } else { + Object.assign(style, { + bottom: 0, + flexDirection: 'column', + minHeight: '100%', + top: 0, + width: '100%', + }); + } + + const classes = ['SplitPane', className, split, disabledClass]; + + const pane1Style = {...paneStyle, ...pane1StyleProps}; + const pane2Style = {...paneStyle, ...pane2StyleProps}; + + const pane1Classes = ['Pane1', paneClassName, pane1ClassName].join(' '); + const pane2Classes = ['Pane2', paneClassName, pane2ClassName].join(' '); + + return ( +
{ + this.splitPane = node; + }} + style={style} + > + { + this.pane1 = node; + }} + size={pane1Size} + split={split} + style={pane1Style} + > + {notNullChildren[0]} + + + { + this.pane2 = node; + }} + size={pane2Size} + split={split} + style={pane2Style} + > + {notNullChildren[1]} + +
+ ); + } + + private onMouseDown = (event: React.MouseEvent) => { + const eventWithTouches = Object.assign({} as React.TouchEvent, event, { + touches: [{clientX: event.clientX, clientY: event.clientY}], + }); + this.onTouchStart(eventWithTouches); + }; + + private onTouchStart = (event: React.TouchEvent | TouchEvent) => { + const {allowResize, onDragStarted, split} = this.props; + if (allowResize) { + unFocus(); + const position = + split === 'vertical' ? event.touches[0].clientX : event.touches[0].clientY; + + if (typeof onDragStarted === 'function') { + onDragStarted(); + } + this.setState({ + active: true, + position, + }); + } + }; + + private onMouseMove = (event: React.MouseEvent | MouseEvent) => { + const eventWithTouches = Object.assign({} as React.TouchEvent, event, { + touches: [{clientX: event.clientX, clientY: event.clientY}], + }); + this.onTouchMove(eventWithTouches); + }; + + private onTouchMove = (event: React.TouchEvent | TouchEvent) => { + const {allowResize, maxSize, minSize, onChange, split, step} = this.props; + const {active, position} = this.state; + + if (allowResize && active) { + unFocus(); + const isPrimaryFirst = this.props.primary === 'first'; + const ref = isPrimaryFirst ? this.pane1 : this.pane2; + const ref2 = isPrimaryFirst ? this.pane2 : this.pane1; + if (ref) { + const node = ref; + const node2 = ref2; + + if (node.getBoundingClientRect) { + const width = node.getBoundingClientRect().width; + const height = node.getBoundingClientRect().height; + const current = + split === 'vertical' ? event.touches[0].clientX : event.touches[0].clientY; + const size = split === 'vertical' ? width : height; + let positionDelta = position - current; + if (step) { + if (Math.abs(positionDelta) < step) { + return; + } + // Integer division + // eslint-disable-next-line no-bitwise + positionDelta = ~~(positionDelta / step) * step; + } + let sizeDelta = isPrimaryFirst ? positionDelta : -positionDelta; + + const pane1Order = parseInt(window.getComputedStyle(node).order); + const pane2Order = parseInt(window.getComputedStyle(node2!).order); + if (pane1Order > pane2Order) { + sizeDelta = -sizeDelta; + } + + let newMaxSize = maxSize; + if (maxSize !== undefined && maxSize <= 0) { + const splitPane = this.splitPane; + if (split === 'vertical') { + newMaxSize = splitPane!.getBoundingClientRect().width + maxSize; + } else { + newMaxSize = splitPane!.getBoundingClientRect().height + maxSize; + } + } + + let newSize = size - sizeDelta; + const newPosition = position - positionDelta; + + if (newSize < minSize) { + newSize = minSize; + } else if (maxSize !== undefined && newSize > newMaxSize!) { + newSize = newMaxSize!; + } else { + this.setState({ + position: newPosition, + resized: true, + }); + } + + if (onChange) onChange(newSize); + + this.setState({ + draggedSize: newSize, + [isPrimaryFirst ? 'pane1Size' : 'pane2Size']: newSize, + } as unknown as State); + } + } + } + }; + + private onMouseUp = () => { + const {allowResize, onDragFinished} = this.props; + const {active, draggedSize} = this.state; + if (allowResize && active) { + if (typeof onDragFinished === 'function') { + onDragFinished(draggedSize); + } + this.setState({active: false}); + } + }; +} diff --git a/src/plugins/highcharts/renderer/components/StyledSplitPane/StyledSplitPane.scss b/src/components/SplitPane/StyledSplitPane.scss similarity index 95% rename from src/plugins/highcharts/renderer/components/StyledSplitPane/StyledSplitPane.scss rename to src/components/SplitPane/StyledSplitPane.scss index 20434158..9723d9c9 100644 --- a/src/plugins/highcharts/renderer/components/StyledSplitPane/StyledSplitPane.scss +++ b/src/components/SplitPane/StyledSplitPane.scss @@ -1,3 +1,6 @@ +// Copyright 2015 tomkp +// Copyright 2022 YANDEX LLC + .styled-split-pane { &__pane-resizer { background: var(--g-color-base-generic); @@ -46,7 +49,6 @@ } .Pane { - overflow: auto; height: 100%; } } diff --git a/src/plugins/highcharts/renderer/components/StyledSplitPane/StyledSplitPane.tsx b/src/components/SplitPane/StyledSplitPane.tsx similarity index 88% rename from src/plugins/highcharts/renderer/components/StyledSplitPane/StyledSplitPane.tsx rename to src/components/SplitPane/StyledSplitPane.tsx index 292250c0..abcf97af 100644 --- a/src/plugins/highcharts/renderer/components/StyledSplitPane/StyledSplitPane.tsx +++ b/src/components/SplitPane/StyledSplitPane.tsx @@ -1,8 +1,10 @@ import React from 'react'; -import SplitPane, {Pane, SplitPaneProps} from 'react-split-pane'; +import {cn} from '../../utils/cn'; -import {cn} from '../../../../../utils/cn'; +import {Pane} from './Pane'; +import {SplitPane} from './SplitPane'; +import type {SplitPaneProps} from './SplitPane'; import './StyledSplitPane.scss'; @@ -15,7 +17,6 @@ type Props = SplitPaneProps & { }; export const StyledSplitPane = ({paneOneRender, paneTwoRender, ...splitPaneProps}: Props) => { - // https://github.com/tomkp/react-split-pane/blob/master/src/SplitPane.js#L307 const splitPaneRef = React.useRef(null); React.useEffect(() => { diff --git a/src/components/SplitPane/constants.ts b/src/components/SplitPane/constants.ts new file mode 100644 index 00000000..2ec0e534 --- /dev/null +++ b/src/components/SplitPane/constants.ts @@ -0,0 +1,4 @@ +export const SplitLayout = { + HORIZONTAL: 'horizontal', + VERTICAL: 'vertical', +} as const; diff --git a/src/components/SplitPane/index.ts b/src/components/SplitPane/index.ts new file mode 100644 index 00000000..9d033cfa --- /dev/null +++ b/src/components/SplitPane/index.ts @@ -0,0 +1,25 @@ +import {ScreenOrientationType} from '../../constants'; + +import {SplitLayoutType} from './types'; + +export {SplitLayout} from './constants'; +export * from './Pane'; +export * from './SplitPane'; +export * from './StyledSplitPane'; +export * from './types'; + +export function mapScreenOrientationTypeToSplitLayout( + type: ScreenOrientationType, +): SplitLayoutType { + switch (type) { + case 'landscape-primary': + case 'landscape-secondary': { + return 'vertical'; + } + case 'portrait-primary': + case 'portrait-secondary': + default: { + return 'horizontal'; + } + } +} diff --git a/src/components/SplitPane/types.ts b/src/components/SplitPane/types.ts new file mode 100644 index 00000000..32449652 --- /dev/null +++ b/src/components/SplitPane/types.ts @@ -0,0 +1,3 @@ +import type {SplitLayout} from './constants'; + +export type SplitLayoutType = (typeof SplitLayout)[keyof typeof SplitLayout]; diff --git a/src/constants/index.ts b/src/constants/index.ts index 36445340..eba0e510 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -1,3 +1,4 @@ export {CHARTKIT_SCROLLABLE_NODE_CLASSNAME} from './common'; export * from './widget-data'; +export * from './misc'; diff --git a/src/constants/misc.ts b/src/constants/misc.ts new file mode 100644 index 00000000..321116b4 --- /dev/null +++ b/src/constants/misc.ts @@ -0,0 +1,28 @@ +function checkWindowAvailability() { + return typeof window === 'object'; +} + +export const IS_WINDOW_AVAILABLE = checkWindowAvailability(); + +function checkScreenOrientationAvailability() { + // W3C spec implementation + return ( + IS_WINDOW_AVAILABLE && + typeof window.ScreenOrientation === 'function' && + typeof screen.orientation.addEventListener === 'function' && + typeof screen.orientation.type === 'string' + ); +} + +export const IS_SCREEN_ORIENTATION_AVAILABLE = checkScreenOrientationAvailability(); + +export const ScreenOrientation = { + PORTRAIT_PRIMARY: 'portrait-primary', + PORTRAIT_SECONDARY: 'portrait-secondary', + LANDSCAPE_PRIMARY: 'landscape-primary', + LANDSCAPE_SECONDARY: 'landscape-secondary', +} as const; + +export type ScreenOrientationType = (typeof ScreenOrientation)[keyof typeof ScreenOrientation]; + +export const AVAILABLE_SCREEN_ORIENTATIONS = Object.values(ScreenOrientation); diff --git a/src/hooks/index.ts b/src/hooks/index.ts new file mode 100644 index 00000000..587b8928 --- /dev/null +++ b/src/hooks/index.ts @@ -0,0 +1 @@ +export * from './misc'; diff --git a/src/hooks/misc.ts b/src/hooks/misc.ts new file mode 100644 index 00000000..fcec1119 --- /dev/null +++ b/src/hooks/misc.ts @@ -0,0 +1,11 @@ +import React from 'react'; + +export function usePrevious(value: T) { + const ref = React.useRef(); + + React.useEffect(() => { + ref.current = value; + }, [value]); + + return ref.current; +} diff --git a/src/plugins/d3/renderer/D3Widget.tsx b/src/plugins/d3/renderer/D3Widget.tsx index 49de8ff7..cac5009f 100644 --- a/src/plugins/d3/renderer/D3Widget.tsx +++ b/src/plugins/d3/renderer/D3Widget.tsx @@ -8,14 +8,19 @@ import {settings} from '../../../libs'; import type {ChartKitProps, ChartKitWidgetRef} from '../../../types'; import {measurePerformance} from '../../../utils'; +import {withSplitPane} from './withSplitPane/withSplitPane'; + +const ChartWithSplitPane = withSplitPane(Chart); + const D3Widget = React.forwardRef>( function D3Widget(props, forwardedRef) { - const {data, onLoad, onRender, onChartLoad} = props; + const {data, tooltip, onLoad, onRender, onChartLoad} = props; const lang = settings.get('lang'); const performanceMeasure = React.useRef | null>( measurePerformance(), ); const chartRef = React.useRef(null); + const ChartComponent = tooltip?.splitted ? ChartWithSplitPane : Chart; const handleResize: NonNullable = React.useCallback( ({dimensions}) => { @@ -57,7 +62,7 @@ const D3Widget = React.forwardRef; + return ; }, ); diff --git a/src/plugins/d3/renderer/__stories__/SplitTooltip.stories.tsx b/src/plugins/d3/renderer/__stories__/SplitTooltip.stories.tsx new file mode 100644 index 00000000..5eac191c --- /dev/null +++ b/src/plugins/d3/renderer/__stories__/SplitTooltip.stories.tsx @@ -0,0 +1,53 @@ +import React from 'react'; + +import type {StoryObj} from '@storybook/react'; + +import {ChartKit} from '../../../../components/ChartKit'; + +import {StoryWrapper} from './StoryWrapper'; + +function getPieSegmentData(name: string, color: string, index: number) { + return { + name, + value: index * 10, + label: name, + color: color, + }; +} + +export const SplitTooltipBasic: StoryObj = { + name: 'Basic', + render: () => { + return ( + + + + ); + }, +}; + +export default { + title: 'Plugins/Gravity Chart/Split tooltip', +}; diff --git a/src/plugins/d3/renderer/__stories__/StoryWrapper.tsx b/src/plugins/d3/renderer/__stories__/StoryWrapper.tsx new file mode 100644 index 00000000..7d2e4f9c --- /dev/null +++ b/src/plugins/d3/renderer/__stories__/StoryWrapper.tsx @@ -0,0 +1,27 @@ +import React from 'react'; + +import {settings} from '../../../../libs'; +import {D3Plugin} from '../../index'; + +settings.set({plugins: [D3Plugin]}); + +type Props = { + children?: React.ReactNode; + style?: React.CSSProperties; +}; + +export const StoryWrapper = (props: Props) => { + const {children, style} = props; + + return ( +
+ {children} +
+ ); +}; diff --git a/src/plugins/d3/renderer/withSplitPane/TooltipContent.tsx b/src/plugins/d3/renderer/withSplitPane/TooltipContent.tsx new file mode 100644 index 00000000..790a3656 --- /dev/null +++ b/src/plugins/d3/renderer/withSplitPane/TooltipContent.tsx @@ -0,0 +1,31 @@ +import React from 'react'; + +import {ChartTooltipContent} from '@gravity-ui/charts'; +import type {ChartTooltipContentProps} from '@gravity-ui/charts'; + +export type TooltipContentRef = { + redraw: (updates?: Omit) => void; +}; + +type TooltipContentProps = Pick; + +export const TooltipContent = React.forwardRef( + function TooltipContent(props, forwardedRef) { + const {renderer} = props; + const [tooltipProps, setTooltipProps] = React.useState< + Omit | undefined + >(); + + React.useImperativeHandle( + forwardedRef, + () => ({ + redraw(updates?: ChartTooltipContentProps) { + setTooltipProps(updates); + }, + }), + [], + ); + + return ; + }, +); diff --git a/src/plugins/d3/renderer/withSplitPane/useWithSplitPaneState.ts b/src/plugins/d3/renderer/withSplitPane/useWithSplitPaneState.ts new file mode 100644 index 00000000..3c789b4a --- /dev/null +++ b/src/plugins/d3/renderer/withSplitPane/useWithSplitPaneState.ts @@ -0,0 +1,74 @@ +import React from 'react'; + +import {SplitLayout} from '../../../../components/SplitPane'; +import type {SplitLayoutType} from '../../../../components/SplitPane'; +import {IS_WINDOW_AVAILABLE} from '../../../../constants'; + +const CHART_SECTION_PERCENTAGE = 0.6; +export const RESIZER_HEIGHT = 24; + +type WithSplitPaneState = { + allowResize: boolean; + tooltipHeight: number; + setTooltipHeight: (value: number) => void; + split: SplitLayoutType; + setSplit: (value: SplitLayoutType) => void; + size: number | string; + setSize: (value: number | string) => void; + maxSize?: number; + minSize?: number; +}; + +function getInitialSplit(): SplitLayoutType { + if (!IS_WINDOW_AVAILABLE) { + return SplitLayout.HORIZONTAL; + } + + return window.innerWidth > window.innerHeight ? SplitLayout.VERTICAL : SplitLayout.HORIZONTAL; +} + +type UseWithSplitPaneProps = { + container: HTMLDivElement | null; +}; + +export function getVerticalSize() { + return window.innerWidth * CHART_SECTION_PERCENTAGE; +} + +function getInitialSize(split: SplitLayoutType) { + const defaultSize = `calc(100% - ${RESIZER_HEIGHT}px)`; + + if (!IS_WINDOW_AVAILABLE) { + return defaultSize; + } + + return split === SplitLayout.VERTICAL ? getVerticalSize() : defaultSize; +} + +export function useWithSplitPaneState(props: UseWithSplitPaneProps): WithSplitPaneState { + const {container} = props; + const [tooltipHeight, setTooltipHeight] = React.useState(0); + const [split, setSplit] = React.useState(getInitialSplit()); + const [size, setSize] = React.useState(getInitialSize(split)); + const allowResize = split === SplitLayout.HORIZONTAL; + let maxSize: number | undefined; + let minSize: number | undefined; + + if (IS_WINDOW_AVAILABLE && container && split === SplitLayout.HORIZONTAL) { + const containerHeight = container.getBoundingClientRect().height; + maxSize = containerHeight - RESIZER_HEIGHT - tooltipHeight; + minSize = containerHeight / 3; + } + + return { + allowResize, + maxSize, + minSize, + tooltipHeight, + setTooltipHeight, + split, + setSplit, + size, + setSize, + }; +} diff --git a/src/plugins/d3/renderer/withSplitPane/withSplitPane.tsx b/src/plugins/d3/renderer/withSplitPane/withSplitPane.tsx new file mode 100644 index 00000000..6b198c21 --- /dev/null +++ b/src/plugins/d3/renderer/withSplitPane/withSplitPane.tsx @@ -0,0 +1,185 @@ +import React from 'react'; + +import {Chart} from '@gravity-ui/charts'; +import type {ChartData, ChartProps, ChartRef} from '@gravity-ui/charts'; +import {getComponentName, useResizeObserver} from '@gravity-ui/uikit'; + +import { + SplitLayout, + StyledSplitPane, + mapScreenOrientationTypeToSplitLayout, +} from '../../../../components/SplitPane'; +import {IS_SCREEN_ORIENTATION_AVAILABLE, IS_WINDOW_AVAILABLE} from '../../../../constants'; +import {usePrevious} from '../../../../hooks'; +import {isScreenOrientationEventType} from '../../../../utils'; + +import {TooltipContent} from './TooltipContent'; +import type {TooltipContentRef} from './TooltipContent'; +import {RESIZER_HEIGHT, getVerticalSize, useWithSplitPaneState} from './useWithSplitPaneState'; + +type WithSplitPaneProps = {}; + +type PointerMoveHandler = NonNullable< + NonNullable['events']>['pointermove'] +>; + +export function withSplitPane(ChartComponent: typeof Chart) { + const componentName = getComponentName(ChartComponent); + const component = React.forwardRef( + function WithSplitPaneComponent(props, _ref) { + const {data, ...restProps} = props; + const containerRef = React.useRef(null); + const tooltipContainerRef = React.useRef(null); + const chartRef = React.useRef(null); + const tooltipRef = React.useRef(null); + const { + allowResize, + minSize, + maxSize, + tooltipHeight, + split, + size, + setTooltipHeight, + setSplit, + setSize, + } = useWithSplitPaneState({ + container: containerRef.current, + }); + const prevTooltipHeight = usePrevious(tooltipHeight); + const prevSplit = usePrevious(split); + + if (prevSplit && split !== prevSplit && split === SplitLayout.VERTICAL) { + setSize(getVerticalSize()); + } else if ( + split === SplitLayout.HORIZONTAL && + containerRef.current && + prevTooltipHeight === 0 && + tooltipHeight !== prevTooltipHeight + ) { + const containerHeight = containerRef.current.getBoundingClientRect().height; + + if (containerHeight - RESIZER_HEIGHT === size) { + setSize(containerHeight - RESIZER_HEIGHT - tooltipHeight); + chartRef.current?.reflow(); + } + } + + const handleTooltipContentResize = React.useCallback(() => { + if (!tooltipContainerRef.current) { + return; + } + + const nextTooltipHeight = + tooltipContainerRef.current.getBoundingClientRect().height; + setTooltipHeight(nextTooltipHeight); + }, [setTooltipHeight]); + + useResizeObserver({ + ref: tooltipContainerRef, + onResize: handleTooltipContentResize, + }); + + const resultData = React.useMemo(() => { + const userPointerMoveHandler = data.chart?.events?.pointermove; + const pointerMoveHandler: PointerMoveHandler = (pointerMoveData, event) => { + tooltipRef.current?.redraw(pointerMoveData); + userPointerMoveHandler?.(pointerMoveData, event); + }; + + return { + ...data, + chart: { + ...data.chart, + events: { + ...data.chart?.events, + pointermove: pointerMoveHandler, + }, + }, + tooltip: { + ...data.tooltip, + enabled: false, + }, + }; + }, [data]); + + const handleOrientationChange = React.useCallback(() => { + const deviceWidth = window.innerWidth; + const deviceHeight = window.innerHeight; + const nextSplit = + deviceWidth > deviceHeight ? SplitLayout.VERTICAL : SplitLayout.HORIZONTAL; + setSplit(nextSplit); + }, [setSplit]); + + const handleScreenOrientationChange = React.useCallback( + (e: Event) => { + const type = e.target && 'type' in e.target && e.target.type; + + if (!isScreenOrientationEventType(type)) { + return; + } + + setSplit(mapScreenOrientationTypeToSplitLayout(type)); + }, + [setSplit], + ); + + const handleSizeChange = React.useCallback( + (nextSize: number) => { + chartRef.current?.reflow(); + + if (split === SplitLayout.HORIZONTAL) { + setSize(nextSize); + } + }, + [split, setSize], + ); + + React.useLayoutEffect(() => { + if (IS_SCREEN_ORIENTATION_AVAILABLE) { + screen.orientation.addEventListener('change', handleScreenOrientationChange); + } else if (IS_WINDOW_AVAILABLE) { + window.addEventListener('orientationchange', handleOrientationChange); + } + + return () => { + if (IS_SCREEN_ORIENTATION_AVAILABLE) { + screen.orientation.removeEventListener( + 'change', + handleScreenOrientationChange, + ); + } else if (IS_WINDOW_AVAILABLE) { + window.removeEventListener('orientationchange', handleOrientationChange); + } + }; + }, [handleOrientationChange, handleScreenOrientationChange]); + + return ( +
+ ( + + )} + paneTwoRender={() => ( +
+ +
+ )} + /> +
+ ); + }, + ); + + component.displayName = `withSplitPane(${componentName})`; + + return component; +} diff --git a/src/plugins/highcharts/renderer/components/withSplitPane/withSplitPane.tsx b/src/plugins/highcharts/renderer/components/withSplitPane/withSplitPane.tsx index abb1da85..803faef4 100644 --- a/src/plugins/highcharts/renderer/components/withSplitPane/withSplitPane.tsx +++ b/src/plugins/highcharts/renderer/components/withSplitPane/withSplitPane.tsx @@ -5,11 +5,11 @@ import React from 'react'; import debounce from 'lodash/debounce'; import get from 'lodash/get'; +import {StyledSplitPane} from '../../../../../components/SplitPane'; import {getRandomCKId} from '../../../../../utils'; import {cn} from '../../../../../utils/cn'; import type {Highcharts} from '../../../types'; import {chartTypesWithoutCrosshair} from '../../helpers/config/config'; -import {StyledSplitPane} from '../StyledSplitPane/StyledSplitPane'; import './WithSplitPane.scss'; diff --git a/src/types/widget.ts b/src/types/widget.ts index 77f337c9..610b42a3 100644 --- a/src/types/widget.ts +++ b/src/types/widget.ts @@ -1,5 +1,4 @@ -import type {Split} from 'react-split-pane'; - +import type {SplitLayoutType} from '../components/SplitPane/types'; import type {Highcharts, HighchartsWidgetData, StringParams} from '../plugins/highcharts/types'; import type {IndicatorWidgetData} from '../plugins/indicator/types'; import type {CustomTooltipProps, Yagr, YagrWidgetData} from '../plugins/yagr/types'; @@ -23,8 +22,8 @@ export interface ChartKitWidget { hoistConfigError?: boolean; nonBodyScroll?: boolean; splitTooltip?: boolean; - paneSplitOrientation?: Split; - onSplitPaneOrientationChange?: (orientation: Split) => void; + paneSplitOrientation?: SplitLayoutType; + onSplitPaneOrientationChange?: (orientation: SplitLayoutType) => void; onChange?: ( data: {type: 'PARAMS_CHANGED'; data: {params: StringParams}}, state: {forceUpdate: boolean}, @@ -34,5 +33,8 @@ export interface ChartKitWidget { d3: { data: ChartKitWidgetData; widget: never; + tooltip?: { + splitted?: boolean; + }; }; } diff --git a/src/utils/index.ts b/src/utils/index.ts index 5eec3262..83d271a5 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,3 +1,4 @@ export {getRandomCKId, randomString} from './common'; export {typedMemo} from './react'; export * from './performance'; +export * from './misc'; diff --git a/src/utils/misc.ts b/src/utils/misc.ts new file mode 100644 index 00000000..33cd0ff2 --- /dev/null +++ b/src/utils/misc.ts @@ -0,0 +1,10 @@ +import {AVAILABLE_SCREEN_ORIENTATIONS} from '../constants'; +import type {ScreenOrientationType} from '../constants'; + +export function isScreenOrientationEventType(value: unknown): value is ScreenOrientationType { + if (typeof value !== 'string') { + return false; + } + + return AVAILABLE_SCREEN_ORIENTATIONS.includes(value as ScreenOrientationType); +}