You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

561 lines
16 KiB

import CheckOutlined from '@ant-design/icons/CheckOutlined';
import CopyOutlined from '@ant-design/icons/CopyOutlined';
import EditOutlined from '@ant-design/icons/EditOutlined';
import classNames from 'classnames';
import copy from 'copy-to-clipboard';
import ResizeObserver from 'rc-resize-observer';
import type { AutoSizeType } from 'rc-textarea/lib/ResizableTextArea';
import toArray from 'rc-util/lib/Children/toArray';
import useIsomorphicLayoutEffect from 'rc-util/lib/hooks/useLayoutEffect';
import useMergedState from 'rc-util/lib/hooks/useMergedState';
import omit from 'rc-util/lib/omit';
import { composeRef } from 'rc-util/lib/ref';
import * as React from 'react';
import { ConfigContext } from '../../config-provider';
import { useLocaleReceiver } from '../../locale-provider/LocaleReceiver';
import TransButton from '../../_util/transButton';
import { isStyleSupport } from '../../_util/styleChecker';
import type { TooltipProps } from '../../tooltip';
import Tooltip from '../../tooltip';
import Editable from '../Editable';
import useMergedConfig from '../hooks/useMergedConfig';
import useUpdatedEffect from '../hooks/useUpdatedEffect';
import type { TypographyProps } from '../Typography';
import Typography from '../Typography';
import Ellipsis from './Ellipsis';
import EllipsisTooltip from './EllipsisTooltip';
export type BaseType = 'secondary' | 'success' | 'warning' | 'danger';
interface CopyConfig {
text?: string;
onCopy?: (event?: React.MouseEvent<HTMLDivElement>) => void;
icon?: React.ReactNode;
tooltips?: boolean | React.ReactNode;
format?: 'text/plain' | 'text/html';
}
interface EditConfig {
text?: string;
editing?: boolean;
icon?: React.ReactNode;
tooltip?: boolean | React.ReactNode;
onStart?: () => void;
onChange?: (value: string) => void;
onCancel?: () => void;
onEnd?: () => void;
maxLength?: number;
autoSize?: boolean | AutoSizeType;
triggerType?: ('icon' | 'text')[];
enterIcon?: React.ReactNode;
}
export interface EllipsisConfig {
rows?: number;
expandable?: boolean;
suffix?: string;
symbol?: React.ReactNode;
onExpand?: React.MouseEventHandler<HTMLElement>;
onEllipsis?: (ellipsis: boolean) => void;
tooltip?: React.ReactNode | TooltipProps;
}
export interface BlockProps<C extends keyof JSX.IntrinsicElements = keyof JSX.IntrinsicElements>
extends TypographyProps<C> {
title?: string;
editable?: boolean | EditConfig;
copyable?: boolean | CopyConfig;
type?: BaseType;
disabled?: boolean;
ellipsis?: boolean | EllipsisConfig;
// decorations
code?: boolean;
mark?: boolean;
underline?: boolean;
delete?: boolean;
strong?: boolean;
keyboard?: boolean;
italic?: boolean;
}
function wrapperDecorations(
{ mark, code, underline, delete: del, strong, keyboard, italic }: BlockProps,
content: React.ReactNode,
) {
let currentContent = content;
function wrap(needed: boolean | undefined, tag: string) {
if (!needed) return;
currentContent = React.createElement(tag, {}, currentContent);
}
wrap(strong, 'strong');
wrap(underline, 'u');
wrap(del, 'del');
wrap(code, 'code');
wrap(mark, 'mark');
wrap(keyboard, 'kbd');
wrap(italic, 'i');
return currentContent;
}
function getNode(dom: React.ReactNode, defaultNode: React.ReactNode, needDom?: boolean) {
if (dom === true || dom === undefined) {
return defaultNode;
}
return dom || (needDom && defaultNode);
}
function toList<T extends any>(val: T | T[]): T[] {
if (val === false) {
return [false, false] as T[];
}
return Array.isArray(val) ? val : [val];
}
const ELLIPSIS_STR = '...';
const Base = React.forwardRef<HTMLElement, BlockProps>((props, ref) => {
const {
prefixCls: customizePrefixCls,
className,
style,
type,
disabled,
children,
ellipsis,
editable,
copyable,
component,
title,
...restProps
} = props;
const { getPrefixCls, direction } = React.useContext(ConfigContext);
const textLocale = useLocaleReceiver('Text')[0]!; // Force TS get this
const typographyRef = React.useRef<HTMLElement>(null);
const editIconRef = React.useRef<HTMLDivElement>(null);
// ============================ MISC ============================
const prefixCls = getPrefixCls('typography', customizePrefixCls);
const textProps = omit(restProps, [
'mark',
'code',
'delete',
'underline',
'strong',
'keyboard',
'italic',
]);
// ========================== Editable ==========================
const [enableEdit, editConfig] = useMergedConfig<EditConfig>(editable);
const [editing, setEditing] = useMergedState(false, {
value: editConfig.editing,
});
const { triggerType = ['icon'] } = editConfig;
const triggerEdit = (edit: boolean) => {
if (edit) {
editConfig.onStart?.();
}
setEditing(edit);
};
// Focus edit icon when back
useUpdatedEffect(() => {
if (!editing) {
editIconRef.current?.focus();
}
}, [editing]);
const onEditClick = (e?: React.MouseEvent<HTMLElement>) => {
e?.preventDefault();
triggerEdit(true);
};
const onEditChange = (value: string) => {
editConfig.onChange?.(value);
triggerEdit(false);
};
const onEditCancel = () => {
editConfig.onCancel?.();
triggerEdit(false);
};
// ========================== Copyable ==========================
const [enableCopy, copyConfig] = useMergedConfig<CopyConfig>(copyable);
const [copied, setCopied] = React.useState(false);
const copyIdRef = React.useRef<number>();
const copyOptions: Pick<CopyConfig, 'format'> = {};
if (copyConfig.format) {
copyOptions.format = copyConfig.format;
}
const cleanCopyId = () => {
window.clearTimeout(copyIdRef.current!);
};
const onCopyClick = (e?: React.MouseEvent<HTMLDivElement>) => {
e?.preventDefault();
e?.stopPropagation();
copy(copyConfig.text || String(children) || '', copyOptions);
setCopied(true);
// Trigger tips update
cleanCopyId();
copyIdRef.current = window.setTimeout(() => {
setCopied(false);
}, 3000);
copyConfig.onCopy?.(e);
};
React.useEffect(() => cleanCopyId, []);
// ========================== Ellipsis ==========================
const [isLineClampSupport, setIsLineClampSupport] = React.useState(false);
const [isTextOverflowSupport, setIsTextOverflowSupport] = React.useState(false);
const [expanded, setExpanded] = React.useState(false);
const [isJsEllipsis, setIsJsEllipsis] = React.useState(false);
const [isNativeEllipsis, setIsNativeEllipsis] = React.useState(false);
const [isNativeVisible, setIsNativeVisible] = React.useState(true);
const [enableEllipsis, ellipsisConfig] = useMergedConfig<EllipsisConfig>(ellipsis, {
expandable: false,
});
const mergedEnableEllipsis = enableEllipsis && !expanded;
// Shared prop to reduce bundle size
const { rows = 1 } = ellipsisConfig;
const needMeasureEllipsis = React.useMemo(
() =>
// Disable ellipsis
!mergedEnableEllipsis ||
// Provide suffix
ellipsisConfig.suffix !== undefined ||
ellipsisConfig.onEllipsis ||
// Can't use css ellipsis since we need to provide the place for button
ellipsisConfig.expandable ||
enableEdit ||
enableCopy,
[mergedEnableEllipsis, ellipsisConfig, enableEdit, enableCopy],
);
useIsomorphicLayoutEffect(() => {
if (enableEllipsis && !needMeasureEllipsis) {
setIsLineClampSupport(isStyleSupport('webkitLineClamp'));
setIsTextOverflowSupport(isStyleSupport('textOverflow'));
}
}, [needMeasureEllipsis, enableEllipsis]);
const cssEllipsis = React.useMemo(() => {
if (needMeasureEllipsis) {
return false;
}
if (rows === 1) {
return isTextOverflowSupport;
}
return isLineClampSupport;
}, [needMeasureEllipsis, isTextOverflowSupport, isLineClampSupport]);
const isMergedEllipsis = mergedEnableEllipsis && (cssEllipsis ? isNativeEllipsis : isJsEllipsis);
const cssTextOverflow = mergedEnableEllipsis && rows === 1 && cssEllipsis;
const cssLineClamp = mergedEnableEllipsis && rows > 1 && cssEllipsis;
// >>>>> Expand
const onExpandClick: React.MouseEventHandler<HTMLElement> = e => {
setExpanded(true);
ellipsisConfig.onExpand?.(e);
};
const [ellipsisWidth, setEllipsisWidth] = React.useState(0);
const [ellipsisFontSize, setEllipsisFontSize] = React.useState(0);
const onResize = ({ offsetWidth }: { offsetWidth: number }, element: HTMLElement) => {
setEllipsisWidth(offsetWidth);
setEllipsisFontSize(parseInt(window.getComputedStyle?.(element).fontSize, 10) || 0);
};
// >>>>> JS Ellipsis
const onJsEllipsis = (jsEllipsis: boolean) => {
setIsJsEllipsis(jsEllipsis);
// Trigger if changed
if (isJsEllipsis !== jsEllipsis) {
ellipsisConfig.onEllipsis?.(jsEllipsis);
}
};
// >>>>> Native ellipsis
React.useEffect(() => {
const textEle = typographyRef.current;
if (enableEllipsis && cssEllipsis && textEle) {
const currentEllipsis = cssLineClamp
? textEle.offsetHeight < textEle.scrollHeight
: textEle.offsetWidth < textEle.scrollWidth;
if (isNativeEllipsis !== currentEllipsis) {
setIsNativeEllipsis(currentEllipsis);
}
}
}, [enableEllipsis, cssEllipsis, children, cssLineClamp, isNativeVisible]);
// https://github.com/ant-design/ant-design/issues/36786
// Use IntersectionObserver to check if element is invisible
React.useEffect(() => {
const textEle = typographyRef.current;
if (
typeof IntersectionObserver === 'undefined' ||
!textEle ||
!cssEllipsis ||
!mergedEnableEllipsis
) {
return;
}
/* eslint-disable-next-line compat/compat */
const observer = new IntersectionObserver(() => {
setIsNativeVisible(!!textEle.offsetParent);
});
observer.observe(textEle!);
return () => {
observer.disconnect();
};
}, [cssEllipsis, mergedEnableEllipsis]);
// ========================== Tooltip ===========================
let tooltipProps: TooltipProps = {};
if (ellipsisConfig.tooltip === true) {
tooltipProps = { title: editConfig.text ?? children };
} else if (React.isValidElement(ellipsisConfig.tooltip)) {
tooltipProps = { title: ellipsisConfig.tooltip };
} else if (typeof ellipsisConfig.tooltip === 'object') {
tooltipProps = { title: editConfig.text ?? children, ...ellipsisConfig.tooltip };
} else {
tooltipProps = { title: ellipsisConfig.tooltip };
}
const topAriaLabel = React.useMemo(() => {
const isValid = (val: any): val is string | number => ['string', 'number'].includes(typeof val);
if (!enableEllipsis || cssEllipsis) {
return undefined;
}
if (isValid(editConfig.text)) {
return editConfig.text;
}
if (isValid(children)) {
return children;
}
if (isValid(title)) {
return title;
}
if (isValid(tooltipProps.title)) {
return tooltipProps.title;
}
return undefined;
}, [enableEllipsis, cssEllipsis, title, tooltipProps.title, isMergedEllipsis]);
// =========================== Render ===========================
// >>>>>>>>>>> Editing input
if (editing) {
return (
<Editable
value={editConfig.text ?? (typeof children === 'string' ? children : '')}
onSave={onEditChange}
onCancel={onEditCancel}
onEnd={editConfig.onEnd}
prefixCls={prefixCls}
className={className}
style={style}
direction={direction}
component={component}
maxLength={editConfig.maxLength}
autoSize={editConfig.autoSize}
enterIcon={editConfig.enterIcon}
/>
);
}
// >>>>>>>>>>> Typography
// Expand
const renderExpand = () => {
const { expandable, symbol } = ellipsisConfig;
if (!expandable) return null;
let expandContent: React.ReactNode;
if (symbol) {
expandContent = symbol;
} else {
expandContent = textLocale.expand;
}
return (
<a
key="expand"
className={`${prefixCls}-expand`}
onClick={onExpandClick}
aria-label={textLocale.expand}
>
{expandContent}
</a>
);
};
// Edit
const renderEdit = () => {
if (!enableEdit) return;
const { icon, tooltip } = editConfig;
const editTitle = toArray(tooltip)[0] || textLocale.edit;
const ariaLabel = typeof editTitle === 'string' ? editTitle : '';
return triggerType.includes('icon') ? (
<Tooltip key="edit" title={tooltip === false ? '' : editTitle}>
<TransButton
ref={editIconRef}
className={`${prefixCls}-edit`}
onClick={onEditClick}
aria-label={ariaLabel}
>
{icon || <EditOutlined role="button" />}
</TransButton>
</Tooltip>
) : null;
};
// Copy
const renderCopy = () => {
if (!enableCopy) return;
const { tooltips, icon } = copyConfig;
const tooltipNodes = toList(tooltips);
const iconNodes = toList(icon);
const copyTitle = copied
? getNode(tooltipNodes[1], textLocale.copied)
: getNode(tooltipNodes[0], textLocale.copy);
const systemStr = copied ? textLocale.copied : textLocale.copy;
const ariaLabel = typeof copyTitle === 'string' ? copyTitle : systemStr;
return (
<Tooltip key="copy" title={copyTitle}>
<TransButton
className={classNames(`${prefixCls}-copy`, copied && `${prefixCls}-copy-success`)}
onClick={onCopyClick}
aria-label={ariaLabel}
>
{copied
? getNode(iconNodes[1], <CheckOutlined />, true)
: getNode(iconNodes[0], <CopyOutlined />, true)}
</TransButton>
</Tooltip>
);
};
const renderOperations = (renderExpanded: boolean) => [
renderExpanded && renderExpand(),
renderEdit(),
renderCopy(),
];
const renderEllipsis = (needEllipsis: boolean) => [
needEllipsis && (
<span aria-hidden key="ellipsis">
{ELLIPSIS_STR}
</span>
),
ellipsisConfig.suffix,
renderOperations(needEllipsis),
];
return (
<ResizeObserver onResize={onResize} disabled={!mergedEnableEllipsis || cssEllipsis}>
{(resizeRef: React.RefObject<HTMLElement>) => (
<EllipsisTooltip
tooltipProps={tooltipProps}
enabledEllipsis={mergedEnableEllipsis}
isEllipsis={isMergedEllipsis}
>
<Typography
className={classNames(
{
[`${prefixCls}-${type}`]: type,
[`${prefixCls}-disabled`]: disabled,
[`${prefixCls}-ellipsis`]: enableEllipsis,
[`${prefixCls}-single-line`]: mergedEnableEllipsis && rows === 1,
[`${prefixCls}-ellipsis-single-line`]: cssTextOverflow,
[`${prefixCls}-ellipsis-multiple-line`]: cssLineClamp,
},
className,
)}
style={{
...style,
WebkitLineClamp: cssLineClamp ? rows : undefined,
}}
component={component}
ref={composeRef(resizeRef, typographyRef, ref)}
direction={direction}
onClick={triggerType.includes('text') ? onEditClick : undefined}
aria-label={topAriaLabel?.toString()}
title={title}
{...textProps}
>
<Ellipsis
enabledMeasure={mergedEnableEllipsis && !cssEllipsis}
text={children}
rows={rows}
width={ellipsisWidth}
fontSize={ellipsisFontSize}
onEllipsis={onJsEllipsis}
>
{(node, needEllipsis) => {
let renderNode: React.ReactNode = node;
if (node.length && needEllipsis && topAriaLabel) {
renderNode = (
<span key="show-content" aria-hidden>
{renderNode}
</span>
);
}
const wrappedContext = wrapperDecorations(
props,
<>
{renderNode}
{renderEllipsis(needEllipsis)}
</>,
);
return wrappedContext;
}}
</Ellipsis>
</Typography>
</EllipsisTooltip>
)}
</ResizeObserver>
);
});
export default Base;