Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(bubble): add editable support #328

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 51 additions & 17 deletions components/bubble/Bubble.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import React from 'react';
import { Avatar } from 'antd';
import useXComponentConfig from '../_util/hooks/use-x-component-config';
import { useXProviderContext } from '../x-provider';
import Editor from './Editor';
import useEditableConfig from './hooks/useEditableConfig';
import useTypedEffect from './hooks/useTypedEffect';
import useTypingConfig from './hooks/useTypingConfig';
import type { BubbleProps } from './interface';
Expand Down Expand Up @@ -40,6 +42,7 @@ const Bubble: React.ForwardRefRenderFunction<BubbleRef, BubbleProps> = (props, r
onTypingComplete,
header,
footer,
editable = {},
...otherHtmlProps
} = props;

Expand All @@ -60,6 +63,17 @@ const Bubble: React.ForwardRefRenderFunction<BubbleRef, BubbleProps> = (props, r
// ===================== Component Config =========================
const contextConfig = useXComponentConfig('bubble');

// =========================== Editable ===========================
const {
enableEdit,
isEditing,
onEditorChange,
onEditorCancel,
onEditorEnd,
editorTextAreaConfig,
editorButtonsConfig,
} = useEditableConfig(editable);

// ============================ Typing ============================
const [typingEnabled, typingStep, typingInterval] = useTypingConfig(typing);

Expand Down Expand Up @@ -119,23 +133,43 @@ const Bubble: React.ForwardRefRenderFunction<BubbleRef, BubbleProps> = (props, r
contentNode = mergedContent as React.ReactNode;
}

let fullContent: React.ReactNode = (
<div
style={{
...contextConfig.styles.content,
...styles.content,
}}
className={classnames(
`${prefixCls}-content`,
`${prefixCls}-content-${variant}`,
shape && `${prefixCls}-content-${shape}`,
contextConfig.classNames.content,
classNames.content,
)}
>
{contentNode}
</div>
);
let fullContent: React.ReactNode =
enableEdit && isEditing ? (
<Editor
prefixCls={prefixCls}
value={mergedContent as string}
onChange={onEditorChange}
onCancel={onEditorCancel}
onEnd={onEditorEnd}
style={{
...contextConfig.styles.editor,
...editable.styles,
}}
className={classnames(
`${prefixCls}-editor`,
contextConfig.classNames.editor,
editable.classNames,
)}
editorTextAreaConfig={editorTextAreaConfig}
editorButtonsConfig={editorButtonsConfig}
/>
) : (
<div
style={{
...contextConfig.styles.content,
...styles.content,
}}
className={classnames(
`${prefixCls}-content`,
`${prefixCls}-content-${variant}`,
shape && `${prefixCls}-content-${shape}`,
contextConfig.classNames.content,
classNames.content,
)}
>
{contentNode}
</div>
);

if (header || footer) {
fullContent = (
Expand Down
121 changes: 121 additions & 0 deletions components/bubble/Editor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import classNames from 'classnames';
import * as React from 'react';

import { Button, Flex, Input } from 'antd';
import type { TextAreaRef } from 'antd/lib/input/TextArea';
import type { EditConfig } from './interface';
import useStyle from './style';

const { TextArea } = Input;

interface EditableProps extends EditConfig {
prefixCls: string;
value: string;
onChange?: (value: string) => void;
onCancel?: () => void;
onEnd?: (value: string) => void;
className?: string;
style?: React.CSSProperties;
editorTextAreaConfig?: EditConfig['textarea'];
editorButtonsConfig?: EditConfig['buttons'];
}
tabzzz1 marked this conversation as resolved.
Show resolved Hide resolved

const Editor: React.FC<EditableProps> = (props) => {
const {
prefixCls,
className,
style,
value,
onChange,
onCancel,
onEnd,
editorTextAreaConfig,
editorButtonsConfig,
} = props;
const textAreaRef = React.useRef<TextAreaRef>(null);

const [current, setCurrent] = React.useState(value);

React.useEffect(() => {
setCurrent(value);
}, [value]);

React.useEffect(() => {
if (textAreaRef.current?.resizableTextArea) {
const { textArea } = textAreaRef.current.resizableTextArea;
textArea.focus();
const { length } = textArea.value;
textArea.setSelectionRange(length, length);
}
}, []);
Comment on lines +43 to +50
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

建议优化文本区域的焦点管理

当前的焦点管理逻辑可能在某些情况下不够稳定。建议添加错误处理并使用 requestAnimationFrame 来确保 DOM 已经准备就绪。

 React.useEffect(() => {
-  if (textAreaRef.current?.resizableTextArea) {
-    const { textArea } = textAreaRef.current.resizableTextArea;
-    textArea.focus();
-    const { length } = textArea.value;
-    textArea.setSelectionRange(length, length);
-  }
+  requestAnimationFrame(() => {
+    try {
+      if (textAreaRef.current?.resizableTextArea) {
+        const { textArea } = textAreaRef.current.resizableTextArea;
+        textArea.focus();
+        const { length } = textArea.value;
+        textArea.setSelectionRange(length, length);
+      }
+    } catch (error) {
+      console.error('Failed to focus textarea:', error);
+    }
+  });
 }, []);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
React.useEffect(() => {
if (textAreaRef.current?.resizableTextArea) {
const { textArea } = textAreaRef.current.resizableTextArea;
textArea.focus();
const { length } = textArea.value;
textArea.setSelectionRange(length, length);
}
}, []);
React.useEffect(() => {
requestAnimationFrame(() => {
try {
if (textAreaRef.current?.resizableTextArea) {
const { textArea } = textAreaRef.current.resizableTextArea;
textArea.focus();
const { length } = textArea.value;
textArea.setSelectionRange(length, length);
}
} catch (error) {
console.error('Failed to focus textarea:', error);
}
});
}, []);


const onTextAreaChange: React.ChangeEventHandler<HTMLTextAreaElement> = ({ target }) => {
setCurrent(target.value.replace(/[\n\r]/g, ''));
onChange?.(target.value.replace(/[\n\r]/g, ''));
};

const confirmEnd = () => {
onEnd?.(current.trim());
};

const [wrapCSSVar, hashId, cssVarCls] = useStyle(prefixCls);

const editorClassName = classNames(
prefixCls,
`${prefixCls}-editor`,
className,
hashId,
cssVarCls,
);

const EditorButtons: React.FC<EditConfig['buttons']> = (editorButtonsConfig) => {
const defaultButtonsConfig = [
{ type: 'cancel', text: 'Cancel', option: {} },
{ type: 'save', text: 'Save', option: {} },
];

const buttonsConfig =
editorButtonsConfig && editorButtonsConfig.length > 0
? editorButtonsConfig
: defaultButtonsConfig;

return buttonsConfig.map((config, index) => {
const { type, text, option } = config;
const handlers = {
cancel: onCancel,
save: confirmEnd,
};
return (
<Button
size="small"
key={index}
type={type === 'save' ? 'primary' : undefined}
{...option}
onClick={handlers[type as keyof typeof handlers]}
>
{text || type}
</Button>
);
});
};

return wrapCSSVar(
<div className={editorClassName} style={style}>
<Flex gap="small" vertical flex="auto">
<TextArea
variant="borderless"
ref={textAreaRef}
value={current}
onChange={onTextAreaChange}
autoSize={{ minRows: 2, maxRows: 3 }}
{...editorTextAreaConfig}
/>
<Flex gap="small" justify="end">
{EditorButtons(editorButtonsConfig)}
</Flex>
</Flex>
</div>,
);
};

export default Editor;
7 changes: 7 additions & 0 deletions components/bubble/demo/editable.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
## zh-CN

通过设置 `editable` 属性,开启对 `content` 的编辑效果。

## en-US

Enable the editing effect of `content` by setting the `editable` property.
145 changes: 145 additions & 0 deletions components/bubble/demo/editable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import {
DeleteOutlined,
EditOutlined,
LeftOutlined,
RightOutlined,
UserOutlined,
} from '@ant-design/icons';
import { Bubble } from '@ant-design/x';
import { Button, Flex } from 'antd';
import React from 'react';
import { EditConfig } from '../interface';

const fooAvatar: React.CSSProperties = {
color: '#f56a00',
backgroundColor: '#fde3cf',
};

const barAvatar: React.CSSProperties = {
color: '#fff',
backgroundColor: '#87d068',
};

const hideAvatar: React.CSSProperties = {
visibility: 'hidden',
};

const App = () => {
const [editing, setEditing] = React.useState(false);
const [currentIndex, setCurrentIndex] = React.useState(0);
const [editHistory, setEditHistory] = React.useState(['Good morning, how are you?']); // 编辑历史记录

const triggerEdit = () => {
setEditing((prev) => !prev);
};

const triggerDelete = () => {
if (editHistory.length === 1 || editing) return null;
// Some else logic
setEditHistory((prev) => {
const newHistory = [...prev];
newHistory.splice(currentIndex, 1);
setCurrentIndex(Math.min(currentIndex, newHistory.length - 1));
return newHistory;
});
};

const handleLeftClick = () => {
if (currentIndex > 0) {
setCurrentIndex((prev) => prev - 1);
}
};

const handleRightClick = () => {
if (currentIndex < editHistory.length - 1) {
setCurrentIndex((prev) => prev + 1);
}
};

const cancelEdit = () => {
setEditing(false);
};

const endEdit = (c: string) => {
setEditing(false);
setEditHistory((prev) => {
const newHistory = [...prev, c];
setCurrentIndex(newHistory.length - 1);
return newHistory;
});
};

const editConfig: EditConfig = {
editing,
onCancel: cancelEdit,
onEnd: endEdit,
textarea: { autoSize: { minRows: 2, maxRows: 4 } },
styles: {
minWidth: '50%',
},
buttons: [
{
type: 'cancel',
text: 'Cancel',
option: { danger: true },
},
{
type: 'save',
text: 'Save',
option: { type: 'primary' },
},
],
};

return (
<Flex gap="middle" vertical>
<Bubble
placement="end"
content={editHistory[currentIndex]}
editable={editConfig}
avatar={{ icon: <UserOutlined />, style: barAvatar }}
header={
editHistory.length > 1 &&
!editing && (
<Flex justify="end">
<Button
size="small"
type="text"
icon={<LeftOutlined />}
onClick={handleLeftClick}
disabled={currentIndex === 0}
/>
<span>{`${currentIndex + 1} / ${editHistory.length}`}</span>
<Button
size="small"
type="text"
icon={<RightOutlined />}
onClick={handleRightClick}
disabled={currentIndex === editHistory.length - 1}
/>
</Flex>
)
}
footer={
<Flex justify="end">
<Button size="small" type="text" icon={<DeleteOutlined />} onClick={triggerDelete} />
<Button size="small" type="text" icon={<EditOutlined />} onClick={triggerEdit} />
</Flex>
}
/>
<Bubble
placement="start"
content="Hi, good morning, I'm fine!"
avatar={{ icon: <UserOutlined />, style: fooAvatar }}
/>
<Bubble
placement="start"
content="What a beautiful day!"
styles={{ avatar: hideAvatar }}
avatar={{}}
/>
</Flex>
);
};

export default App;
Loading
Loading