UNPKG

@douyinfe/semi-ui

Version:

A modern, comprehensive, flexible design system and UI library. Connect DesignOps & DevOps. Quickly build beautiful React apps. Maintained by Douyin-fe team.

287 lines 10.9 kB
import { EditorContent, useEditor, isNodeEmpty } from '@tiptap/react'; import React, { useCallback, useEffect, useMemo } from 'react'; import Document from '@tiptap/extension-document'; import Text from '@tiptap/extension-text'; import { UndoRedo } from '@tiptap/extensions'; import Paragraph from '@tiptap/extension-paragraph'; import HardBreak from '@tiptap/extension-hard-break'; import { Placeholder } from '@tiptap/extensions'; import { Plugin, PluginKey } from '@tiptap/pm/state'; import { Decoration, DecorationSet } from '@tiptap/pm/view'; import InputSlot from './extension/inputSlot'; import SelectSlot from './extension/selectSlot'; import SkillSlot from './extension/skillSlot'; import { strings } from '@douyinfe/semi-foundation/lib/es/aiChatInput/constants'; import { cssClasses } from '@douyinfe/semi-foundation/lib/es/aiChatInput/constants'; import { handleCompositionEndLogic, handlePasteLogic, handleTextInputLogic, handleZeroWidthCharLogic } from './extension/plugins'; import SemiStatusExtension from './extension/statusExtension'; const PREFIX = cssClasses.PREFIX; /** * 复制 tiptap Placeholder 扩展的 preparePlaceholderAttribute 函数,用于规范化 dataAttribute */ function preparePlaceholderAttribute(attr) { return attr // replace whitespace with dashes .replace(/\s+/g, '-') // replace non-alphanumeric characters // or special chars like $, %, &, etc. // but not dashes .replace(/[^a-zA-Z0-9-]/g, '') // and replace any numeric character at the start .replace(/^[0-9-]+/, '') // and finally replace any stray, leading dashes .replace(/^-+/, '').toLowerCase(); } /** * 自定义 Placeholder 扩展,覆盖原插件的 decorations 逻辑,让仅包含 skillSlot 和零宽字符的文档也能显示 placeholder */ const CustomPlaceholder = Placeholder.extend({ addProseMirrorPlugins() { const dataAttribute = this.options.dataAttribute ? `data-${preparePlaceholderAttribute(this.options.dataAttribute)}` : 'data-placeholder'; // 自定义函数:检查文档是否“实际为空”——即忽略 skillSlot 节点和零宽字符后没有其他内容 const isDocActuallyEmpty = doc => { let actuallyEmpty = true; doc.descendants((node, pos, parent) => { // 如果已经发现不为空,提前终止遍历 if (!actuallyEmpty) { return false; } // 跳过 skillSlot 节点及其子节点 if (node.type.name === 'skillSlot') { return false; } // 检查文本节点是否只包含零宽字符 if (node.isText) { const textWithoutZeroWidth = (node.text || '').replace(new RegExp(strings.ZERO_WIDTH_CHAR, 'g'), ''); if (textWithoutZeroWidth.length > 0) { actuallyEmpty = false; return false; } } else if ( // 检查是否是其他非 leaf、非容器的自定义节点(如 inputSlot、selectSlot 等),如果是则不为空 node.type.name !== 'doc' && node.type.name !== 'paragraph') { // 对于 inputSlot/selectSlot 这类非 leaf、非容器的自定义节点,视为有内容 actuallyEmpty = false; return false; } return true; }); return actuallyEmpty; }; // 自定义函数:检查 paragraph 是否“实际为空”——即忽略 skillSlot 节点和零宽字符后没有其他内容 const isParagraphActuallyEmpty = paragraphNode => { let actuallyEmpty = true; paragraphNode.descendants((node, pos, parent) => { // 如果已经发现不为空,提前终止遍历 if (!actuallyEmpty) { return false; } // 跳过 skillSlot 节点及其子节点 if (node.type.name === 'skillSlot') { return false; } // 检查文本节点是否只包含零宽字符 if (node.isText) { const textWithoutZeroWidth = (node.text || '').replace(new RegExp(strings.ZERO_WIDTH_CHAR, 'g'), ''); if (textWithoutZeroWidth.length > 0) { actuallyEmpty = false; return false; } } else if (node.type.name !== 'paragraph') { // 对于其他自定义节点,视为有内容 actuallyEmpty = false; return false; } return true; }); return actuallyEmpty; }; // 自定义函数:检查 paragraph 是否包含 skillSlot const paragraphHasSkillSlot = paragraphNode => { let hasSkill = false; paragraphNode.descendants((node, pos, parent) => { if (node.type.name === 'skillSlot') { hasSkill = true; return false; } return true; }); return hasSkill; }; return [new Plugin({ key: new PluginKey('custom-placeholder'), props: { decorations: _ref => { let { doc, selection } = _ref; var _a; const active = this.editor.isEditable || !this.options.showOnlyWhenEditable; const { anchor } = selection; const decorations = []; if (!active) { return null; } const showPlaceholderWhenSkillOnly = (_a = this.options.showPlaceholderWhenSkillOnly) !== null && _a !== void 0 ? _a : false; const isEmptyDoc = this.editor.isEmpty || showPlaceholderWhenSkillOnly && isDocActuallyEmpty(doc); doc.descendants((node, pos) => { const hasAnchor = anchor >= pos && anchor <= pos + node.nodeSize; // 当开启 showPlaceholderWhenSkillOnly 时,使用自定义的 isParagraphActuallyEmpty const isEmpty = !node.isLeaf && (node.type.name === 'paragraph' ? showPlaceholderWhenSkillOnly ? isParagraphActuallyEmpty(node) : isNodeEmpty(node) : isNodeEmpty(node)); if ((hasAnchor || !this.options.showOnlyCurrent) && isEmpty) { const classes = [this.options.emptyNodeClass]; if (isEmptyDoc) { classes.push(this.options.emptyEditorClass); } // 如果开启 showPlaceholderWhenSkillOnly 且 paragraph 包含 skillSlot,添加特殊类 const hasSkill = showPlaceholderWhenSkillOnly && node.type.name === 'paragraph' && paragraphHasSkillSlot(node); if (hasSkill) { classes.push('has-skill-slot'); } const attrs = { class: classes.join(' '), [dataAttribute]: typeof this.options.placeholder === 'function' ? this.options.placeholder({ editor: this.editor, node, pos, hasAnchor }) : this.options.placeholder }; const decoration = Decoration.node(pos, pos + node.nodeSize, attrs); decorations.push(decoration); } return this.options.includeChildren; }); return DecorationSet.create(doc, decorations); } } })]; } }); export default props => { const { setEditor, onKeyDown, onChange, placeholder, extensions = [], defaultContent, onPaste, onPasteEvent, innerRef, handleKeyDown, onFocus, onBlur, handleCreate, immediatelyRender, showPlaceholderWhenSkillOnly } = props; const handleCompositionEnd = useCallback(view => { // Wait for ProseMirror to flush composition mutations before cleaning // zero-width placeholders, otherwise the slot content can be lost. setTimeout(() => { handleCompositionEndLogic(view); }, 60); }, []); const handleTextInput = useCallback((view, from, to, text) => { if (view.composing) { return false; } return handleTextInputLogic(view, from, to, text); }, []); const allExtensions = useMemo(() => { // 根据 showPlaceholderWhenSkillOnly 决定使用 CustomPlaceholder 还是原生的 Placeholder const customPlaceholderOptions = { placeholder: placeholder, showPlaceholderWhenSkillOnly: true }; const placeholderExtension = showPlaceholderWhenSkillOnly ? CustomPlaceholder.configure(customPlaceholderOptions) : Placeholder.configure({ placeholder: placeholder }); return [Document, Paragraph, Text, UndoRedo, HardBreak, InputSlot, SelectSlot, SkillSlot, placeholderExtension, SemiStatusExtension, ...extensions]; }, [extensions, placeholder, showPlaceholderWhenSkillOnly]); const editorProps = useMemo(() => { return { handleKeyDown: handleKeyDown, handlePaste: handlePasteLogic, handleTextInput, handleDOMEvents: { compositionend: handleCompositionEnd } }; }, [handleKeyDown, handleTextInput, handleCompositionEnd]); // const onSelectionUpdate = useCallback(({ editor }) => { // // For debug // const fromPos = editor.state.selection.from; // const { $from } = editor.state.selection; // console.log('光标/选区位置', fromPos, editor.state.selection, editor.state.doc); // // console.log('before', $from.nodeBefore, $from.nodeAfter); // }, []); const onCreate = useCallback(_ref2 => { let { editor } = _ref2; const { state, view } = editor; const tr = handleZeroWidthCharLogic(state); if (tr) { // 一次性触发,避免多次触发导致 appendTransaction 被多次调用 view.dispatch(tr); } handleCreate(); }, [handleCreate]); const onUpdate = useCallback(_ref3 => { let { editor } = _ref3; // The content has changed. const content = editor.getText(); onChange(content); }, [onChange]); const handlePaste = useCallback(e => { var _a; // To support file paste const items = (_a = e.clipboardData) === null || _a === void 0 ? void 0 : _a.items; let files = []; if (items) { for (const it of items) { const file = it.getAsFile(); file && files.push(it.getAsFile()); } } if (files.length) { onPaste === null || onPaste === void 0 ? void 0 : onPaste(files); } }, [onPaste]); const editor = useEditor({ extensions: allExtensions, content: defaultContent !== null && defaultContent !== void 0 ? defaultContent : ``, editorProps: editorProps, immediatelyRender, // onSelectionUpdate, onCreate, onUpdate, onPaste: handlePaste }); useEffect(() => { setEditor(editor); }, [editor, setEditor]); if (!editor) { // Prevent rendering until the editor is initialized return null; } return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(EditorContent, { editor: editor, onKeyDown: onKeyDown, onFocus: onFocus, onBlur: onBlur, onPaste: onPasteEvent, ref: innerRef, className: `${PREFIX}-editor-content` })); };