ant-design/components/typography/Base/index.tsx
二货爱吃白萝卜 7e6678aa51
enhance: ssr ellipsis effect (#48205)
* enhance: ssr ellipsis effect

* test: update snapshot

* chore: fix lint
2024-04-02 14:50:46 +08:00

521 lines
15 KiB
TypeScript

import * as React from 'react';
import EditOutlined from '@ant-design/icons/EditOutlined';
import classNames from 'classnames';
import ResizeObserver from 'rc-resize-observer';
import type { AutoSizeType } from 'rc-textarea';
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 { isStyleSupport } from '../../_util/styleChecker';
import TransButton from '../../_util/transButton';
import { ConfigContext } from '../../config-provider';
import useLocale from '../../locale/useLocale';
import type { TooltipProps } from '../../tooltip';
import Tooltip from '../../tooltip';
import Editable from '../Editable';
import useCopyClick from '../hooks/useCopyClick';
import useMergedConfig from '../hooks/useMergedConfig';
import useUpdatedEffect from '../hooks/useUpdatedEffect';
import type { TypographyProps } from '../Typography';
import Typography from '../Typography';
import CopyBtn from './CopyBtn';
import Ellipsis from './Ellipsis';
import EllipsisTooltip from './EllipsisTooltip';
export type BaseType = 'secondary' | 'success' | 'warning' | 'danger';
export interface CopyConfig {
text?: string | (() => string | Promise<string>);
onCopy?: (event?: React.MouseEvent<HTMLDivElement>) => void;
icon?: React.ReactNode;
tooltips?: React.ReactNode;
format?: 'text/plain' | 'text/html';
}
interface EditConfig {
text?: string;
editing?: boolean;
icon?: React.ReactNode;
tooltip?: 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 | 'collapsible';
suffix?: string;
symbol?: React.ReactNode | ((expanded: boolean) => React.ReactNode);
defaultExpanded?: boolean;
expanded?: boolean;
onExpand?: (e: React.MouseEvent<HTMLElement, MouseEvent>, info: { expanded: boolean }) => void;
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(tag: string, needed?: boolean) {
if (!needed) {
return;
}
currentContent = React.createElement(tag, {}, currentContent);
}
wrap('strong', strong);
wrap('u', underline);
wrap('del', del);
wrap('code', code);
wrap('mark', mark);
wrap('kbd', keyboard);
wrap('i', italic);
return currentContent;
}
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] = useLocale('Text');
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, copyLoading, onClick: onCopyClick } = useCopyClick({ copyConfig, children });
// ========================== Ellipsis ==========================
const [isLineClampSupport, setIsLineClampSupport] = React.useState(false);
const [isTextOverflowSupport, setIsTextOverflowSupport] = 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,
symbol: (isExpanded) => (isExpanded ? textLocale?.collapse : textLocale?.expand),
});
const [expanded, setExpanded] = useMergedState(ellipsisConfig.defaultExpanded || false, {
value: ellipsisConfig.expanded,
});
const mergedEnableEllipsis =
enableEllipsis && (!expanded || ellipsisConfig.expandable === 'collapsible');
// 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, setCssEllipsis] = React.useState(mergedEnableEllipsis);
const canUseCssEllipsis = React.useMemo(() => {
if (needMeasureEllipsis) {
return false;
}
if (rows === 1) {
return isTextOverflowSupport;
}
return isLineClampSupport;
}, [needMeasureEllipsis, isTextOverflowSupport, isLineClampSupport]);
// We use effect to change from css ellipsis to js ellipsis.
// To make SSR still can see the ellipsis.
useIsomorphicLayoutEffect(() => {
setCssEllipsis(canUseCssEllipsis && mergedEnableEllipsis);
}, [canUseCssEllipsis, mergedEnableEllipsis]);
const isMergedEllipsis = mergedEnableEllipsis && (cssEllipsis ? isNativeEllipsis : isJsEllipsis);
const cssTextOverflow = mergedEnableEllipsis && rows === 1 && cssEllipsis;
const cssLineClamp = mergedEnableEllipsis && rows > 1 && cssEllipsis;
// >>>>> Expand
const onExpandClick: EllipsisConfig['onExpand'] = (e, info) => {
setExpanded(info.expanded);
ellipsisConfig.onExpand?.(e, info);
};
const [ellipsisWidth, setEllipsisWidth] = React.useState(0);
const onResize = ({ offsetWidth }: { offsetWidth: number }) => {
setEllipsisWidth(offsetWidth);
};
// >>>>> 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, ellipsisWidth]);
// 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;
if (expanded && expandable !== 'collapsible') return null;
return (
<a
key="expand"
className={`${prefixCls}-${expanded ? 'collapse' : 'expand'}`}
onClick={(e) => onExpandClick(e, { expanded: !expanded })}
aria-label={expanded ? textLocale.collapse : textLocale?.expand}
>
{typeof symbol === 'function' ? symbol(expanded) : symbol}
</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 null;
}
return (
<CopyBtn
key="copy"
{...copyConfig}
prefixCls={prefixCls}
copied={copied}
locale={textLocale}
onCopy={onCopyClick}
loading={copyLoading}
iconOnly={children === null || children === undefined}
/>
);
};
const renderOperations = (canEllipsis: boolean) => [
// (renderExpanded || ellipsisConfig.collapsible) && renderExpand(),
canEllipsis && renderExpand(),
renderEdit(),
renderCopy(),
];
const renderEllipsis = (canEllipsis: boolean) => [
canEllipsis && !expanded && (
<span aria-hidden key="ellipsis">
{ELLIPSIS_STR}
</span>
),
ellipsisConfig.suffix,
renderOperations(canEllipsis),
];
return (
<ResizeObserver onResize={onResize} disabled={!mergedEnableEllipsis}>
{(resizeRef: React.RefObject<HTMLElement>) => (
<EllipsisTooltip
tooltipProps={tooltipProps}
enableEllipsis={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,
)}
prefixCls={customizePrefixCls}
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
enableMeasure={mergedEnableEllipsis && !cssEllipsis}
text={children}
rows={rows}
width={ellipsisWidth}
onEllipsis={onJsEllipsis}
expanded={expanded}
miscDeps={[copied, expanded, copyLoading]}
>
{(node, canEllipsis) => {
let renderNode: React.ReactNode = node;
if (node.length && canEllipsis && !expanded && topAriaLabel) {
renderNode = (
<span key="show-content" aria-hidden>
{renderNode}
</span>
);
}
const wrappedContext = wrapperDecorations(
props,
<>
{renderNode}
{renderEllipsis(canEllipsis)}
</>,
);
return wrappedContext;
}}
</Ellipsis>
</Typography>
</EllipsisTooltip>
)}
</ResizeObserver>
);
});
export default Base;