@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
JavaScript
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`
}));
};