590 lines
16 KiB
Vue
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;
|