ant-design-vue/components/typography/Base.tsx

590 lines
16 KiB
Vue

import omit from 'omit.js';
import LocaleReceiver from '../locale-provider/LocaleReceiver';
import warning from '../_util/warning';
import TransButton from '../_util/transButton';
import raf from '../_util/raf';
import isStyleSupport from '../_util/styleChecker';
import Editable from './Editable';
import measure from './util';
import PropTypes from '../_util/vue-types';
import type { TypographyProps } from './Typography';
import Typography from './Typography';
import ResizeObserver from '../vc-resize-observer';
import Tooltip from '../tooltip';
import copy from '../_util/copy-to-clipboard';
import CheckOutlined from '@ant-design/icons-vue/CheckOutlined';
import CopyOutlined from '@ant-design/icons-vue/CopyOutlined';
import EditOutlined from '@ant-design/icons-vue/EditOutlined';
import type { VNodeTypes, CSSProperties } from 'vue';
import {
defineComponent,
reactive,
ref,
onMounted,
onBeforeUnmount,
watch,
watchEffect,
nextTick,
computed,
toRaw,
} from 'vue';
import type { AutoSizeType } from '../input/ResizableTextArea';
import useConfigInject from '../_util/hooks/useConfigInject';
export type BaseType = 'secondary' | 'success' | 'warning' | 'danger';
const isLineClampSupport = isStyleSupport('webkitLineClamp');
const isTextOverflowSupport = isStyleSupport('textOverflow');
export interface CopyConfig {
text?: string;
onCopy?: () => void;
tooltip?: boolean;
}
export interface EditConfig {
editing?: boolean;
tooltip?: boolean;
onStart?: () => void;
onChange?: (value: string) => void;
onCancel?: () => void;
onEnd?: () => void;
maxlength?: number;
autoSize?: boolean | AutoSizeType;
}
export interface EllipsisConfig {
rows?: number;
expandable?: boolean;
suffix?: string;
symbol?: string;
onExpand?: EventHandlerNonNull;
onEllipsis?: (ellipsis: boolean) => void;
tooltip?: boolean;
}
export interface BlockProps extends TypographyProps {
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;
content?: string;
}
interface Locale {
edit?: string;
copy?: string;
copied?: string;
expand?: string;
}
interface InternalBlockProps extends BlockProps {
component: string;
}
const ELLIPSIS_STR = '...';
const Base = defineComponent<InternalBlockProps>({
name: 'Base',
inheritAttrs: false,
emits: ['update:content'],
setup(props, { slots, attrs, emit }) {
const { prefixCls } = useConfigInject('typography', props);
const state = reactive({
edit: false,
copied: false,
ellipsisText: '',
ellipsisContent: null,
isEllipsis: false,
expanded: false,
clientRendered: false,
//locale
expandStr: '',
copyStr: '',
copiedStr: '',
editStr: '',
copyId: undefined,
rafId: undefined,
prevProps: undefined,
originContent: '',
});
const contentRef = ref();
const editIcon = ref();
const ellipsis = computed((): EllipsisConfig => {
const ellipsis = props.ellipsis;
if (!ellipsis) return {};
return {
rows: 1,
expandable: false,
...(typeof ellipsis === 'object' ? ellipsis : null),
};
});
onMounted(() => {
state.clientRendered = true;
});
onBeforeUnmount(() => {
window.clearTimeout(state.copyId);
raf.cancel(state.rafId);
});
watch(
[() => ellipsis.value.rows, () => props.content],
() => {
nextTick(() => {
resizeOnNextFrame();
});
},
{ flush: 'post', deep: true, immediate: true },
);
watchEffect(() => {
if (props.content === undefined) {
warning(
!props.editable,
'Typography',
'When `editable` is enabled, please use `content` instead of children',
);
warning(
!props.ellipsis,
'Typography',
'When `ellipsis` is enabled, please use `content` instead of children',
);
}
});
function getChildrenText(): string {
return props.ellipsis || props.editable ? props.content : contentRef.value?.$el?.innerText;
}
// =============== Expand ===============
function onExpandClick(e: MouseEvent) {
const { onExpand } = ellipsis.value;
state.expanded = true;
onExpand?.(e);
}
// ================ Edit ================
function onEditClick(e: MouseEvent) {
e.preventDefault();
state.originContent = props.content;
triggerEdit(true);
}
function onEditChange(value: string) {
onContentChange(value);
triggerEdit(false);
}
function onContentChange(value: string) {
const { onChange } = editable.value;
if (value !== props.content) {
emit('update:content', value);
onChange?.(value);
}
}
function onEditCancel() {
editable.value.onCancel?.();
triggerEdit(false);
}
// ================ Copy ================
function onCopyClick(e: MouseEvent) {
e.preventDefault();
const { copyable } = props;
const copyConfig = {
...(typeof copyable === 'object' ? copyable : null),
};
if (copyConfig.text === undefined) {
copyConfig.text = getChildrenText();
}
copy(copyConfig.text || '');
state.copied = true;
nextTick(() => {
if (copyConfig.onCopy) {
copyConfig.onCopy();
}
state.copyId = window.setTimeout(() => {
state.copied = false;
}, 3000);
});
}
const editable = computed(() => {
const editable = props.editable;
if (!editable) return { editing: state.edit };
return {
editing: state.edit,
...(typeof editable === 'object' ? editable : null),
};
});
function triggerEdit(edit: boolean) {
const { onStart } = editable.value;
if (edit && onStart) {
onStart();
}
state.edit = edit;
nextTick(() => {
if (!edit) {
editIcon.value?.focus();
}
});
}
// ============== Ellipsis ==============
function resizeOnNextFrame() {
raf.cancel(state.rafId);
state.rafId = raf(() => {
// Do not bind `syncEllipsis`. It need for test usage on prototype
syncEllipsis();
});
}
const canUseCSSEllipsis = computed(() => {
const { rows, expandable, suffix, onEllipsis, tooltip } = ellipsis.value;
if (suffix || tooltip) return false;
// Can't use css ellipsis since we need to provide the place for button
if (props.editable || props.copyable || expandable || onEllipsis) {
return false;
}
if (rows === 1) {
return isTextOverflowSupport;
}
return isLineClampSupport;
});
const syncEllipsis = () => {
const { ellipsisText, isEllipsis } = state;
const { rows, suffix, onEllipsis } = ellipsis.value;
if (
!rows ||
rows < 0 ||
!contentRef.value?.$el ||
state.expanded ||
props.content === undefined
)
return;
// Do not measure if css already support ellipsis
if (canUseCSSEllipsis.value) return;
const {
content,
text,
ellipsis: ell,
} = measure(
contentRef.value?.$el,
{ rows, suffix },
props.content,
renderOperations(true),
ELLIPSIS_STR,
);
if (ellipsisText !== text || state.isEllipsis !== ell) {
state.ellipsisText = text;
state.ellipsisContent = content;
state.isEllipsis = ell;
if (isEllipsis !== ell && onEllipsis) {
onEllipsis(ell);
}
}
};
function wrapperDecorations(
{ mark, code, underline, delete: del, strong, keyboard }: BlockProps,
content,
) {
let currentContent = content;
function wrap(needed: boolean, Tag: string) {
if (!needed) return;
currentContent = <Tag>{currentContent}</Tag>;
}
wrap(strong, 'strong');
wrap(underline, 'u');
wrap(del, 'del');
wrap(code, 'code');
wrap(mark, 'mark');
wrap(keyboard, 'kbd');
return currentContent;
}
function renderExpand(forceRender?: boolean) {
const { expandable, symbol } = ellipsis.value;
if (!expandable) return null;
// force render expand icon for measure usage or it will cause dead loop
if (!forceRender && (state.expanded || !state.isEllipsis)) return null;
const expandContent =
(slots.ellipsisSymbol ? slots.ellipsisSymbol() : symbol) || state.expandStr;
return (
<a
key="expand"
class={`${prefixCls.value}-expand`}
onClick={onExpandClick}
aria-label={state.expandStr}
>
{expandContent}
</a>
);
}
function renderEdit() {
if (!props.editable) return;
const { tooltip } = props.editable as EditConfig;
const icon = slots.editableIcon ? slots.editableIcon() : <EditOutlined role="button" />;
const title = slots.editableTooltip ? slots.editableTooltip() : state.editStr;
const ariaLabel = typeof title === 'string' ? title : '';
return (
<Tooltip key="edit" title={tooltip === false ? '' : title}>
<TransButton
ref={editIcon}
class={`${prefixCls.value}-edit`}
onClick={onEditClick}
aria-label={ariaLabel}
>
{icon}
</TransButton>
</Tooltip>
);
}
function renderCopy() {
if (!props.copyable) return;
const { tooltip } = props.copyable as CopyConfig;
const defaultTitle = state.copied ? state.copiedStr : state.copyStr;
const title = slots.copyableTooltip
? slots.copyableTooltip({ copied: state.copied })
: defaultTitle;
const ariaLabel = typeof title === 'string' ? title : '';
const defaultIcon = state.copied ? <CheckOutlined /> : <CopyOutlined />;
const icon = slots.copyableIcon
? slots.copyableIcon({ copied: !!state.copied })
: defaultIcon;
return (
<Tooltip key="copy" title={tooltip === false ? '' : title}>
<TransButton
class={[
`${prefixCls.value}-copy`,
{ [`${prefixCls.value}-copy-success`]: state.copied },
]}
onClick={onCopyClick}
aria-label={ariaLabel}
>
{icon}
</TransButton>
</Tooltip>
);
}
function renderEditInput() {
const { class: className, style } = attrs;
const { maxlength, autoSize, onEnd } = editable.value;
return (
<Editable
class={className}
style={style}
prefixCls={prefixCls.value}
value={props.content}
originContent={state.originContent}
maxlength={maxlength}
autoSize={autoSize}
onSave={onEditChange}
onChange={onContentChange}
onCancel={onEditCancel}
onEnd={onEnd}
/>
);
}
function renderOperations(forceRenderExpanded?: boolean) {
return [renderExpand(forceRenderExpanded), renderEdit(), renderCopy()].filter(node => node);
}
return () => {
const { editing } = editable.value;
const children =
props.ellipsis || props.editable
? props.content !== undefined
? props.content
: slots.default?.()
: slots.default
? slots.default()
: props.content;
if (editing) {
return renderEditInput();
}
return (
<LocaleReceiver
componentName="Text"
children={(locale: Locale) => {
const {
type,
disabled,
content,
class: className,
style,
...restProps
} = {
...props,
...attrs,
};
const { rows, suffix, tooltip } = ellipsis.value;
const { edit, copy: copyStr, copied, expand } = locale;
state.editStr = edit;
state.copyStr = copyStr;
state.copiedStr = copied;
state.expandStr = expand;
const textProps = omit(restProps, [
'prefixCls',
'editable',
'copyable',
'ellipsis',
'mark',
'code',
'delete',
'underline',
'strong',
'keyboard',
]);
const cssEllipsis = canUseCSSEllipsis.value;
const cssTextOverflow = rows === 1 && cssEllipsis;
const cssLineClamp = rows && rows > 1 && cssEllipsis;
let textNode = children as VNodeTypes;
let ariaLabel: string | undefined;
// Only use js ellipsis when css ellipsis not support
if (rows && state.isEllipsis && !state.expanded && !cssEllipsis) {
const { title } = restProps;
let restContent = title || '';
if (!title && (typeof children === 'string' || typeof children === 'number')) {
restContent = String(children);
}
// show rest content as title on symbol
restContent = restContent?.slice(String(state.ellipsisContent || '').length);
// We move full content to outer element to avoid repeat read the content by accessibility
textNode = (
<>
{toRaw(state.ellipsisContent)}
<span title={restContent} aria-hidden="true">
{ELLIPSIS_STR}
</span>
{suffix}
</>
);
} else {
textNode = (
<>
{children}
{suffix}
</>
);
}
textNode = wrapperDecorations(props, textNode);
const showTooltip =
tooltip && rows && state.isEllipsis && !state.expanded && !cssEllipsis;
const title = slots.ellipsisTooltip ? slots.ellipsisTooltip() : tooltip;
return (
<ResizeObserver onResize={resizeOnNextFrame} disabled={!rows}>
<Typography
ref={contentRef}
class={[
{
[`${prefixCls.value}-${type}`]: type,
[`${prefixCls.value}-disabled`]: disabled,
[`${prefixCls.value}-ellipsis`]: rows,
[`${prefixCls.value}-single-line`]: rows === 1,
[`${prefixCls.value}-ellipsis-single-line`]: cssTextOverflow,
[`${prefixCls.value}-ellipsis-multiple-line`]: cssLineClamp,
},
className,
]}
style={{
...(style as CSSProperties),
WebkitLineClamp: cssLineClamp ? rows : undefined,
}}
aria-label={ariaLabel}
{...textProps}
>
{showTooltip ? (
<Tooltip title={tooltip === true ? children : title}>
<span>{textNode}</span>
</Tooltip>
) : (
textNode
)}
{renderOperations()}
</Typography>
</ResizeObserver>
);
}}
/>
);
};
},
});
export const baseProps = () => ({
editable: PropTypes.oneOfType([PropTypes.looseBool, PropTypes.object]),
copyable: PropTypes.oneOfType([PropTypes.looseBool, PropTypes.object]),
prefixCls: PropTypes.string,
component: PropTypes.string,
type: PropTypes.oneOf(['secondary', 'success', 'danger', 'warning']),
disabled: PropTypes.looseBool,
ellipsis: PropTypes.oneOfType([PropTypes.looseBool, PropTypes.object]),
code: PropTypes.looseBool,
mark: PropTypes.looseBool,
underline: PropTypes.looseBool,
delete: PropTypes.looseBool,
strong: PropTypes.looseBool,
keyboard: PropTypes.looseBool,
content: PropTypes.string,
});
Base.props = baseProps();
export default Base;