Skip to content

Commit efe11d5

Browse files
authored
fix: [AIChatInput] Fixed the issue where addPasteRules for custom nod… (#3042)
* fix: [AIChatInput] Fixed the issue where addPasteRules for custom nodes was not working, closes #3040 * chore: update yarn.lock
1 parent 2166713 commit efe11d5

File tree

7 files changed

+268
-142
lines changed

7 files changed

+268
-142
lines changed

packages/semi-foundation/aiChatInput/constants.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ const strings = {
99
TOPS_SLOT_POSITION_DEFAULT: "top",
1010
ZERO_WIDTH_CHAR: '\uFEFF',
1111
PIC_PREFIX: 'image/',
12-
PIC_SUFFIX_ARRAY: ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp']
12+
PIC_SUFFIX_ARRAY: ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp'],
13+
DELETABLE: 'skipCustomTransactionPlugin'
1314
};
1415

1516
const numbers = {

packages/semi-ui/aiChatInput/_story/aiChatInput.stories.jsx

Lines changed: 71 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,10 @@ import { getAttachmentType, isImageType } from '@douyinfe/semi-foundation/aiChat
1010
import suggestion from './suggestion';
1111
import Mention from '@tiptap/extension-mention';
1212
import ReferSlot from './referSlot';
13-
import { RadioGroup, Radio, Cascader } from '../../index';
13+
import { RadioGroup, Radio, Cascader, Toast } from '../../index';
1414
import getConfigureItem from '../configure/getConfigureItem';
15+
import DocSlot from './docSlot';
16+
import copy from 'copy-text-to-clipboard';
1517

1618
export default {
1719
title: 'AIChatInput',
@@ -52,11 +54,19 @@ export const Basic = () => {
5254
const temp = {
5355
'input-slot': `我是一名<input-slot placeholder="[职业]">学生</input-slot>,帮我写一段面向<input-slot placeholder="[输入对象]"></input-slot>的话术内容`,
5456
'select-slot': `我的职业是<select-slot value="打工人" options='["打工人", "学生"]'></select-slot>,帮我写一份...`,
55-
// 'skill-slot': '<skill-slot data-label="帮我写作" data-value="writing" data-template=true></skill-slot>帮我完成...',
56-
'skill-slot': {
57-
type: "skillSlot",
58-
attrs: { label: "帮我写作", value: 'writing', hasTemplate: false }
59-
},
57+
// 'skill-slot': '<skill-slot data-label="帮我写作" data-value="writing" data-template=true></skill-slot>',
58+
"skill-slot": {
59+
type: "doc",
60+
content: [
61+
{
62+
type: "paragraph",
63+
content: [{
64+
type: "skillSlot",
65+
attrs: { value: "writing", label: "帮我写作", hasTemplate: "true", isCustomSlot: true }
66+
}]
67+
}
68+
]
69+
}
6070
};
6171

6272
export const RichTextExample = () => {
@@ -575,3 +585,58 @@ export const CustomRichTextExtension = () => {
575585
</>
576586
);
577587
}
588+
589+
export const AddPasteRule = () => {
590+
const ref = useRef();
591+
const extensions = useMemo(() => {
592+
return [ DocSlot ]
593+
}, []);
594+
595+
const onContentChange = useCallback((content) => {
596+
console.log('onContentChange', content);
597+
}, []);
598+
599+
const onButtonClick = useCallback(() => {
600+
console.log('html', ref.current?.editor.getHTML());
601+
console.log('json', ref.current?.editor.getJSON());
602+
}, [ref]);
603+
604+
const transformer = useMemo(() => {
605+
return new Map([
606+
['docSlot', (obj) => {
607+
const { attrs = {} } = obj;
608+
const { value, type = 'text', uniqueKey, urlValue } = attrs;
609+
return {
610+
type: type,
611+
value: value,
612+
urlValue: urlValue,
613+
uniqueKey: uniqueKey,
614+
};
615+
}],
616+
]);
617+
}, []);
618+
619+
const onClickCopy = useCallback(() => {
620+
const url = 'https://bytedance.larkoffice.com/docx/UihWdOxOmoya5CxbzKEcWTfTnnf';
621+
copy(url);
622+
Toast.success('复制成功,粘贴到富文本输入框中查看效果')
623+
}, []);
624+
625+
return (
626+
<>
627+
<Button onClick={onClickCopy}>点我复制文档链接</Button>
628+
<AIChatInput
629+
className='customTopSlot'
630+
extensions={extensions}
631+
onContentChange={onContentChange}
632+
ref={ref}
633+
transformer={transformer}
634+
uploadProps={uploadProps}
635+
style={outerStyle}
636+
placeholder="点击复制文档链接按钮,然后粘贴到这里"
637+
/>
638+
<Button onClick={onButtonClick}>点我获取结果</Button>
639+
{/* <Button onClick={onButtonClick2}>点我设置结果</Button> */}
640+
</>
641+
);
642+
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import React from 'react';
2+
import { Typography, Tooltip, AIChatInput } from '@douyinfe/semi-ui';
3+
import { Extension, Node, mergeAttributes, nodePasteRule } from '@tiptap/core';
4+
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react';
5+
6+
import { IconFile } from '@douyinfe/semi-icons';
7+
8+
9+
export const docSlotComponent = props => {
10+
const { Text } = Typography;
11+
const { node } = props;
12+
const value = node.attrs.urlValue ?? '';
13+
return (<NodeViewWrapper
14+
onClick={() => console.log('click doc slot', value)}
15+
className={'docSlotContainer'}
16+
>
17+
<div className={'textContainer'}>
18+
<IconFile />
19+
<Text
20+
className={'text'}
21+
ellipsis={{ showTooltip: { opts: { content: value } } }}
22+
>
23+
{value}
24+
</Text>
25+
</div>
26+
</NodeViewWrapper>
27+
);
28+
};
29+
30+
const DocSlot = Node.create({
31+
name: 'docSlot',
32+
inline: true,
33+
group: 'inline',
34+
atom: true,
35+
selectable: false,
36+
37+
// 自定义粘贴规则用例测试
38+
addPasteRules() {
39+
return [
40+
nodePasteRule({
41+
find: /^https:\/\/bytedance\.larkoffice\.com\/(docx|wiki)\/[A-Za-z0-9]{27}(?:\?[^\s]*)?/g,
42+
type: this.type,
43+
getAttributes: match => {
44+
console.log('match', match[0]);
45+
return {
46+
urlValue: match[0],
47+
};
48+
},
49+
}),
50+
];
51+
},
52+
53+
addAttributes() {
54+
return {
55+
value: {
56+
default: '',
57+
parseHTML: element => element.getAttribute('data-value'),
58+
renderHTML: attributes => ({
59+
'data-value': attributes.value,
60+
}),
61+
},
62+
urlValue: {
63+
default: '',
64+
parseHTML: element => element.getAttribute('data-url-value'),
65+
renderHTML: attributes => ({
66+
'data-url-value': attributes.urlValue,
67+
}),
68+
},
69+
type: {
70+
default: 'url',
71+
},
72+
uniqueKey: {
73+
default: '',
74+
parseHTML: element => element.getAttribute('data-unique-key'),
75+
renderHTML: attributes => ({
76+
'data-unique-key': attributes.uniqueKey,
77+
}),
78+
},
79+
isCustomSlot: AIChatInput.getCustomSlotAttribute(),
80+
};
81+
},
82+
83+
parseHTML() {
84+
return [ { tag: 'doc-slot' } ];
85+
},
86+
renderHTML({ HTMLAttributes }) {
87+
return ['doc-slot', mergeAttributes(HTMLAttributes)];
88+
},
89+
addNodeView() {
90+
return ReactNodeViewRenderer(docSlotComponent);
91+
},
92+
});
93+
94+
export default DocSlot;

packages/semi-ui/aiChatInput/_story/stories.scss

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,28 @@
188188
}
189189

190190

191+
.docSlotContainer {
192+
display: inline-flex;
193+
border: 1px solid var(--semi-color-border);
194+
background: var(--semi-color-fill-0);
195+
border-radius: 4px;
196+
font-size: 16px;
197+
line-height: 20px;
198+
199+
.textContainer {
200+
display: inline-flex;
201+
align-items: center;
202+
justify-content: center;
203+
204+
.text {
205+
max-width: 150px;
206+
font-size: 16px;
207+
line-height: 20px;
208+
color: var(--semi-color-text-0);
209+
}
210+
}
211+
}
212+
191213

192214

193215

packages/semi-ui/aiChatInput/extension/plugins.ts

Lines changed: 11 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -93,15 +93,15 @@ export function handleZeroWidthCharLogic(newState: EditorState) {
9393
export function ensureTrailingText(schema: any) {
9494
return new Plugin({
9595
appendTransaction(transactions, oldState, newState) {
96+
if (transactions.some(tr => tr.getMeta(strings.DELETABLE))) {
97+
// 此次 transaction 是主动删除 inputSlot,不补零宽字符
98+
// This is an active deletion of inputSlot, do not add zero-width characters
99+
return null;
100+
}
96101
// 只在内容发生变化时修正,防止选区丢失
97102
// Only correct when content changes to prevent loss of selections
98103
const docChanged = transactions.some(tr => tr.docChanged);
99104
if (!docChanged) return null;
100-
// if (transactions.some(tr => tr.getMeta(strings.DeleteAble))) {
101-
// // 此次 transaction 是主动删除 inputSlot,不补零宽字符
102-
// // This is an active deletion of inputSlot, do not add zero-width characters
103-
// return null;
104-
// }
105105
return handleZeroWidthCharLogic(newState);
106106
},
107107
});
@@ -409,51 +409,12 @@ export function keyDownHandlePlugin(schema: any) {
409409
}
410410

411411
export function handlePasteLogic(view: EditorView, event: ClipboardEvent) {
412-
// If there is rich text content, let tiptap handle it by default
413-
const types = event.clipboardData?.types || [];
414-
const html = event.clipboardData?.getData('text/html');
415-
// 如果包含 html 内容,并且 html 内容中包含 input-slot, select-slot, skill-slot 节点,则不阻断
416-
// todo:增加用户扩展 slot 的判断
417-
if ((types.includes('text/html') && (['<input-slot', '<select-slot', '<skill-slot'].some(slot => html?.includes(slot))))
418-
|| types.includes('application/x-prosemirror-slice')) {
419-
return false;
420-
}
421-
const text = event.clipboardData?.getData('text/plain');
422-
if (text) {
423-
const { state, dispatch } = view;
424-
const $from = state.selection.$from;
425-
let tr = state.tr;
426-
removeZeroWidthChar($from, tr);
427-
/* Use tr to continue the subsequent pasting logic and solve the problem of unsuccessful line wrapping of content
428-
pasted from certain web pages, such as the code of Feishu Documents */
429-
const lines = text.split('\n');
430-
let finalCursorPos = null;
431-
if (lines.length === 1) {
432-
// Insert the first line directly
433-
tr = tr.insertText(lines[0], tr.selection.from, tr.selection.to);
434-
finalCursorPos = tr.selection.$to.pos;
435-
} else {
436-
// other lines, insert one by one
437-
tr = tr.insertText(lines[0], tr.selection.from, tr.selection.to);
438-
let pos = tr.selection.$to.pos;
439-
for (let i = 1; i < lines.length; i++) {
440-
const paragraph = state.schema.nodes.paragraph.create(
441-
{},
442-
lines[i] ? state.schema.text(lines[i]) : null
443-
);
444-
tr = tr.insert(pos, paragraph);
445-
pos += paragraph.nodeSize;
446-
}
447-
finalCursorPos = pos; // 粘贴多行时,光标应在最后插入内容末尾
448-
}
449-
// 设置 selection 到粘贴内容末尾
450-
tr = tr.setSelection(TextSelection.create(tr.doc, finalCursorPos));
451-
// scroll to the pasted position
452-
tr = tr.scrollIntoView();
453-
dispatch(tr);
454-
event.preventDefault();
455-
return true;
456-
}
412+
const { state, dispatch } = view;
413+
const $from = state.selection.$from;
414+
let tr = state.tr;
415+
removeZeroWidthChar($from, tr);
416+
tr.setMeta(strings.DELETABLE, true);
417+
dispatch(tr);
457418
return false;
458419
}
459420

packages/semi-ui/package.json

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -26,15 +26,15 @@
2626
"@douyinfe/semi-icons": "2.88.2",
2727
"@douyinfe/semi-illustrations": "2.88.2",
2828
"@douyinfe/semi-theme-default": "2.88.2",
29-
"@tiptap/core": "^3.1.0",
30-
"@tiptap/extension-document": "^3.3.0",
31-
"@tiptap/extension-hard-break": "^3.3.0",
32-
"@tiptap/extension-mention": "^3.1.0",
33-
"@tiptap/extension-paragraph": "^3.3.0",
34-
"@tiptap/extension-text": "^3.3.0",
35-
"@tiptap/extensions": "^3.1.0",
36-
"@tiptap/pm": "^3.1.0",
37-
"@tiptap/react": "^3.1.0",
29+
"@tiptap/core": "^3.10.7",
30+
"@tiptap/extension-document": "^3.10.7",
31+
"@tiptap/extension-hard-break": "^3.10.7",
32+
"@tiptap/extension-mention": "^3.10.7",
33+
"@tiptap/extension-paragraph": "^3.10.7",
34+
"@tiptap/extension-text": "^3.10.7",
35+
"@tiptap/extensions": "^3.10.7",
36+
"@tiptap/pm": "^3.10.7",
37+
"@tiptap/react": "^3.10.7",
3838
"async-validator": "^3.5.0",
3939
"classnames": "^2.2.6",
4040
"copy-text-to-clipboard": "^2.1.1",

0 commit comments

Comments
 (0)