feat: support pivots menu (#1755)

This commit is contained in:
Qi
2023-03-30 17:37:41 +08:00
committed by GitHub
parent 4dd1490eef
commit b6ded30770
40 changed files with 1513 additions and 665 deletions

View File

@@ -1,12 +1,12 @@
import type {
CSSProperties,
FocusEventHandler,
ForwardedRef,
HTMLAttributes,
InputHTMLAttributes,
KeyboardEventHandler,
} from 'react';
import { forwardRef } from 'react';
import { useEffect, useState } from 'react';
import { forwardRef, useEffect, useState } from 'react';
import { StyledInput } from './style';
@@ -14,13 +14,14 @@ type inputProps = {
value?: string;
placeholder?: string;
disabled?: boolean;
width?: number;
height?: number;
width?: CSSProperties['width'];
height?: CSSProperties['height'];
maxLength?: number;
minLength?: number;
onChange?: (value: string) => void;
onBlur?: FocusEventHandler<HTMLInputElement>;
onKeyDown?: KeyboardEventHandler<HTMLInputElement>;
noBorder?: boolean;
} & Omit<HTMLAttributes<HTMLInputElement>, 'onChange'>;
export const Input = forwardRef<HTMLInputElement, inputProps>(function Input(
@@ -31,10 +32,11 @@ export const Input = forwardRef<HTMLInputElement, inputProps>(function Input(
maxLength,
minLength,
height,
width = 260,
width,
onChange,
onBlur,
onKeyDown,
noBorder = false,
...otherProps
}: inputProps,
ref: ForwardedRef<HTMLInputElement>
@@ -69,7 +71,8 @@ export const Input = forwardRef<HTMLInputElement, inputProps>(function Input(
onBlur={handleBlur}
onKeyDown={handleKeyDown}
height={height}
noBorder={noBorder}
{...otherProps}
></StyledInput>
/>
);
});

View File

@@ -1,28 +1,25 @@
import type { CSSProperties } from 'react';
import { styled } from '../../styles';
export const StyledInput = styled('input')<{
disabled?: boolean;
value?: string;
width: number;
height?: number;
}>(({ theme, width, disabled, height }) => {
const fontWeight = 400;
const fontSize = '16px';
width?: CSSProperties['width'];
height?: CSSProperties['height'];
noBorder?: boolean;
}>(({ theme, width, disabled, height, noBorder }) => {
return {
width: `${width}px`,
width: width || '100%',
height,
lineHeight: '22px',
padding: '8px 12px',
fontWeight,
fontSize,
height: height ? `${height}px` : 'auto',
color: disabled ? theme.colors.disableColor : theme.colors.textColor,
border: `1px solid`,
border: noBorder ? 'unset' : `1px solid`,
borderColor: theme.colors.borderColor, // TODO: check out disableColor,
backgroundColor: theme.colors.popoverBackground,
borderRadius: '10px',
'&::placeholder': {
fontWeight,
fontSize,
color: theme.colors.placeHolderColor,
},
'&:focus': {

View File

@@ -1,31 +1,28 @@
import type { HTMLAttributes, PropsWithChildren, ReactElement } from 'react';
import { cloneElement, forwardRef } from 'react';
import { forwardRef } from 'react';
import {
StyledContent,
StyledEndIconWrapper,
StyledMenuItem,
StyledStartIconWrapper,
} from './styles';
import { StyledArrow, StyledMenuItem } from './styles';
export type IconMenuProps = PropsWithChildren<{
isDir?: boolean;
icon?: ReactElement;
endIcon?: ReactElement;
iconSize?: [number, number];
disabled?: boolean;
}> &
HTMLAttributes<HTMLButtonElement>;
export const MenuItem = forwardRef<HTMLButtonElement, IconMenuProps>(
({ isDir = false, icon, iconSize, children, ...props }, ref) => {
const [iconWidth, iconHeight] = iconSize || [20, 20];
({ endIcon, icon, iconSize, children, ...props }, ref) => {
return (
<StyledMenuItem ref={ref} {...props}>
{icon &&
cloneElement(icon, {
width: iconWidth,
height: iconHeight,
style: {
marginRight: 12,
...icon.props?.style,
},
})}
{children}
{isDir ? <StyledArrow /> : null}
{icon && <StyledStartIconWrapper>{icon}</StyledStartIconWrapper>}
<StyledContent>{children}</StyledContent>
{endIcon && <StyledEndIconWrapper>{endIcon}</StyledEndIconWrapper>}
</StyledMenuItem>
);
}

View File

@@ -4,12 +4,17 @@ import type { PurePopperProps } from '../popper';
import { PurePopper } from '../popper';
import { StyledMenuWrapper } from './styles';
export type PureMenuProps = PurePopperProps & {
width?: CSSProperties['width'];
height?: CSSProperties['height'];
};
export const PureMenu = ({
children,
placement,
width,
height,
...otherProps
}: PurePopperProps & { width?: CSSProperties['width'] }) => {
}: PureMenuProps) => {
return (
<PurePopper placement={placement} {...otherProps}>
<StyledMenuWrapper width={width} placement={placement}>

View File

@@ -1,14 +1,15 @@
import { ArrowRightSmallIcon } from '@blocksuite/icons';
import type { CSSProperties } from 'react';
import { displayFlex, styled } from '../../styles';
import { displayFlex, styled, textEllipsis } from '../../styles';
import StyledPopperContainer from '../shared/Container';
export const StyledMenuWrapper = styled(StyledPopperContainer)<{
width?: CSSProperties['width'];
}>(({ theme, width }) => {
height?: CSSProperties['height'];
}>(({ theme, width, height }) => {
return {
width,
height,
background: theme.colors.popoverBackground,
padding: '8px 4px',
fontSize: '14px',
@@ -17,13 +18,28 @@ export const StyledMenuWrapper = styled(StyledPopperContainer)<{
};
});
export const StyledArrow = styled(ArrowRightSmallIcon)({
position: 'absolute',
right: '12px',
top: 0,
bottom: 0,
margin: 'auto',
fontSize: '20px',
export const StyledStartIconWrapper = styled('div')(({ theme }) => {
return {
marginRight: '12px',
fontSize: '20px',
color: theme.colors.iconColor,
};
});
export const StyledEndIconWrapper = styled('div')(({ theme }) => {
return {
marginLeft: '12px',
fontSize: '20px',
color: theme.colors.iconColor,
};
});
export const StyledContent = styled('div')(({ theme }) => {
return {
textAlign: 'left',
flexGrow: 1,
fontSize: theme.font.base,
...textEllipsis(1),
};
});
export const StyledMenuItem = styled('button')<{

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect } from 'react';
import { useDrag, useDrop } from 'react-dnd';
import {
@@ -14,21 +14,21 @@ import type {
TreeNodeProps,
} from './types';
const NodeLine = <N,>({
const NodeLine = <RenderProps,>({
node,
onDrop,
allowDrop = true,
isTop = false,
}: NodeLIneProps<N>) => {
}: NodeLIneProps<RenderProps>) => {
const [{ isOver }, drop] = useDrop(
() => ({
accept: 'node',
drop: (item: Node<N>, monitor) => {
drop: (item: Node<RenderProps>, monitor) => {
const didDrop = monitor.didDrop();
if (didDrop) {
return;
}
onDrop?.(item, node, {
onDrop?.(item.id, node.id, {
internal: false,
topLine: isTop,
bottomLine: !isTop,
@@ -44,24 +44,23 @@ const NodeLine = <N,>({
return <StyledNodeLine ref={drop} show={isOver && allowDrop} isTop={isTop} />;
};
const TreeNodeItem = <N,>({
const TreeNodeItemWithDnd = <RenderProps,>({
node,
allowDrop,
collapsed,
setCollapsed,
...otherProps
}: TreeNodeItemProps<N>) => {
}: TreeNodeItemProps<RenderProps>) => {
const { onAdd, onDelete, onDrop } = otherProps;
const [{ canDrop, isOver }, drop] = useDrop(
() => ({
accept: 'node',
drop: (item: Node<N>, monitor) => {
drop: (item: Node<RenderProps>, monitor) => {
const didDrop = monitor.didDrop();
if (didDrop || item.id === node.id || !allowDrop) {
return;
}
onDrop?.(item, node, {
onDrop?.(item.id, node.id, {
internal: true,
topLine: false,
bottomLine: false,
@@ -77,44 +76,79 @@ const TreeNodeItem = <N,>({
useEffect(() => {
if (isOver && canDrop) {
setCollapsed(false);
setCollapsed(node.id, false);
}
}, [isOver, canDrop]);
return (
<div ref={drop}>
<TreeNodeItem
dropRef={drop}
onAdd={onAdd}
onDelete={onDelete}
node={node}
allowDrop={allowDrop}
setCollapsed={setCollapsed}
isOver={isOver}
canDrop={canDrop}
{...otherProps}
/>
);
};
const TreeNodeItem = <RenderProps,>({
node,
collapsed,
setCollapsed,
selectedId,
isOver = false,
canDrop = false,
onAdd,
onDelete,
dropRef,
}: TreeNodeItemProps<RenderProps>) => {
return (
<div ref={dropRef}>
{node.render?.(node, {
isOver: !!(isOver && canDrop),
isOver: isOver && canDrop,
onAdd: () => onAdd?.(node),
onDelete: () => onDelete?.(node),
collapsed,
setCollapsed,
isSelected: selectedId === node.id,
})}
</div>
);
};
export const TreeNode = <N,>({
node,
index,
allowDrop = true,
...otherProps
}: TreeNodeProps<N>) => {
const { indent } = otherProps;
const [collapsed, setCollapsed] = useState(false);
export const TreeNodeWithDnd = <RenderProps,>(
props: TreeNodeProps<RenderProps>
) => {
const [{ isDragging }, drag] = useDrag(() => ({
type: 'node',
item: node,
item: props.node,
collect: monitor => ({
isDragging: monitor.isDragging(),
}),
}));
return <TreeNode dragRef={drag} isDragging={isDragging} {...props} />;
};
export const TreeNode = <RenderProps,>({
node,
index,
isDragging = false,
allowDrop = true,
dragRef,
...otherProps
}: TreeNodeProps<RenderProps>) => {
const { indent, enableDnd, collapsedIds } = otherProps;
const collapsed = collapsedIds.includes(node.id);
return (
<StyledTreeNodeContainer ref={drag} isDragging={isDragging}>
<StyledTreeNodeContainer ref={dragRef} isDragging={isDragging}>
<StyledTreeNodeWrapper>
{index === 0 && (
{enableDnd && index === 0 && (
<NodeLine
node={node}
{...otherProps}
@@ -122,15 +156,25 @@ export const TreeNode = <N,>({
isTop={true}
/>
)}
<TreeNodeItem
node={node}
index={index}
allowDrop={allowDrop}
collapsed={collapsed}
setCollapsed={setCollapsed}
{...otherProps}
/>
{(!node.children?.length || collapsed) && (
{enableDnd ? (
<TreeNodeItemWithDnd
node={node}
index={index}
allowDrop={allowDrop}
collapsed={collapsed}
{...otherProps}
/>
) : (
<TreeNodeItem
node={node}
index={index}
allowDrop={allowDrop}
collapsed={collapsed}
{...otherProps}
/>
)}
{enableDnd && (!node.children?.length || collapsed) && (
<NodeLine
node={node}
{...otherProps}
@@ -140,18 +184,26 @@ export const TreeNode = <N,>({
</StyledTreeNodeWrapper>
<StyledCollapse in={!collapsed} indent={indent}>
{node.children &&
node.children.map((childNode, index) => (
<TreeNode
key={childNode.id}
node={childNode}
index={index}
allowDrop={isDragging ? false : allowDrop}
{...otherProps}
/>
))}
node.children.map((childNode, index) =>
enableDnd ? (
<TreeNodeWithDnd
key={childNode.id}
node={childNode}
index={index}
allowDrop={isDragging ? false : allowDrop}
{...otherProps}
/>
) : (
<TreeNode
key={childNode.id}
node={childNode}
index={index}
allowDrop={false}
{...otherProps}
/>
)
)}
</StyledCollapse>
</StyledTreeNodeContainer>
);
};
export default TreeNode;

View File

@@ -1,15 +1,108 @@
import { useEffect, useState } from 'react';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import { TreeNode } from './TreeNode';
import type { TreeViewProps } from './types';
export const TreeView = <N,>({ data, ...otherProps }: TreeViewProps<N>) => {
import { TreeNode, TreeNodeWithDnd } from './TreeNode';
import type { TreeNodeProps, TreeViewProps } from './types';
import { flattenIds } from './utils';
export const TreeView = <RenderProps,>({
data,
enableKeyboardSelection,
onSelect,
enableDnd = true,
initialCollapsedIds = [],
...otherProps
}: TreeViewProps<RenderProps>) => {
const [selectedId, setSelectedId] = useState<string>();
// TODO: should record collapsedIds in localStorage
const [collapsedIds, setCollapsedIds] =
useState<string[]>(initialCollapsedIds);
useEffect(() => {
if (!enableKeyboardSelection) {
return;
}
const flattenedIds = flattenIds<RenderProps>(data);
const handleDirectionKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') {
return;
}
if (selectedId === undefined) {
setSelectedId(flattenedIds[0]);
return;
}
let selectedIndex = flattenedIds.indexOf(selectedId);
if (e.key === 'ArrowDown') {
selectedIndex < flattenedIds.length - 1 && selectedIndex++;
}
if (e.key === 'ArrowUp') {
selectedIndex > 0 && selectedIndex--;
}
setSelectedId(flattenedIds[selectedIndex]);
};
const handleEnterKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Enter') {
return;
}
selectedId && onSelect?.(selectedId);
};
document.addEventListener('keydown', handleDirectionKeyDown);
document.addEventListener('keydown', handleEnterKeyDown);
return () => {
document.removeEventListener('keydown', handleDirectionKeyDown);
document.removeEventListener('keydown', handleEnterKeyDown);
};
}, [data, selectedId]);
const setCollapsed: TreeNodeProps['setCollapsed'] = (id, collapsed) => {
if (collapsed) {
setCollapsedIds(ids => [...ids, id]);
} else {
setCollapsedIds(ids => ids.filter(i => i !== id));
}
};
if (enableDnd) {
return (
<DndProvider backend={HTML5Backend}>
{data.map((node, index) => (
<TreeNodeWithDnd
key={node.id}
index={index}
collapsedIds={collapsedIds}
setCollapsed={setCollapsed}
node={node}
selectedId={selectedId}
enableDnd={enableDnd}
{...otherProps}
/>
))}
</DndProvider>
);
}
return (
<DndProvider backend={HTML5Backend}>
<>
{data.map((node, index) => (
<TreeNode key={node.id} index={index} node={node} {...otherProps} />
<TreeNode
key={node.id}
index={index}
collapsedIds={collapsedIds}
setCollapsed={setCollapsed}
node={node}
selectedId={selectedId}
enableDnd={enableDnd}
{...otherProps}
/>
))}
</DndProvider>
</>
);
};

View File

@@ -15,8 +15,8 @@ export const StyledTreeNodeWrapper = styled('div')(() => {
position: 'relative',
};
});
export const StyledTreeNodeContainer = styled('div')<{ isDragging: boolean }>(
({ isDragging, theme }) => {
export const StyledTreeNodeContainer = styled('div')<{ isDragging?: boolean }>(
({ isDragging = false, theme }) => {
return {
background: isDragging ? theme.colors.hoverBackground : '',
// opacity: isDragging ? 0.4 : 1,

View File

@@ -1,52 +1,71 @@
import type { CSSProperties, ReactNode } from 'react';
import type { CSSProperties, ReactNode, Ref } from 'react';
export type Node<N> = {
export type DropPosition = {
topLine: boolean;
bottomLine: boolean;
internal: boolean;
};
export type OnDrop = (
dragId: string,
dropId: string,
position: DropPosition
) => void;
export type Node<RenderProps = unknown> = {
id: string;
children?: Node<N>[];
render?: (
node: Node<N>,
children?: Node<RenderProps>[];
render: (
node: Node<RenderProps>,
eventsAndStatus: {
isOver: boolean;
onAdd: () => void;
onDelete: () => void;
collapsed: boolean;
setCollapsed: (collapsed: boolean) => void;
setCollapsed: (id: string, collapsed: boolean) => void;
isSelected: boolean;
},
extendProps?: unknown
renderProps?: RenderProps
) => ReactNode;
} & N;
type CommonProps<N> = {
indent?: CSSProperties['paddingLeft'];
onAdd?: (node: Node<N>) => void;
onDelete?: (node: Node<N>) => void;
onDrop?: (
dragNode: Node<N>,
dropNode: Node<N>,
position: {
topLine: boolean;
bottomLine: boolean;
internal: boolean;
}
) => void;
};
export type TreeNodeProps<N> = {
node: Node<N>;
type CommonProps<RenderProps = unknown> = {
enableDnd?: boolean;
enableKeyboardSelection?: boolean;
indent?: CSSProperties['paddingLeft'];
onAdd?: (node: Node<RenderProps>) => void;
onDelete?: (node: Node<RenderProps>) => void;
onDrop?: OnDrop;
// Only trigger when the enableKeyboardSelection is true
onSelect?: (id: string) => void;
};
export type TreeNodeProps<RenderProps = unknown> = {
node: Node<RenderProps>;
index: number;
collapsedIds: string[];
setCollapsed: (id: string, collapsed: boolean) => void;
allowDrop?: boolean;
} & CommonProps<N>;
selectedId?: string;
isDragging?: boolean;
dragRef?: Ref<HTMLDivElement>;
} & CommonProps<RenderProps>;
export type TreeNodeItemProps<N> = {
export type TreeNodeItemProps<RenderProps = unknown> = {
collapsed: boolean;
setCollapsed: (collapsed: boolean) => void;
} & TreeNodeProps<N>;
setCollapsed: (id: string, collapsed: boolean) => void;
export type TreeViewProps<N> = {
data: Node<N>[];
} & CommonProps<N>;
isOver?: boolean;
canDrop?: boolean;
export type NodeLIneProps<N> = {
dropRef?: Ref<HTMLDivElement>;
} & TreeNodeProps<RenderProps>;
export type TreeViewProps<RenderProps = unknown> = {
data: Node<RenderProps>[];
initialCollapsedIds?: string[];
} & CommonProps<RenderProps>;
export type NodeLIneProps<RenderProps = unknown> = {
allowDrop: boolean;
isTop?: boolean;
} & Pick<TreeNodeProps<N>, 'node' | 'onDrop'>;
} & Pick<TreeNodeProps<RenderProps>, 'node' | 'onDrop'>;

View File

@@ -0,0 +1,18 @@
import type { Node } from '@affine/component';
export function flattenIds<RenderProps>(arr: Node<RenderProps>[]): string[] {
const result: string[] = [];
function flatten(arr: Node<RenderProps>[]) {
for (let i = 0, len = arr.length; i < len; i++) {
const item = arr[i];
result.push(item.id);
if (Array.isArray(item.children)) {
flatten(item.children);
}
}
}
flatten(arr);
return result;
}