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.

582 lines (581 loc) 28.2 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.ensureTrailingText = ensureTrailingText; exports.handleCompositionEndLogic = handleCompositionEndLogic; exports.handlePasteLogic = handlePasteLogic; exports.handleTextInputLogic = handleTextInputLogic; exports.handleZeroWidthCharLogic = handleZeroWidthCharLogic; exports.keyDownHandlePlugin = keyDownHandlePlugin; exports.removeZeroWidthChar = removeZeroWidthChar; exports.specialPasteLogicForInputSlot = specialPasteLogicForInputSlot; var _prosemirrorState = require("prosemirror-state"); var _constants = require("@douyinfe/semi-foundation/lib/cjs/aiChatInput/constants"); /** * @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. */ function handleZeroWidthCharLogic(newState) { 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 !== _constants.strings.ZERO_WIDTH_CHAR) { todoPositions.push(paragraphEndPos); } } if (lastChild === firstChild && lastChild.isText && lastChild.text === _constants.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(_constants.strings.ZERO_WIDTH_CHAR, insertPos, insertPos); } }); return tr; } return null; } /** * 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) { return (event === null || event === void 0 ? void 0 : event.type) === 'keydown' && (event === null || event === void 0 ? void 0 : event.key) === 'Backspace' && (event === null || event === void 0 ? void 0 : 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) { const patchedView = view; if (patchedView._semiSyntheticBackspaceGuard) { return; } const origSomeProp = view.someProp.bind(view); patchedView._semiSyntheticBackspaceGuard = true; patchedView._semiOrigSomeProp = origSomeProp; view.someProp = function (propName, f) { if (propName !== 'handleKeyDown' || typeof f !== 'function') { return origSomeProp(propName, f); } return origSomeProp(propName, prop => { if (typeof prop !== 'function') { return f(prop); } return f((innerView, event) => { // 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); }); }); }; } /** * Restore the original `someProp` implementation when the plugin view unmounts. */ function removeSyntheticBackspaceGuard(view) { const patchedView = view; if (!patchedView._semiSyntheticBackspaceGuard || !patchedView._semiOrigSomeProp) { return; } view.someProp = patchedView._semiOrigSomeProp; delete patchedView._semiOrigSomeProp; delete patchedView._semiSyntheticBackspaceGuard; } function ensureTrailingText(schema) { return new _prosemirrorState.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(_constants.strings.DELETABLE))) { return null; } const docChanged = transactions.some(tr => tr.docChanged); if (!docChanged) return null; return handleZeroWidthCharLogic(newState); } }); } function keyDownHandlePlugin(schema) { return new _prosemirrorState.Plugin({ key: new _prosemirrorState.PluginKey('prevent-empty-inline-node'), props: { handleKeyDown(view, event) { var _a; 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 === _constants.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(_prosemirrorState.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(_prosemirrorState.TextSelection.create(state.doc, nextCursorPos))); event.preventDefault(); return true; } } else if ($from.nodeBefore.text.endsWith(_constants.strings.ZERO_WIDTH_CHAR)) { // Backup,当零宽字符出现在 text 节点中 const nextCursorPos = $from.pos - 2; dispatch(state.tr.setSelection(_prosemirrorState.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 === _constants.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(_prosemirrorState.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(_prosemirrorState.TextSelection.create(state.doc, nextCursorPos))); event.preventDefault(); return true; } } else if ($from.nodeBefore && $from.nodeBefore.isText && $from.nodeBefore.text.startsWith(_constants.strings.ZERO_WIDTH_CHAR)) { // Backup,当零宽字符出现在 text 节点中 const nextCursorPos = $from.pos + 2; dispatch(state.tr.setSelection(_prosemirrorState.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 && ((_a = $from.nodeBefore.text) === null || _a === void 0 ? void 0 : _a.length) === 1 && $from.nodeBefore.text !== _constants.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(_constants.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(_constants.strings.ZERO_WIDTH_CHAR)) { deleteStart = deleteStart - 1; } } if (afterNode.isText && afterNode.text.startsWith(_constants.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 === _constants.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 === _constants.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(_constants.strings.ZERO_WIDTH_CHAR)) { const pos = $from.pos - 2; dispatch(state.tr.setSelection(_prosemirrorState.TextSelection.create(state.doc, pos))); event.preventDefault(); return true; } } // 2. 如果前面不是零宽字符,则插入一个零宽字符,并将光标移动到零宽字符之前 const pos = $from.pos - 1; let tr = state.tr.insertText(_constants.strings.ZERO_WIDTH_CHAR, pos, pos + 1); tr = tr.setSelection(_prosemirrorState.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(_constants.strings.ZERO_WIDTH_CHAR)) { startPos -= 1; } if (nodeAfter && nodeAfter.isText && nodeAfter.text.startsWith(_constants.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(_constants.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(_prosemirrorState.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(_constants.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(_constants.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(_constants.strings.ZERO_WIDTH_CHAR, $from.start(), $from.end()); const pos = $from.start() + 1; tr.setSelection(_prosemirrorState.TextSelection.create(tr.doc, pos)); dispatch(tr); event.preventDefault(); return true; } } return false; } } }); } function handlePasteLogic(view, event) { 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(_constants.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. */ function specialPasteLogicForInputSlot(event, $from, tr, dispatch, selection) { var _a; const parentNode = $from.parent; const nodeText = parentNode.textContent; const isOnlyZeroWidth = nodeText && nodeText === _constants.strings.ZERO_WIDTH_CHAR; const isAllTextSelected = !selection.empty && selection.from === $from.start() && selection.to === $from.end(); if (isOnlyZeroWidth || isAllTextSelected) { const pastedText = ((_a = event.clipboardData) === null || _a === void 0 ? void 0 : _a.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(_prosemirrorState.TextSelection.create(tr.doc, endPos)); tr.setMeta(_constants.strings.DELETABLE, true); dispatch(tr); event.preventDefault(); return true; } } return false; } function removeZeroWidthChar($from, tr) { // Handling zero-width characters before and after pasting // Check the previous node of the cursor if ($from.nodeBefore && $from.nodeBefore.isText && $from.nodeBefore.text === _constants.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 === _constants.strings.ZERO_WIDTH_CHAR) { tr = tr.delete($from.pos, $from.pos + $from.nodeAfter.nodeSize); return true; } return false; } function handleCompositionEndLogic(view) { 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(_constants.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 === null || text === void 0 ? void 0 : text.startsWith(_constants.strings.ZERO_WIDTH_CHAR)) { const removeStart = $from.pos - $from.nodeBefore.nodeSize; const removeEnd = removeStart + 1; tr.delete(removeStart, removeEnd); dispatch(tr); } } } function handleTextInputLogic(view, from, to, text) { 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 }