plugins.ts 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514
  1. import { EditorState, Plugin, PluginKey, TextSelection, Transaction } from 'prosemirror-state';
  2. import { strings } from '@douyinfe/semi-foundation/aiChatInput/constants';
  3. import { EditorView } from '@tiptap/pm/view';
  4. /**
  5. * @param newState
  6. * @returns
  7. * handleZeroWidthCharLogic 用于插入零宽字符或者删除多余的零宽字符
  8. * 为什么需要插入零宽字符?
  9. * 1. 保证自定义节点前后的光标高度正常,光标高度和内容相关,解决自定义节点是最后一个节点,
  10. * 光标高度会和自定义节点占据高度一致,和文本中光标高度不一致的问题
  11. * 2. 保证对于可编辑的 inline 节点(比如 input-slot),为最后一个节点时候,光标可以聚焦到该节点后
  12. * Why do we need to insert zero-width characters?
  13. * 1. Ensure that the cursor height before and after the custom node is normal.
  14. * The cursor height is related to the content. Solve the problem that when the custom node is the last node,
  15. * the cursor height will be consistent with the height occupied by the custom node, and inconsistent with the cursor height in the text.
  16. * 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.
  17. */
  18. export function handleZeroWidthCharLogic(newState: EditorState) {
  19. let todoPositions = [];
  20. let { tr } = newState;
  21. newState.doc.descendants((node, pos, parent) => {
  22. if (node.type.name === 'paragraph' && node.childCount > 0) {
  23. const { lastChild, firstChild } = node;
  24. if (firstChild && firstChild.attrs.isCustomSlot) {
  25. // 如果第一个 child 是自定义节点,应该在自定义节点前添加零宽字符
  26. // If the first child is a custom node, a zero-width character should be added before the custom node.
  27. // 保证光标可以移动到第一个自定义节点前
  28. // Ensure that the cursor can move to the first custom node before.
  29. todoPositions.push(pos + 1);
  30. }
  31. if (lastChild && lastChild.attrs.isCustomSlot) {
  32. // 在段落末尾插入一个零宽字符, 避免当自定义节点是段落最后一个节点时候,光标无法移出
  33. // Insert a zero-width character at the end of the paragraph to prevent
  34. // the cursor from being unable to move out when custom node is the last node of the paragraph.
  35. const paragraphEndPos = pos + node.nodeSize - 1;
  36. const prevChar = tr.doc.textBetween(paragraphEndPos - 1, paragraphEndPos, '', '');
  37. if (prevChar !== strings.ZERO_WIDTH_CHAR) {
  38. todoPositions.push(paragraphEndPos);
  39. }
  40. }
  41. if (lastChild === firstChild && lastChild.isText && lastChild.text === strings.ZERO_WIDTH_CHAR) {
  42. todoPositions.push(['remove', pos + 1]);
  43. }
  44. }
  45. // 保证在 undo/通过 set 修改 content 时候,没有内容的 inputSlot 节点内部有零宽字符
  46. // Ensure that there are zero-width characters inside the inputSlot node without content when undoing/setting content
  47. // 保证 input-slot 节点可以正常显示
  48. // Ensure that the input-slot node can be displayed normally
  49. if (node.type.name === 'inputSlot' && node.content.size === 0) {
  50. todoPositions.push(pos + 1);
  51. }
  52. /**
  53. * 如果连续两个节点都是 custom slot,则需要在两个节点中间插入零宽字符,用于保证
  54. * - 对于 input-slot,光标可以移动到两个 input-slot 之间
  55. * - 对于其他的非输入类型的 custom-slot,光标高度正确
  56. */
  57. if (node.attrs.isCustomSlot) {
  58. let nodeIndex = -1;
  59. parent.forEach((child, offset, i) => {
  60. if (child === node) {
  61. nodeIndex = i;
  62. }
  63. });
  64. if (nodeIndex > -1 && nodeIndex < parent.childCount - 1) {
  65. const nextSibling = parent.child(nodeIndex + 1);
  66. if (nextSibling.attrs.isCustomSlot) {
  67. todoPositions.push(pos + node.nodeSize);
  68. }
  69. }
  70. }
  71. });
  72. if (todoPositions.length > 0) {
  73. // why sorting?
  74. // If you insert from the beginning, the newly inserted content will affect the position of the original record.
  75. todoPositions.sort((a, b) => {
  76. const aOrder = Array.isArray(a) ? a[1] : a;
  77. const bOrder = Array.isArray(b) ? b[1] : b;
  78. return bOrder - aOrder;
  79. }).forEach(insertPos => {
  80. if (Array.isArray(insertPos) && insertPos[0] === 'remove') {
  81. tr = tr.delete(insertPos[1], insertPos[1] + 1);
  82. } else {
  83. tr = tr.insertText(strings.ZERO_WIDTH_CHAR, insertPos, insertPos);
  84. }
  85. });
  86. return tr;
  87. }
  88. return null;
  89. }
  90. export function ensureTrailingText(schema: any) {
  91. return new Plugin({
  92. appendTransaction(transactions, oldState, newState) {
  93. // 只在内容发生变化时修正,防止选区丢失
  94. // Only correct when content changes to prevent loss of selections
  95. const docChanged = transactions.some(tr => tr.docChanged);
  96. if (!docChanged) return null;
  97. // if (transactions.some(tr => tr.getMeta(strings.DeleteAble))) {
  98. // // 此次 transaction 是主动删除 inputSlot,不补零宽字符
  99. // // This is an active deletion of inputSlot, do not add zero-width characters
  100. // return null;
  101. // }
  102. return handleZeroWidthCharLogic(newState);
  103. },
  104. });
  105. }
  106. export function keyDownHandlePlugin(schema: any) {
  107. return new Plugin({
  108. key: new PluginKey('prevent-empty-inline-node'),
  109. props: {
  110. handleKeyDown(view, event) {
  111. // console.log('handle key down plugin');
  112. const { state, dispatch } = view;
  113. const { selection } = state;
  114. const { $from, $to } = selection;
  115. const node = $from.node();
  116. if (event.key === 'ArrowLeft' && node.type.name !== 'inputSlot') {
  117. if ($from.nodeBefore && $from.nodeBefore.isText && $from.nodeBefore.text) {
  118. if ($from.nodeBefore.text === strings.ZERO_WIDTH_CHAR) {
  119. // 获取零宽字符前的节点
  120. // Get the node before the zero-width character
  121. const parent = $from.parent;
  122. const index = $from.index();
  123. if (index >= 2) {
  124. /**
  125. * 判断条件: 节点顺序为[···、customSlot、零宽字符、光标、····],按下 arrowLeft
  126. * - 如果 custom slot 为 input-slot, 则光标跳到 input-slot 的最后一个可聚焦位置
  127. * - 如果 custom slot 为其他不可编辑的 slot, 则光标调整到 custom slot 之前,注:不可编辑的节点大小为 1
  128. */
  129. const secondBeforeCursorNode = parent.child(index - 2);
  130. if (secondBeforeCursorNode.attrs.isCustomSlot) {
  131. // The end of the content in the inputSlot node
  132. const nextCursorPos = $from.pos - 2;
  133. dispatch(state.tr.setSelection(TextSelection.create(state.doc, nextCursorPos)));
  134. event.preventDefault();
  135. return true;
  136. }
  137. } else if (index === 1 && $from.pos !== 0) {
  138. /**
  139. * 判断条件: 节点顺序为[前一个 Paragraph、换行、零宽字符、光标、····],按下 arrowLeft
  140. * 结果: [前一个 Paragraph、光标、换行、零宽字符、 ····]
  141. */
  142. const nextCursorPos = $from.before() - 1;
  143. nextCursorPos > 0 && dispatch(state.tr.setSelection(TextSelection.create(state.doc, nextCursorPos)));
  144. event.preventDefault();
  145. return true;
  146. }
  147. } else if ($from.nodeBefore.text.endsWith(strings.ZERO_WIDTH_CHAR)) {
  148. // Backup,当零宽字符出现在 text 节点中
  149. const nextCursorPos = $from.pos - 2;
  150. dispatch(state.tr.setSelection(TextSelection.create(state.doc, nextCursorPos)));
  151. event.preventDefault();
  152. return true;
  153. }
  154. }
  155. }
  156. if (event.key === 'ArrowRight' && node.type.name !== 'inputSlot') {
  157. if ($from.nodeAfter && $from.nodeAfter.isText) {
  158. if ($from.nodeAfter.text === strings.ZERO_WIDTH_CHAR) {
  159. /**
  160. * 判断条件: 节点顺序为[···、光标、零宽字符、customSlot、····],按下 arrowRight
  161. * - 如果 custom slot 为 input-slot, 则光标跳到 input-slot 的第一个一个可聚焦位置
  162. * - 如果 custom slot 为其他不可编辑的 slot, 则光标调整到 custom slot 之后
  163. */
  164. // 获取零宽字符后的节点
  165. // Get the node before the zero-width character
  166. const parent = $from.parent;
  167. const index = $from.index();
  168. if (index < parent.children.length - 1) {
  169. const secondAfterCursorNode = parent.child(index + 1);
  170. if (secondAfterCursorNode.attrs.isCustomSlot) {
  171. // The starting position of the input-slot node
  172. const newPos = $from.pos + 2;
  173. dispatch(state.tr.setSelection(TextSelection.create(state.doc, newPos)));
  174. event.preventDefault();
  175. return true;
  176. }
  177. } else if (index === parent.children.length - 1 && state.doc.lastChild !== node ) {
  178. /**
  179. * 判断条件: 节点顺序为[···光标、零宽字符、换行、下一个 paragraphph···],按下 arrowLeft
  180. * 结果: [···零宽字符、换行、光标、下一个 paragraphph···]
  181. */
  182. const nextCursorPos = $from.after() + 1;
  183. dispatch(state.tr.setSelection(TextSelection.create(state.doc, nextCursorPos)));
  184. event.preventDefault();
  185. return true;
  186. }
  187. } else if ($from.nodeBefore && $from.nodeBefore.isText && $from.nodeBefore.text.startsWith(strings.ZERO_WIDTH_CHAR)) {
  188. // Backup,当零宽字符出现在 text 节点中
  189. const nextCursorPos = $from.pos + 2;
  190. dispatch(state.tr.setSelection(TextSelection.create(state.doc, nextCursorPos)));
  191. event.preventDefault();
  192. return true;
  193. }
  194. }
  195. }
  196. if (event.key === 'Backspace' && selection.empty) {
  197. const beforeNode = $from.nodeBefore;
  198. const afterNode = $from.nodeAfter;
  199. /**
  200. * [长度为 1 的普通文本、光标、 customSlot] ---按下删除按键--->[光标、customSlot]
  201. * 专用于处理 custom slot 前为一个文本节点,且文本节点中长度为1时候,文本删除不掉的情况
  202. */
  203. if (
  204. $from.nodeBefore && $from.nodeBefore.isText &&
  205. $from.nodeBefore.text?.length === 1 && $from.nodeBefore.text !== strings.ZERO_WIDTH_CHAR &&
  206. $from.nodeAfter && $from.nodeAfter.attrs.isCustomSlot
  207. ) {
  208. const begin = $from.pos - $from.nodeBefore.nodeSize;
  209. const end = $from.pos;
  210. let tr = state.tr.delete(begin, end);
  211. tr = tr.insertText(strings.ZERO_WIDTH_CHAR, begin, begin);
  212. dispatch(tr);
  213. event.preventDefault();
  214. return true;
  215. }
  216. // 顺序为[···、零宽字符(可能)、customSlot、光标、零宽字符(可能)、 ····] -> [···、光标、····]
  217. if (beforeNode && beforeNode.attrs.isCustomSlot) {
  218. const parent = $from.parent;
  219. const index = $from.index(); // 当前光标在 parent.children 中的 offset
  220. const initalStart = $from.pos - beforeNode.nodeSize;
  221. const intialEnd = $from.pos;
  222. let deleteStart = initalStart;
  223. let deleteEnd = intialEnd;
  224. if (index > 1) {
  225. const prevPrevNode = parent.child(index - 2);
  226. if (prevPrevNode && prevPrevNode.isText && prevPrevNode.text.endsWith(strings.ZERO_WIDTH_CHAR)) {
  227. deleteStart = deleteStart - 1;
  228. }
  229. }
  230. if (afterNode.isText && afterNode.text.startsWith(strings.ZERO_WIDTH_CHAR)) {
  231. deleteEnd = deleteEnd + 1;
  232. }
  233. if (deleteStart !== initalStart || deleteEnd !== intialEnd) {
  234. const tr = state.tr.delete(deleteStart, deleteEnd);
  235. dispatch(tr);
  236. event.preventDefault();
  237. return true;
  238. }
  239. }
  240. if (afterNode && afterNode.isText && afterNode.text === strings.ZERO_WIDTH_CHAR) {
  241. const index = $from.index(); // 当前光标在 parent.children 中的 offset
  242. if (index === 0 && $from.pos !== 1) {
  243. /**
  244. * 判断条件: 节点顺序为[····、前一个 Paragraph、换行、光标、零宽字符、····],按下 delete
  245. * 结果: [前一个 Paragraph、光标 ····]
  246. */
  247. const startPos = selection.from - 2;
  248. const tr = state.tr.delete(startPos, selection.to + 1);
  249. dispatch(tr);
  250. event.preventDefault();
  251. return true;
  252. }
  253. }
  254. if (beforeNode && beforeNode.isText && beforeNode.text === strings.ZERO_WIDTH_CHAR) {
  255. const parent = $from.parent;
  256. const index = $from.index(); // 当前光标在 parent.children 中的 offset
  257. if (index > 1) {
  258. /** 判断条件: 节点顺序为[···、customSlot、零宽字符、光标、····] 按下 Backspace
  259. * 结果: 节点顺序为[···、光标、····]
  260. */
  261. const prevPrevNode = parent.child(index - 2);
  262. if (prevPrevNode.attrs.isCustomSlot) {
  263. const deleteStart = $from.pos - beforeNode.nodeSize - prevPrevNode.nodeSize;
  264. const tr = state.tr.delete(deleteStart, $from.pos);
  265. dispatch(tr);
  266. event.preventDefault();
  267. return true;
  268. }
  269. // prevPrevNode 就是你想要的光标前一个节点的前一个节点
  270. } else if (index === 1 && node.type.name !== 'inputSlot') {
  271. /**
  272. * 判断条件:节点顺序 [···、上一个paragraph、换行、零宽字符、光标、customSlot、····], 按下 Backspace
  273. * 结果:[···、原来的上一个paragraph、光标、customSlot、····]
  274. */
  275. if ($from.pos !== 1) {
  276. const startPos = selection.from - 1 - 2;
  277. const tr = state.tr.delete(startPos, selection.to);
  278. dispatch(tr);
  279. event.preventDefault();
  280. return true;
  281. }
  282. }
  283. } else {
  284. /**
  285. * 判断条件:节点顺序为[···、inputSlot、····], 光标在 inputSlot 的首位,按下 backSpace
  286. * 结论:1. 如果 inputSlot 前面是零宽字符,则直接将光标移动到零宽字符之前
  287. * 2. 如果前面不是零宽字符,则在 inputSlot 前面添加零宽字符,并将光标移动到零宽字符前
  288. * 用于解决光标在 inputSlot 前,按下 backSpace,出现 inputSlot 前的内容被删除问题
  289. */
  290. if (node.type.name === 'inputSlot' && $from.pos === $from.start()) {
  291. // 1. 如果前面是零宽字符,则直接将光标移动到零宽字符之前
  292. const grandParent = $from.node($from.depth - 1);
  293. let parentPrevNode = null;
  294. const parentIndex = $from.index($from.depth - 1);
  295. if (parentIndex > 0) {
  296. parentPrevNode = grandParent.child(parentIndex - 1);
  297. if (parentPrevNode && parentPrevNode.isText && parentPrevNode.text.endsWith(strings.ZERO_WIDTH_CHAR)) {
  298. const pos = $from.pos - 2;
  299. dispatch(state.tr.setSelection(TextSelection.create(state.doc, pos)));
  300. event.preventDefault();
  301. return true;
  302. }
  303. }
  304. // 2. 如果前面不是零宽字符,则插入一个零宽字符,并将光标移动到零宽字符之前
  305. const pos = $from.pos - 1;
  306. let tr = state.tr.insertText(strings.ZERO_WIDTH_CHAR, pos, pos + 1);
  307. tr = tr.setSelection(TextSelection.create(tr.doc, pos));
  308. dispatch(tr);
  309. event.preventDefault();
  310. return true;
  311. }
  312. }
  313. }
  314. if (event.key === 'Backspace' && !selection.empty) {
  315. let startPos = selection.from;
  316. let endPos = selection.to;
  317. const nodeBefore = $from.nodeBefore;
  318. const nodeAfter = $from.nodeAfter;
  319. if (nodeBefore && nodeBefore.isText && nodeBefore.text.endsWith(strings.ZERO_WIDTH_CHAR)) {
  320. startPos -= 1;
  321. }
  322. if (nodeAfter && nodeAfter.isText && nodeAfter.text.startsWith(strings.ZERO_WIDTH_CHAR)) {
  323. endPos += 1;
  324. }
  325. if (startPos !== selection.from || endPos !== selection.to) {
  326. let tr = state.tr.delete(startPos, endPos);
  327. dispatch(tr);
  328. event.preventDefault();
  329. return true;
  330. }
  331. }
  332. // 光标在 inputSlot 的内部
  333. if (node.type.name === 'inputSlot') {
  334. // 处理当显示 placeholder 时候,按键的光标移动,保证通过一次按键,光标就跳出节点
  335. // When the placeholder is displayed, the cursor of the button moves to ensure that the cursor
  336. // jumps out of the node after pressing the button once.
  337. if (node.textContent === strings.ZERO_WIDTH_CHAR &&
  338. (event.key === 'ArrowLeft' || event.key === 'ArrowRight')
  339. ) {
  340. // 如果光标在节点内,按左右键时直接跳出节点
  341. // If the cursor is within a node, press the left and right keys to jump out of the node directly.
  342. const pos = event.key === 'ArrowLeft' ? $from.before() : $from.after();
  343. // 拿到光标的选区位置
  344. if (selection.from - pos !== 1 && selection.from - pos !== -1) {
  345. dispatch(state.tr.setSelection(TextSelection.create(state.doc, pos)));
  346. event.preventDefault();
  347. return true;
  348. }
  349. }
  350. // 删除 input-slot 的最后一个字符时,插入零宽字符
  351. // When removing the last character of input-slot, insert a zero-width character
  352. if ($from.pos === $from.end() && node.textContent.length === 1 && node.textContent !== strings.ZERO_WIDTH_CHAR &&
  353. event.key === 'Backspace'
  354. ) {
  355. const pos = $from.pos - 1;
  356. dispatch(state.tr.insertText(strings.ZERO_WIDTH_CHAR, pos, pos + 1));
  357. event.preventDefault();
  358. return true;
  359. }
  360. // 全选 input-slot 节点内容时,点击删除,插入零宽字符
  361. // When selecting all input-slot node content, insert zero-width characters
  362. if (!selection.empty && $from.parent === node &&
  363. selection.from === $from.start() && selection.to >= $from.end() &&
  364. (event.key === 'Backspace')
  365. ) {
  366. const tr = state.tr;
  367. // 删除 inputSlot 之后被选中的内容
  368. // Delete the selected content after inputSlot
  369. if (selection.to > $from.end()) {
  370. tr.delete($from.end(), selection.to);
  371. }
  372. // 替换 inputSlot 内部内容为 ZERO_WIDTH_CHAR
  373. // Replace the internal content of inputSlot with ZERO_WIDTH_CHAR
  374. tr.insertText(strings.ZERO_WIDTH_CHAR, $from.start(), $from.end());
  375. const pos = $from.start() + 1; // 1 是零宽字符的长度
  376. tr.setSelection(TextSelection.create(tr.doc, pos));
  377. dispatch(tr);
  378. event.preventDefault();
  379. return true;
  380. }
  381. // 如果内容只剩零宽字符,再次删除时允许节点被删
  382. // If only zero-width characters remain in the content, allow the node to be deleted when deleting again.
  383. if (node.textContent === strings.ZERO_WIDTH_CHAR &&
  384. (event.key === 'Backspace')
  385. ) {
  386. // 计算当前节点在文档中的位置
  387. // Calculate the position of the current node in the document
  388. const pos = $from.before();
  389. dispatch(state.tr.delete(pos, pos + node.nodeSize));
  390. event.preventDefault();
  391. return true;
  392. }
  393. }
  394. return false;
  395. },
  396. },
  397. });
  398. }
  399. export function handlePasteLogic(view: EditorView, event: ClipboardEvent) {
  400. // If there is rich text content, let tiptap handle it by default
  401. const types = event.clipboardData?.types || [];
  402. const html = event.clipboardData?.getData('text/html');
  403. // 如果包含 html 内容,并且 html 内容中包含 input-slot, select-slot, skill-slot 节点,则不阻断
  404. // todo:增加用户扩展 slot 的判断
  405. if ((types.includes('text/html') && (['<input-slot', '<select-slot', '<skill-slot'].some(slot => html?.includes(slot))))
  406. || types.includes('application/x-prosemirror-slice')) {
  407. return false;
  408. }
  409. const text = event.clipboardData?.getData('text/plain');
  410. if (text) {
  411. const { state, dispatch } = view;
  412. const $from = state.selection.$from;
  413. let tr = state.tr;
  414. removeZeroWidthChar($from, tr);
  415. /* Use tr to continue the subsequent pasting logic and solve the problem of unsuccessful line wrapping of content
  416. pasted from certain web pages, such as the code of Feishu Documents */
  417. const lines = text.split('\n');
  418. let finalCursorPos = null;
  419. if (lines.length === 1) {
  420. // Insert the first line directly
  421. tr = tr.insertText(lines[0], tr.selection.from, tr.selection.to);
  422. finalCursorPos = tr.selection.$to.pos;
  423. } else {
  424. // other lines, insert one by one
  425. tr = tr.insertText(lines[0], tr.selection.from, tr.selection.to);
  426. let pos = tr.selection.$to.pos;
  427. for (let i = 1; i < lines.length; i++) {
  428. const paragraph = state.schema.nodes.paragraph.create(
  429. {},
  430. lines[i] ? state.schema.text(lines[i]) : null
  431. );
  432. tr = tr.insert(pos, paragraph);
  433. pos += paragraph.nodeSize;
  434. }
  435. finalCursorPos = pos; // 粘贴多行时,光标应在最后插入内容末尾
  436. }
  437. // 设置 selection 到粘贴内容末尾
  438. tr = tr.setSelection(TextSelection.create(tr.doc, finalCursorPos));
  439. // scroll to the pasted position
  440. tr = tr.scrollIntoView();
  441. dispatch(tr);
  442. event.preventDefault();
  443. return true;
  444. }
  445. return false;
  446. }
  447. export function removeZeroWidthChar($from: any, tr: Transaction) {
  448. // Handling zero-width characters before and after pasting
  449. // Check the previous node of the cursor
  450. if ($from.nodeBefore && $from.nodeBefore.isText && $from.nodeBefore.text === strings.ZERO_WIDTH_CHAR) {
  451. tr = tr.delete($from.pos - $from.nodeBefore.nodeSize, $from.pos);
  452. return true;
  453. }
  454. // Check the node after the cursor
  455. if ($from.nodeAfter && $from.nodeAfter.isText && $from.nodeAfter.text === strings.ZERO_WIDTH_CHAR) {
  456. tr = tr.delete($from.pos, $from.pos + $from.nodeAfter.nodeSize);
  457. return true;
  458. }
  459. return false;
  460. }
  461. export function removeZeroWidthCharForComposition($from: any, tr: Transaction) {
  462. // 检查光标左侧的 text node 是否以零宽字符开头
  463. if ($from.nodeBefore && $from.nodeBefore.isText) {
  464. const text = $from.nodeBefore.text;
  465. if (text?.startsWith(strings.ZERO_WIDTH_CHAR)) {
  466. // 删除第一个字符
  467. const removeStart = $from.pos - $from.nodeBefore.nodeSize;
  468. const removeEnd = removeStart + 1; // 只删开头零宽字符
  469. tr = tr.delete(removeStart, removeEnd);
  470. return tr;
  471. }
  472. }
  473. // 或者再补 $from.nodeAfter 的情况(一般只需要 nodeBefore)
  474. return null;
  475. }
  476. export function handleCompositionEndLogic(view: EditorView) { // composition 结束时再移除零宽字符
  477. const { state, dispatch } = view;
  478. const $from = state.selection.$from;
  479. let tr = state.tr;
  480. let modified = removeZeroWidthCharForComposition($from, tr);
  481. if (modified) {
  482. dispatch(tr);
  483. }
  484. }
  485. export function handleTextInputLogic(view: EditorView, from: number, to: number, text: string) {
  486. const { state, dispatch } = view;
  487. const $from = state.selection.$from;
  488. let tr = state.tr;
  489. let modified = removeZeroWidthChar($from, tr);
  490. // Remove zero-width characters before inserting text
  491. if (modified) {
  492. tr = tr.insertText(text, tr.selection.from, tr.selection.to);
  493. dispatch(tr);
  494. return true; // prevent default
  495. }
  496. return false; // continue default behavior
  497. }