@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.
597 lines (567 loc) • 32.1 kB
text/typescript
import { EditorState, Plugin, PluginKey, TextSelection, Transaction, Selection } from 'prosemirror-state';
import { strings } from '@douyinfe/semi-foundation/aiChatInput/constants';
import { EditorView } from '@tiptap/pm/view';
/**
* @param newState
* @returns
* handleZeroWidthCharLogic 用于插入零宽字符或者删除多余的零宽字符
* 为什么需要插入零宽字符?
* 1. 保证自定义节点前后的光标高度正常,光标高度和内容相关,解决自定义节点是最后一个节点,
* 光标高度会和自定义节点占据高度一致,和文本中光标高度不一致的问题
* 2. 保证对于可编辑的 inline 节点(比如 input-slot),为最后一个节点时候,光标可以聚焦到该节点后
* Why do we need to insert zero-width characters?
* 1. Ensure that the cursor height before and after the custom node is normal.
* The cursor height is related to the content. Solve the problem that when the custom node is the last node,
* the cursor height will be consistent with the height occupied by the custom node, and inconsistent with the cursor height in the text.
* 2. Ensure that for an editable inline node (such as input-slot), when it is the last node, the cursor can focus after the node.
*/
export function handleZeroWidthCharLogic(newState: EditorState) {
let todoPositions = [];
let { tr } = newState;
newState.doc.descendants((node, pos, parent) => {
if (node.type.name === 'paragraph' && node.childCount > 0) {
const { lastChild, firstChild } = node;
if (firstChild && firstChild.attrs.isCustomSlot) {
// 如果第一个 child 是自定义节点,应该在自定义节点前添加零宽字符
// If the first child is a custom node, a zero-width character should be added before the custom node.
// 保证光标可以移动到第一个自定义节点前
// Ensure that the cursor can move to the first custom node before.
todoPositions.push(pos + 1);
}
if (lastChild && lastChild.attrs.isCustomSlot) {
// 在段落末尾插入一个零宽字符, 避免当自定义节点是段落最后一个节点时候,光标无法移出
// Insert a zero-width character at the end of the paragraph to prevent
// the cursor from being unable to move out when custom node is the last node of the paragraph.
const paragraphEndPos = pos + node.nodeSize - 1;
const prevChar = tr.doc.textBetween(paragraphEndPos - 1, paragraphEndPos, '', '');
if (prevChar !== strings.ZERO_WIDTH_CHAR) {
todoPositions.push(paragraphEndPos);
}
}
if (lastChild === firstChild && lastChild.isText && lastChild.text === strings.ZERO_WIDTH_CHAR) {
todoPositions.push(['remove', pos + 1]);
}
}
// 保证在 undo/通过 set 修改 content 时候,没有内容的 inputSlot 节点内部有零宽字符
// Ensure that there are zero-width characters inside the inputSlot node without content when undoing/setting content
// 保证 input-slot 节点可以正常显示
// Ensure that the input-slot node can be displayed normally
if (node.type.name === 'inputSlot' && node.content.size === 0) {
todoPositions.push(pos + 1);
}
/**
* 如果连续两个节点都是 custom slot,则需要在两个节点中间插入零宽字符,用于保证
* - 对于 input-slot,光标可以移动到两个 input-slot 之间
* - 对于其他的非输入类型的 custom-slot,光标高度正确
*/
if (node.attrs.isCustomSlot) {
let nodeIndex = -1;
parent.forEach((child, offset, i) => {
if (child === node) {
nodeIndex = i;
}
});
if (nodeIndex > -1 && nodeIndex < parent.childCount - 1) {
const nextSibling = parent.child(nodeIndex + 1);
if (nextSibling.attrs.isCustomSlot) {
todoPositions.push(pos + node.nodeSize);
}
}
}
});
if (todoPositions.length > 0) {
// why sorting?
// If you insert from the beginning, the newly inserted content will affect the position of the original record.
todoPositions.sort((a, b) => {
const aOrder = Array.isArray(a) ? a[1] : a;
const bOrder = Array.isArray(b) ? b[1] : b;
return bOrder - aOrder;
}).forEach(insertPos => {
if (Array.isArray(insertPos) && insertPos[0] === 'remove') {
tr = tr.delete(insertPos[1], insertPos[1] + 1);
} else {
tr = tr.insertText(strings.ZERO_WIDTH_CHAR, insertPos, insertPos);
}
});
return tr;
}
return null;
}
type SyntheticBackspacePatchedView = EditorView & {
_semiSyntheticBackspaceGuard?: boolean;
_semiOrigSomeProp?: typeof EditorView.prototype.someProp
};
/**
* ProseMirror uses `document.createEvent("Event")` to synthesize a Backspace
* during `readDOMChange()` when a DOM diff "looks like" a deletion. That replay
* is not a real browser KeyboardEvent, but it still flows through `handleKeyDown`.
*/
function isSyntheticBackspaceEvent(event: KeyboardEvent) {
return event?.type === 'keydown' &&
event?.key === 'Backspace' &&
event?.keyCode === 8 &&
(event.isTrusted === false || !(event instanceof KeyboardEvent));
}
/**
* Wrap `view.someProp("handleKeyDown")` so we can ignore only ProseMirror's
* synthetic Backspace replay inside `inputSlot` while IME composition is active.
*
* The real user Backspace must still go through unchanged, otherwise slot
* deletion, placeholder recovery and cursor movement regressions come back.
*/
function installSyntheticBackspaceGuard(view: EditorView) {
const patchedView = view as SyntheticBackspacePatchedView;
if (patchedView._semiSyntheticBackspaceGuard) {
return;
}
const origSomeProp = view.someProp.bind(view);
patchedView._semiSyntheticBackspaceGuard = true;
patchedView._semiOrigSomeProp = origSomeProp;
view.someProp = function (propName: any, f?: any) {
if (propName !== 'handleKeyDown' || typeof f !== 'function') {
return origSomeProp(propName, f);
}
return origSomeProp(propName, (prop: any) => {
if (typeof prop !== 'function') {
return f(prop);
}
return f((innerView: EditorView, event: KeyboardEvent) => {
// ProseMirror may replay a synthetic Backspace from readDOMChange().
// Ignore only that replay inside inputSlot while IME is active.
if (
innerView.composing &&
innerView.state.selection.$from.parent.type.name === 'inputSlot' &&
isSyntheticBackspaceEvent(event)
) {
return false;
}
return prop(innerView, event);
});
});
} as any;
}
/**
* Restore the original `someProp` implementation when the plugin view unmounts.
*/
function removeSyntheticBackspaceGuard(view: EditorView) {
const patchedView = view as SyntheticBackspacePatchedView;
if (!patchedView._semiSyntheticBackspaceGuard || !patchedView._semiOrigSomeProp) {
return;
}
view.someProp = patchedView._semiOrigSomeProp;
delete patchedView._semiOrigSomeProp;
delete patchedView._semiSyntheticBackspaceGuard;
}
export function ensureTrailingText(schema: any) {
return new Plugin({
view(view) {
installSyntheticBackspaceGuard(view);
return {
destroy() {
removeSyntheticBackspaceGuard(view);
},
};
},
appendTransaction(transactions, oldState, newState) {
if (transactions.some(tr => tr.getMeta('composition'))) {
return null;
}
if (transactions.some(tr => tr.getMeta(strings.DELETABLE))) {
return null;
}
const docChanged = transactions.some(tr => tr.docChanged);
if (!docChanged) return null;
return handleZeroWidthCharLogic(newState);
},
});
}
export function keyDownHandlePlugin(schema: any) {
return new Plugin({
key: new PluginKey('prevent-empty-inline-node'),
props: {
handleKeyDown(view, event) {
if (view.composing) {
return false;
}
const { state, dispatch } = view;
const { selection } = state;
const { $from, $to } = selection;
const node = $from.node();
if (event.key === 'ArrowLeft' && node.type.name !== 'inputSlot') {
if ($from.nodeBefore && $from.nodeBefore.isText && $from.nodeBefore.text) {
if ($from.nodeBefore.text === strings.ZERO_WIDTH_CHAR) {
// 获取零宽字符前的节点
// Get the node before the zero-width character
const parent = $from.parent;
const index = $from.index();
if (index >= 2) {
/**
* 判断条件: 节点顺序为[···、customSlot、零宽字符、光标、····],按下 arrowLeft
* - 如果 custom slot 为 input-slot, 则光标跳到 input-slot 的最后一个可聚焦位置
* - 如果 custom slot 为其他不可编辑的 slot, 则光标调整到 custom slot 之前,注:不可编辑的节点大小为 1
*/
const secondBeforeCursorNode = parent.child(index - 2);
if (secondBeforeCursorNode.attrs.isCustomSlot) {
// The end of the content in the inputSlot node
const nextCursorPos = $from.pos - 2;
dispatch(state.tr.setSelection(TextSelection.create(state.doc, nextCursorPos)));
event.preventDefault();
return true;
}
} else if (index === 1 && $from.pos !== 0) {
/**
* 判断条件: 节点顺序为[前一个 Paragraph、换行、零宽字符、光标、····],按下 arrowLeft
* 结果: [前一个 Paragraph、光标、换行、零宽字符、 ····]
*/
const nextCursorPos = $from.before() - 1;
nextCursorPos > 0 && dispatch(state.tr.setSelection(TextSelection.create(state.doc, nextCursorPos)));
event.preventDefault();
return true;
}
} else if ($from.nodeBefore.text.endsWith(strings.ZERO_WIDTH_CHAR)) {
// Backup,当零宽字符出现在 text 节点中
const nextCursorPos = $from.pos - 2;
dispatch(state.tr.setSelection(TextSelection.create(state.doc, nextCursorPos)));
event.preventDefault();
return true;
}
}
}
if (event.key === 'ArrowRight' && node.type.name !== 'inputSlot') {
if ($from.nodeAfter && $from.nodeAfter.isText) {
if ($from.nodeAfter.text === strings.ZERO_WIDTH_CHAR) {
/**
* 判断条件: 节点顺序为[···、光标、零宽字符、customSlot、····],按下 arrowRight
* - 如果 custom slot 为 input-slot, 则光标跳到 input-slot 的第一个一个可聚焦位置
* - 如果 custom slot 为其他不可编辑的 slot, 则光标调整到 custom slot 之后
*/
// 获取零宽字符后的节点
// Get the node before the zero-width character
const parent = $from.parent;
const index = $from.index();
if (index < parent.children.length - 1) {
const secondAfterCursorNode = parent.child(index + 1);
if (secondAfterCursorNode.attrs.isCustomSlot) {
// The starting position of the input-slot node
const newPos = $from.pos + 2;
dispatch(state.tr.setSelection(TextSelection.create(state.doc, newPos)));
event.preventDefault();
return true;
}
} else if (index === parent.children.length - 1 && state.doc.lastChild !== node ) {
/**
* 判断条件: 节点顺序为[···光标、零宽字符、换行、下一个 paragraphph···],按下 arrowLeft
* 结果: [···零宽字符、换行、光标、下一个 paragraphph···]
*/
const nextCursorPos = $from.after() + 1;
dispatch(state.tr.setSelection(TextSelection.create(state.doc, nextCursorPos)));
event.preventDefault();
return true;
}
} else if ($from.nodeBefore && $from.nodeBefore.isText && $from.nodeBefore.text.startsWith(strings.ZERO_WIDTH_CHAR)) {
// Backup,当零宽字符出现在 text 节点中
const nextCursorPos = $from.pos + 2;
dispatch(state.tr.setSelection(TextSelection.create(state.doc, nextCursorPos)));
event.preventDefault();
return true;
}
}
}
if (event.key === 'Backspace' && selection.empty) {
const beforeNode = $from.nodeBefore;
const afterNode = $from.nodeAfter;
/**
* [长度为 1 的普通文本、光标、 customSlot] ---按下删除按键--->[光标、customSlot]
* 专用于处理 custom slot 前为一个文本节点,且文本节点中长度为1时候,文本删除不掉的情况
*/
if (
$from.nodeBefore && $from.nodeBefore.isText &&
$from.nodeBefore.text?.length === 1 && $from.nodeBefore.text !== strings.ZERO_WIDTH_CHAR &&
$from.nodeAfter && $from.nodeAfter.attrs.isCustomSlot
) {
const begin = $from.pos - $from.nodeBefore.nodeSize;
const end = $from.pos;
let tr = state.tr.delete(begin, end);
tr = tr.insertText(strings.ZERO_WIDTH_CHAR, begin, begin);
dispatch(tr);
event.preventDefault();
return true;
}
// 顺序为[···、零宽字符(可能)、customSlot、光标、零宽字符(可能)、 ····] -> [···、光标、····]
if (beforeNode && beforeNode.attrs.isCustomSlot) {
const parent = $from.parent;
const index = $from.index(); // 当前光标在 parent.children 中的 offset
const initalStart = $from.pos - beforeNode.nodeSize;
const intialEnd = $from.pos;
let deleteStart = initalStart;
let deleteEnd = intialEnd;
if (index > 1) {
const prevPrevNode = parent.child(index - 2);
if (prevPrevNode && prevPrevNode.isText && prevPrevNode.text.endsWith(strings.ZERO_WIDTH_CHAR)) {
deleteStart = deleteStart - 1;
}
}
if (afterNode.isText && afterNode.text.startsWith(strings.ZERO_WIDTH_CHAR)) {
deleteEnd = deleteEnd + 1;
}
if (deleteStart !== initalStart || deleteEnd !== intialEnd) {
const tr = state.tr.delete(deleteStart, deleteEnd);
dispatch(tr);
event.preventDefault();
return true;
}
}
if (afterNode && afterNode.isText && afterNode.text === strings.ZERO_WIDTH_CHAR) {
const index = $from.index(); // 当前光标在 parent.children 中的 offset
if (index === 0 && $from.pos !== 1) {
/**
* 判断条件: 节点顺序为[····、前一个 Paragraph、换行、光标、零宽字符、····],按下 delete
* 结果: [前一个 Paragraph、光标 ····]
*/
const startPos = selection.from - 2;
const tr = state.tr.delete(startPos, selection.to + 1);
dispatch(tr);
event.preventDefault();
return true;
}
}
if (beforeNode && beforeNode.isText && beforeNode.text === strings.ZERO_WIDTH_CHAR) {
const parent = $from.parent;
const index = $from.index(); // 当前光标在 parent.children 中的 offset
if (index > 1) {
/** 判断条件: 节点顺序为[···、customSlot、零宽字符、光标、····] 按下 Backspace
* 结果: 节点顺序为[···、光标、····]
*/
const prevPrevNode = parent.child(index - 2);
if (prevPrevNode.attrs.isCustomSlot) {
const deleteStart = $from.pos - beforeNode.nodeSize - prevPrevNode.nodeSize;
const tr = state.tr.delete(deleteStart, $from.pos);
dispatch(tr);
event.preventDefault();
return true;
}
// prevPrevNode 就是你想要的光标前一个节点的前一个节点
} else if (index === 1 && node.type.name !== 'inputSlot') {
/**
* 判断条件:节点顺序 [···、上一个paragraph、换行、零宽字符、光标、customSlot、····], 按下 Backspace
* 结果:[···、原来的上一个paragraph、光标、customSlot、····]
*/
if ($from.pos !== 1) {
const startPos = selection.from - 1 - 2;
const tr = state.tr.delete(startPos, selection.to);
dispatch(tr);
event.preventDefault();
return true;
}
}
} else {
/**
* 判断条件:节点顺序为[···、inputSlot、····], 光标在 inputSlot 的首位,按下 backSpace
* 结论:1. 如果 inputSlot 前面是零宽字符,则直接将光标移动到零宽字符之前
* 2. 如果前面不是零宽字符,则在 inputSlot 前面添加零宽字符,并将光标移动到零宽字符前
* 用于解决光标在 inputSlot 前,按下 backSpace,出现 inputSlot 前的内容被删除问题
*/
if (node.type.name === 'inputSlot' && $from.pos === $from.start()) {
// 1. 如果前面是零宽字符,则直接将光标移动到零宽字符之前
const grandParent = $from.node($from.depth - 1);
let parentPrevNode = null;
const parentIndex = $from.index($from.depth - 1);
if (parentIndex > 0) {
parentPrevNode = grandParent.child(parentIndex - 1);
if (parentPrevNode && parentPrevNode.isText && parentPrevNode.text.endsWith(strings.ZERO_WIDTH_CHAR)) {
const pos = $from.pos - 2;
dispatch(state.tr.setSelection(TextSelection.create(state.doc, pos)));
event.preventDefault();
return true;
}
}
// 2. 如果前面不是零宽字符,则插入一个零宽字符,并将光标移动到零宽字符之前
const pos = $from.pos - 1;
let tr = state.tr.insertText(strings.ZERO_WIDTH_CHAR, pos, pos + 1);
tr = tr.setSelection(TextSelection.create(tr.doc, pos));
dispatch(tr);
event.preventDefault();
return true;
}
}
}
if (event.key === 'Backspace' && !selection.empty) {
let startPos = selection.from;
let endPos = selection.to;
const nodeBefore = $from.nodeBefore;
const nodeAfter = $from.nodeAfter;
if (nodeBefore && nodeBefore.isText && nodeBefore.text.endsWith(strings.ZERO_WIDTH_CHAR)) {
startPos -= 1;
}
if (nodeAfter && nodeAfter.isText && nodeAfter.text.startsWith(strings.ZERO_WIDTH_CHAR)) {
endPos += 1;
}
if (startPos !== selection.from || endPos !== selection.to) {
let tr = state.tr.delete(startPos, endPos);
dispatch(tr);
event.preventDefault();
return true;
}
}
// 光标在 inputSlot 的内部
if (node.type.name === 'inputSlot') {
// 处理当显示 placeholder 时候,按键的光标移动,保证通过一次按键,光标就跳出节点
// When the placeholder is displayed, the cursor of the button moves to ensure that the cursor
// jumps out of the node after pressing the button once.
const slotVisibleText = node.textContent.replace(new RegExp(strings.ZERO_WIDTH_CHAR, 'g'), '');
if (slotVisibleText.length === 0 && node.textContent.length > 0 &&
(event.key === 'ArrowLeft' || event.key === 'ArrowRight')
) {
// 如果光标在节点内,按左右键时直接跳出节点
// If the cursor is within a node, press the left and right keys to jump out of the node directly.
const pos = event.key === 'ArrowLeft' ? $from.before() : $from.after();
// 拿到光标的选区位置
if (selection.from - pos !== 1 && selection.from - pos !== -1) {
dispatch(state.tr.setSelection(TextSelection.create(state.doc, pos)));
event.preventDefault();
return true;
}
}
// 删除 input-slot 的最后一个可见字符时,将整个内容替换为零宽字符
// When removing the last visible character of input-slot, replace entire content with zero-width character
if (event.key === 'Backspace' && selection.empty) {
const zeroWidthRegex = new RegExp(strings.ZERO_WIDTH_CHAR, 'g');
const visibleText = node.textContent.replace(zeroWidthRegex, '');
const onlyZeroWidth = node.textContent.length > 0 && visibleText.length === 0;
if ($from.pos === $from.end() && visibleText.length === 1) {
const tr = state.tr.insertText(strings.ZERO_WIDTH_CHAR, $from.start(), $from.end());
dispatch(tr);
event.preventDefault();
return true;
}
if (onlyZeroWidth) {
const pos = $from.before();
dispatch(state.tr.delete(pos, pos + node.nodeSize));
event.preventDefault();
return true;
}
}
// 全选 input-slot 节点内容时,点击删除,插入零宽字符
// When selecting all input-slot node content, insert zero-width characters
if (!selection.empty && $from.parent === node &&
selection.from === $from.start() && selection.to >= $from.end() &&
(event.key === 'Backspace')
) {
const tr = state.tr;
if (selection.to > $from.end()) {
tr.delete($from.end(), selection.to);
}
tr.insertText(strings.ZERO_WIDTH_CHAR, $from.start(), $from.end());
const pos = $from.start() + 1;
tr.setSelection(TextSelection.create(tr.doc, pos));
dispatch(tr);
event.preventDefault();
return true;
}
}
return false;
},
},
});
}
export function handlePasteLogic(view: EditorView, event: ClipboardEvent) {
const { state, dispatch } = view;
const $from = state.selection.$from;
let tr = state.tr;
const parentNode = $from.parent;
if (parentNode.type.name === 'inputSlot') {
return specialPasteLogicForInputSlot(event, $from, tr, dispatch, state.selection);
}
removeZeroWidthChar($from, tr);
tr.setMeta(strings.DELETABLE, true);
dispatch(tr);
return false;
}
/**
* specialPasteLogicForInputSlot 处理两种情况
* 1. 如果是 parentNode 是 input-slot,当里面内容仅为零宽字符时候
* 直接去掉零宽字符会导致 input-slot 消失,
* 因此将此行为处理成获取文字部分,将零宽字符替换为文字内容
* 2. 如果 parentNode 是 input-slot,选中 input-slot 中的所有文字并粘贴
* 会导致 input-slot 被删除,因此需要处理成使用复制内容替换原有内容的
* The `specialPasteLogicForInputSlot` function handles two cases.
* 1. If the parentNode is input-slot, and its content consists of only zero-width characters...
* Removing zero-width characters directly will cause the input slot to disappear.
* Therefore, this behavior is processed by retrieving the text portion and replacing the zero-width characters with the text content.
* 2. If the parentNode is input-slot, select all the text in the input-slot and paste it.
* This will cause the input slot to be deleted, so it needs to be handled by replacing the original content with copied content.
*/
export function specialPasteLogicForInputSlot(event: ClipboardEvent, $from: any, tr: Transaction, dispatch: (tr: Transaction) => void, selection: Selection) {
const parentNode = $from.parent;
const nodeText = parentNode.textContent;
const isOnlyZeroWidth = nodeText && nodeText === strings.ZERO_WIDTH_CHAR;
const isAllTextSelected = !selection.empty &&
selection.from === $from.start() && selection.to === $from.end();
if (isOnlyZeroWidth || isAllTextSelected) {
const pastedText = event.clipboardData?.getData('text/plain') || '';
if (pastedText) {
const pos = $from.start();
tr = tr.insertText(pastedText, pos, pos + nodeText.length);
const endPos = pos + pastedText.length;
tr = tr.setSelection(TextSelection.create(tr.doc, endPos));
tr.setMeta(strings.DELETABLE, true);
dispatch(tr);
event.preventDefault();
return true;
}
}
return false;
}
export function removeZeroWidthChar($from: any, tr: Transaction) {
// Handling zero-width characters before and after pasting
// Check the previous node of the cursor
if ($from.nodeBefore && $from.nodeBefore.isText && $from.nodeBefore.text === strings.ZERO_WIDTH_CHAR) {
tr = tr.delete($from.pos - $from.nodeBefore.nodeSize, $from.pos);
return true;
}
// Check the node after the cursor
if ($from.nodeAfter && $from.nodeAfter.isText && $from.nodeAfter.text === strings.ZERO_WIDTH_CHAR) {
tr = tr.delete($from.pos, $from.pos + $from.nodeAfter.nodeSize);
return true;
}
return false;
}
export function handleCompositionEndLogic(view: EditorView) {
const { state, dispatch } = view;
const $from = state.selection.$from;
const node = $from.node();
if (node.type.name === 'inputSlot') {
const text = node.textContent;
const zeroWidthRegex = new RegExp(strings.ZERO_WIDTH_CHAR, 'g');
const cleanText = text.replace(zeroWidthRegex, '');
if (cleanText.length > 0 && cleanText.length < text.length) {
const tr = state.tr;
tr.insertText(cleanText, $from.start(), $from.end());
dispatch(tr);
return;
}
}
const tr = state.tr;
if ($from.nodeBefore && $from.nodeBefore.isText) {
const text = $from.nodeBefore.text;
if (text?.startsWith(strings.ZERO_WIDTH_CHAR)) {
const removeStart = $from.pos - $from.nodeBefore.nodeSize;
const removeEnd = removeStart + 1;
tr.delete(removeStart, removeEnd);
dispatch(tr);
}
}
}
export function handleTextInputLogic(view: EditorView, from: number, to: number, text: string) {
const { state, dispatch } = view;
const $from = state.selection.$from;
let tr = state.tr;
let modified = removeZeroWidthChar($from, tr);
// Remove zero-width characters before inserting text
if (modified) {
tr = tr.insertText(text, tr.selection.from, tr.selection.to);
dispatch(tr);
return true; // prevent default
}
return false; // continue default behavior
}