76bb3dec8192503687ccc619ddf3b3af7cf208106cec8097c79a6ebcb12a87b7c1e6ba28f96aa80e00db22529f349e2498525297f3d955a8a2c78071eb9673 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170
  1. import { EmbedBlot, Scope } from 'parchment';
  2. import TextBlot from './text.js';
  3. class Cursor extends EmbedBlot {
  4. static blotName = 'cursor';
  5. static className = 'ql-cursor';
  6. static tagName = 'span';
  7. static CONTENTS = '\uFEFF'; // Zero width no break space
  8. static value() {
  9. return undefined;
  10. }
  11. constructor(scroll, domNode, selection) {
  12. super(scroll, domNode);
  13. this.selection = selection;
  14. this.textNode = document.createTextNode(Cursor.CONTENTS);
  15. this.domNode.appendChild(this.textNode);
  16. this.savedLength = 0;
  17. }
  18. detach() {
  19. // super.detach() will also clear domNode.__blot
  20. if (this.parent != null) this.parent.removeChild(this);
  21. }
  22. format(name, value) {
  23. if (this.savedLength !== 0) {
  24. super.format(name, value);
  25. return;
  26. }
  27. // TODO: Fix this next time the file is edited.
  28. // eslint-disable-next-line @typescript-eslint/no-this-alias
  29. let target = this;
  30. let index = 0;
  31. while (target != null && target.statics.scope !== Scope.BLOCK_BLOT) {
  32. index += target.offset(target.parent);
  33. target = target.parent;
  34. }
  35. if (target != null) {
  36. this.savedLength = Cursor.CONTENTS.length;
  37. // @ts-expect-error TODO: allow empty context in Parchment
  38. target.optimize();
  39. target.formatAt(index, Cursor.CONTENTS.length, name, value);
  40. this.savedLength = 0;
  41. }
  42. }
  43. index(node, offset) {
  44. if (node === this.textNode) return 0;
  45. return super.index(node, offset);
  46. }
  47. length() {
  48. return this.savedLength;
  49. }
  50. position() {
  51. return [this.textNode, this.textNode.data.length];
  52. }
  53. remove() {
  54. super.remove();
  55. // @ts-expect-error Fix me later
  56. this.parent = null;
  57. }
  58. restore() {
  59. if (this.selection.composing || this.parent == null) return null;
  60. const range = this.selection.getNativeRange();
  61. // Browser may push down styles/nodes inside the cursor blot.
  62. // https://dvcs.w3.org/hg/editing/raw-file/tip/editing.html#push-down-values
  63. while (this.domNode.lastChild != null && this.domNode.lastChild !== this.textNode) {
  64. // @ts-expect-error Fix me later
  65. this.domNode.parentNode.insertBefore(this.domNode.lastChild, this.domNode);
  66. }
  67. const prevTextBlot = this.prev instanceof TextBlot ? this.prev : null;
  68. const prevTextLength = prevTextBlot ? prevTextBlot.length() : 0;
  69. const nextTextBlot = this.next instanceof TextBlot ? this.next : null;
  70. // @ts-expect-error TODO: make TextBlot.text public
  71. const nextText = nextTextBlot ? nextTextBlot.text : '';
  72. const {
  73. textNode
  74. } = this;
  75. // take text from inside this blot and reset it
  76. const newText = textNode.data.split(Cursor.CONTENTS).join('');
  77. textNode.data = Cursor.CONTENTS;
  78. // proactively merge TextBlots around cursor so that optimization
  79. // doesn't lose the cursor. the reason we are here in cursor.restore
  80. // could be that the user clicked in prevTextBlot or nextTextBlot, or
  81. // the user typed something.
  82. let mergedTextBlot;
  83. if (prevTextBlot) {
  84. mergedTextBlot = prevTextBlot;
  85. if (newText || nextTextBlot) {
  86. prevTextBlot.insertAt(prevTextBlot.length(), newText + nextText);
  87. if (nextTextBlot) {
  88. nextTextBlot.remove();
  89. }
  90. }
  91. } else if (nextTextBlot) {
  92. mergedTextBlot = nextTextBlot;
  93. nextTextBlot.insertAt(0, newText);
  94. } else {
  95. const newTextNode = document.createTextNode(newText);
  96. mergedTextBlot = this.scroll.create(newTextNode);
  97. this.parent.insertBefore(mergedTextBlot, this);
  98. }
  99. this.remove();
  100. if (range) {
  101. // calculate selection to restore
  102. const remapOffset = (node, offset) => {
  103. if (prevTextBlot && node === prevTextBlot.domNode) {
  104. return offset;
  105. }
  106. if (node === textNode) {
  107. return prevTextLength + offset - 1;
  108. }
  109. if (nextTextBlot && node === nextTextBlot.domNode) {
  110. return prevTextLength + newText.length + offset;
  111. }
  112. return null;
  113. };
  114. const start = remapOffset(range.start.node, range.start.offset);
  115. const end = remapOffset(range.end.node, range.end.offset);
  116. if (start !== null && end !== null) {
  117. return {
  118. startNode: mergedTextBlot.domNode,
  119. startOffset: start,
  120. endNode: mergedTextBlot.domNode,
  121. endOffset: end
  122. };
  123. }
  124. }
  125. return null;
  126. }
  127. update(mutations, context) {
  128. if (mutations.some(mutation => {
  129. return mutation.type === 'characterData' && mutation.target === this.textNode;
  130. })) {
  131. const range = this.restore();
  132. if (range) context.range = range;
  133. }
  134. }
  135. // Avoid .ql-cursor being a descendant of `<a/>`.
  136. // The reason is Safari pushes down `<a/>` on text insertion.
  137. // That will cause DOM nodes not sync with the model.
  138. //
  139. // For example ({I} is the caret), given the markup:
  140. // <a><span class="ql-cursor">\uFEFF{I}</span></a>
  141. // When typing a char "x", `<a/>` will be pushed down inside the `<span>` first:
  142. // <span class="ql-cursor"><a>\uFEFF{I}</a></span>
  143. // And then "x" will be inserted after `<a/>`:
  144. // <span class="ql-cursor"><a>\uFEFF</a>d{I}</span>
  145. optimize(context) {
  146. // @ts-expect-error Fix me later
  147. super.optimize(context);
  148. let {
  149. parent
  150. } = this;
  151. while (parent) {
  152. if (parent.domNode.tagName === 'A') {
  153. this.savedLength = Cursor.CONTENTS.length;
  154. // @ts-expect-error TODO: make isolate generic
  155. parent.isolate(this.offset(parent), this.length()).unwrap();
  156. this.savedLength = 0;
  157. break;
  158. }
  159. parent = parent.parent;
  160. }
  161. }
  162. value() {
  163. return '';
  164. }
  165. }
  166. export default Cursor;
  167. //# sourceMappingURL=cursor.js.map