| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514 |
- import { EditorState, Plugin, PluginKey, TextSelection, Transaction } 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;
- }
- export function ensureTrailingText(schema: any) {
- return new Plugin({
- appendTransaction(transactions, oldState, newState) {
- // 只在内容发生变化时修正,防止选区丢失
- // Only correct when content changes to prevent loss of selections
- const docChanged = transactions.some(tr => tr.docChanged);
- if (!docChanged) return null;
- // if (transactions.some(tr => tr.getMeta(strings.DeleteAble))) {
- // // 此次 transaction 是主动删除 inputSlot,不补零宽字符
- // // This is an active deletion of inputSlot, do not add zero-width characters
- // return null;
- // }
- return handleZeroWidthCharLogic(newState);
- },
- });
- }
- export function keyDownHandlePlugin(schema: any) {
- return new Plugin({
- key: new PluginKey('prevent-empty-inline-node'),
- props: {
- handleKeyDown(view, event) {
- // console.log('handle key down plugin');
- 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.
- if (node.textContent === strings.ZERO_WIDTH_CHAR &&
- (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 character of input-slot, insert a zero-width character
- if ($from.pos === $from.end() && node.textContent.length === 1 && node.textContent !== strings.ZERO_WIDTH_CHAR &&
- event.key === 'Backspace'
- ) {
- const pos = $from.pos - 1;
- dispatch(state.tr.insertText(strings.ZERO_WIDTH_CHAR, pos, pos + 1));
- 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;
- // 删除 inputSlot 之后被选中的内容
- // Delete the selected content after inputSlot
- if (selection.to > $from.end()) {
- tr.delete($from.end(), selection.to);
- }
- // 替换 inputSlot 内部内容为 ZERO_WIDTH_CHAR
- // Replace the internal content of inputSlot with ZERO_WIDTH_CHAR
- tr.insertText(strings.ZERO_WIDTH_CHAR, $from.start(), $from.end());
- const pos = $from.start() + 1; // 1 是零宽字符的长度
- tr.setSelection(TextSelection.create(tr.doc, pos));
- dispatch(tr);
- event.preventDefault();
- return true;
- }
- // 如果内容只剩零宽字符,再次删除时允许节点被删
- // If only zero-width characters remain in the content, allow the node to be deleted when deleting again.
- if (node.textContent === strings.ZERO_WIDTH_CHAR &&
- (event.key === 'Backspace')
- ) {
- // 计算当前节点在文档中的位置
- // Calculate the position of the current node in the document
- const pos = $from.before();
- dispatch(state.tr.delete(pos, pos + node.nodeSize));
- event.preventDefault();
- return true;
- }
- }
- return false;
- },
- },
- });
- }
- export function handlePasteLogic(view: EditorView, event: ClipboardEvent) {
- // If there is rich text content, let tiptap handle it by default
- const types = event.clipboardData?.types || [];
- const html = event.clipboardData?.getData('text/html');
- // 如果包含 html 内容,并且 html 内容中包含 input-slot, select-slot, skill-slot 节点,则不阻断
- // todo:增加用户扩展 slot 的判断
- if ((types.includes('text/html') && (['<input-slot', '<select-slot', '<skill-slot'].some(slot => html?.includes(slot))))
- || types.includes('application/x-prosemirror-slice')) {
- return false;
- }
- const text = event.clipboardData?.getData('text/plain');
- if (text) {
- const { state, dispatch } = view;
- const $from = state.selection.$from;
- let tr = state.tr;
- removeZeroWidthChar($from, tr);
- /* Use tr to continue the subsequent pasting logic and solve the problem of unsuccessful line wrapping of content
- pasted from certain web pages, such as the code of Feishu Documents */
- const lines = text.split('\n');
- let finalCursorPos = null;
- if (lines.length === 1) {
- // Insert the first line directly
- tr = tr.insertText(lines[0], tr.selection.from, tr.selection.to);
- finalCursorPos = tr.selection.$to.pos;
- } else {
- // other lines, insert one by one
- tr = tr.insertText(lines[0], tr.selection.from, tr.selection.to);
- let pos = tr.selection.$to.pos;
- for (let i = 1; i < lines.length; i++) {
- const paragraph = state.schema.nodes.paragraph.create(
- {},
- lines[i] ? state.schema.text(lines[i]) : null
- );
- tr = tr.insert(pos, paragraph);
- pos += paragraph.nodeSize;
- }
- finalCursorPos = pos; // 粘贴多行时,光标应在最后插入内容末尾
- }
- // 设置 selection 到粘贴内容末尾
- tr = tr.setSelection(TextSelection.create(tr.doc, finalCursorPos));
- // scroll to the pasted position
- tr = tr.scrollIntoView();
- 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 removeZeroWidthCharForComposition($from: any, tr: Transaction) {
- // 检查光标左侧的 text node 是否以零宽字符开头
- 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 = tr.delete(removeStart, removeEnd);
- return tr;
- }
- }
- // 或者再补 $from.nodeAfter 的情况(一般只需要 nodeBefore)
- return null;
- }
- export function handleCompositionEndLogic(view: EditorView) { // composition 结束时再移除零宽字符
- const { state, dispatch } = view;
- const $from = state.selection.$from;
- let tr = state.tr;
- let modified = removeZeroWidthCharForComposition($from, tr);
- if (modified) {
- 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
- }
|