79e731d1f9b075be9dbd975cad231d4ad8a95362da4add08933660035eb137779e92c084cef1ae0aa1b84675f0284706968a8e923171aacb28a7154ddeef07 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374
  1. import { LeafBlot, Scope } from 'parchment';
  2. import { cloneDeep, isEqual } from 'lodash-es';
  3. import Emitter from './emitter.js';
  4. import logger from './logger.js';
  5. const debug = logger('quill:selection');
  6. export class Range {
  7. constructor(index) {
  8. let length = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 0;
  9. this.index = index;
  10. this.length = length;
  11. }
  12. }
  13. class Selection {
  14. constructor(scroll, emitter) {
  15. this.emitter = emitter;
  16. this.scroll = scroll;
  17. this.composing = false;
  18. this.mouseDown = false;
  19. this.root = this.scroll.domNode;
  20. // @ts-expect-error
  21. this.cursor = this.scroll.create('cursor', this);
  22. // savedRange is last non-null range
  23. this.savedRange = new Range(0, 0);
  24. this.lastRange = this.savedRange;
  25. this.lastNative = null;
  26. this.handleComposition();
  27. this.handleDragging();
  28. this.emitter.listenDOM('selectionchange', document, () => {
  29. if (!this.mouseDown && !this.composing) {
  30. setTimeout(this.update.bind(this, Emitter.sources.USER), 1);
  31. }
  32. });
  33. this.emitter.on(Emitter.events.SCROLL_BEFORE_UPDATE, () => {
  34. if (!this.hasFocus()) return;
  35. const native = this.getNativeRange();
  36. if (native == null) return;
  37. if (native.start.node === this.cursor.textNode) return; // cursor.restore() will handle
  38. this.emitter.once(Emitter.events.SCROLL_UPDATE, (source, mutations) => {
  39. try {
  40. if (this.root.contains(native.start.node) && this.root.contains(native.end.node)) {
  41. this.setNativeRange(native.start.node, native.start.offset, native.end.node, native.end.offset);
  42. }
  43. const triggeredByTyping = mutations.some(mutation => mutation.type === 'characterData' || mutation.type === 'childList' || mutation.type === 'attributes' && mutation.target === this.root);
  44. this.update(triggeredByTyping ? Emitter.sources.SILENT : source);
  45. } catch (ignored) {
  46. // ignore
  47. }
  48. });
  49. });
  50. this.emitter.on(Emitter.events.SCROLL_OPTIMIZE, (mutations, context) => {
  51. if (context.range) {
  52. const {
  53. startNode,
  54. startOffset,
  55. endNode,
  56. endOffset
  57. } = context.range;
  58. this.setNativeRange(startNode, startOffset, endNode, endOffset);
  59. this.update(Emitter.sources.SILENT);
  60. }
  61. });
  62. this.update(Emitter.sources.SILENT);
  63. }
  64. handleComposition() {
  65. this.emitter.on(Emitter.events.COMPOSITION_BEFORE_START, () => {
  66. this.composing = true;
  67. });
  68. this.emitter.on(Emitter.events.COMPOSITION_END, () => {
  69. this.composing = false;
  70. if (this.cursor.parent) {
  71. const range = this.cursor.restore();
  72. if (!range) return;
  73. setTimeout(() => {
  74. this.setNativeRange(range.startNode, range.startOffset, range.endNode, range.endOffset);
  75. }, 1);
  76. }
  77. });
  78. }
  79. handleDragging() {
  80. this.emitter.listenDOM('mousedown', document.body, () => {
  81. this.mouseDown = true;
  82. });
  83. this.emitter.listenDOM('mouseup', document.body, () => {
  84. this.mouseDown = false;
  85. this.update(Emitter.sources.USER);
  86. });
  87. }
  88. focus() {
  89. if (this.hasFocus()) return;
  90. this.root.focus({
  91. preventScroll: true
  92. });
  93. this.setRange(this.savedRange);
  94. }
  95. format(format, value) {
  96. this.scroll.update();
  97. const nativeRange = this.getNativeRange();
  98. if (nativeRange == null || !nativeRange.native.collapsed || this.scroll.query(format, Scope.BLOCK)) return;
  99. if (nativeRange.start.node !== this.cursor.textNode) {
  100. const blot = this.scroll.find(nativeRange.start.node, false);
  101. if (blot == null) return;
  102. // TODO Give blot ability to not split
  103. if (blot instanceof LeafBlot) {
  104. const after = blot.split(nativeRange.start.offset);
  105. blot.parent.insertBefore(this.cursor, after);
  106. } else {
  107. // @ts-expect-error TODO: nativeRange.start.node doesn't seem to match function signature
  108. blot.insertBefore(this.cursor, nativeRange.start.node); // Should never happen
  109. }
  110. this.cursor.attach();
  111. }
  112. this.cursor.format(format, value);
  113. this.scroll.optimize();
  114. this.setNativeRange(this.cursor.textNode, this.cursor.textNode.data.length);
  115. this.update();
  116. }
  117. getBounds(index) {
  118. let length = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 0;
  119. const scrollLength = this.scroll.length();
  120. index = Math.min(index, scrollLength - 1);
  121. length = Math.min(index + length, scrollLength - 1) - index;
  122. let node;
  123. let [leaf, offset] = this.scroll.leaf(index);
  124. if (leaf == null) return null;
  125. if (length > 0 && offset === leaf.length()) {
  126. const [next] = this.scroll.leaf(index + 1);
  127. if (next) {
  128. const [line] = this.scroll.line(index);
  129. const [nextLine] = this.scroll.line(index + 1);
  130. if (line === nextLine) {
  131. leaf = next;
  132. offset = 0;
  133. }
  134. }
  135. }
  136. [node, offset] = leaf.position(offset, true);
  137. const range = document.createRange();
  138. if (length > 0) {
  139. range.setStart(node, offset);
  140. [leaf, offset] = this.scroll.leaf(index + length);
  141. if (leaf == null) return null;
  142. [node, offset] = leaf.position(offset, true);
  143. range.setEnd(node, offset);
  144. return range.getBoundingClientRect();
  145. }
  146. let side = 'left';
  147. let rect;
  148. if (node instanceof Text) {
  149. // Return null if the text node is empty because it is
  150. // not able to get a useful client rect:
  151. // https://github.com/w3c/csswg-drafts/issues/2514.
  152. // Empty text nodes are most likely caused by TextBlot#optimize()
  153. // not getting called when editor content changes.
  154. if (!node.data.length) {
  155. return null;
  156. }
  157. if (offset < node.data.length) {
  158. range.setStart(node, offset);
  159. range.setEnd(node, offset + 1);
  160. } else {
  161. range.setStart(node, offset - 1);
  162. range.setEnd(node, offset);
  163. side = 'right';
  164. }
  165. rect = range.getBoundingClientRect();
  166. } else {
  167. if (!(leaf.domNode instanceof Element)) return null;
  168. rect = leaf.domNode.getBoundingClientRect();
  169. if (offset > 0) side = 'right';
  170. }
  171. return {
  172. bottom: rect.top + rect.height,
  173. height: rect.height,
  174. left: rect[side],
  175. right: rect[side],
  176. top: rect.top,
  177. width: 0
  178. };
  179. }
  180. getNativeRange() {
  181. const selection = document.getSelection();
  182. if (selection == null || selection.rangeCount <= 0) return null;
  183. const nativeRange = selection.getRangeAt(0);
  184. if (nativeRange == null) return null;
  185. const range = this.normalizeNative(nativeRange);
  186. debug.info('getNativeRange', range);
  187. return range;
  188. }
  189. getRange() {
  190. const root = this.scroll.domNode;
  191. if ('isConnected' in root && !root.isConnected) {
  192. // document.getSelection() forces layout on Blink, so we trend to
  193. // not calling it.
  194. return [null, null];
  195. }
  196. const normalized = this.getNativeRange();
  197. if (normalized == null) return [null, null];
  198. const range = this.normalizedToRange(normalized);
  199. return [range, normalized];
  200. }
  201. hasFocus() {
  202. return document.activeElement === this.root || document.activeElement != null && contains(this.root, document.activeElement);
  203. }
  204. normalizedToRange(range) {
  205. const positions = [[range.start.node, range.start.offset]];
  206. if (!range.native.collapsed) {
  207. positions.push([range.end.node, range.end.offset]);
  208. }
  209. const indexes = positions.map(position => {
  210. const [node, offset] = position;
  211. const blot = this.scroll.find(node, true);
  212. // @ts-expect-error Fix me later
  213. const index = blot.offset(this.scroll);
  214. if (offset === 0) {
  215. return index;
  216. }
  217. if (blot instanceof LeafBlot) {
  218. return index + blot.index(node, offset);
  219. }
  220. // @ts-expect-error Fix me later
  221. return index + blot.length();
  222. });
  223. const end = Math.min(Math.max(...indexes), this.scroll.length() - 1);
  224. const start = Math.min(end, ...indexes);
  225. return new Range(start, end - start);
  226. }
  227. normalizeNative(nativeRange) {
  228. if (!contains(this.root, nativeRange.startContainer) || !nativeRange.collapsed && !contains(this.root, nativeRange.endContainer)) {
  229. return null;
  230. }
  231. const range = {
  232. start: {
  233. node: nativeRange.startContainer,
  234. offset: nativeRange.startOffset
  235. },
  236. end: {
  237. node: nativeRange.endContainer,
  238. offset: nativeRange.endOffset
  239. },
  240. native: nativeRange
  241. };
  242. [range.start, range.end].forEach(position => {
  243. let {
  244. node,
  245. offset
  246. } = position;
  247. while (!(node instanceof Text) && node.childNodes.length > 0) {
  248. if (node.childNodes.length > offset) {
  249. node = node.childNodes[offset];
  250. offset = 0;
  251. } else if (node.childNodes.length === offset) {
  252. // @ts-expect-error Fix me later
  253. node = node.lastChild;
  254. if (node instanceof Text) {
  255. offset = node.data.length;
  256. } else if (node.childNodes.length > 0) {
  257. // Container case
  258. offset = node.childNodes.length;
  259. } else {
  260. // Embed case
  261. offset = node.childNodes.length + 1;
  262. }
  263. } else {
  264. break;
  265. }
  266. }
  267. position.node = node;
  268. position.offset = offset;
  269. });
  270. return range;
  271. }
  272. rangeToNative(range) {
  273. const scrollLength = this.scroll.length();
  274. const getPosition = (index, inclusive) => {
  275. index = Math.min(scrollLength - 1, index);
  276. const [leaf, leafOffset] = this.scroll.leaf(index);
  277. return leaf ? leaf.position(leafOffset, inclusive) : [null, -1];
  278. };
  279. return [...getPosition(range.index, false), ...getPosition(range.index + range.length, true)];
  280. }
  281. setNativeRange(startNode, startOffset) {
  282. let endNode = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : startNode;
  283. let endOffset = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : startOffset;
  284. let force = arguments.length > 4 && arguments[4] !== undefined ? arguments[4] : false;
  285. debug.info('setNativeRange', startNode, startOffset, endNode, endOffset);
  286. if (startNode != null && (this.root.parentNode == null || startNode.parentNode == null ||
  287. // @ts-expect-error Fix me later
  288. endNode.parentNode == null)) {
  289. return;
  290. }
  291. const selection = document.getSelection();
  292. if (selection == null) return;
  293. if (startNode != null) {
  294. if (!this.hasFocus()) this.root.focus({
  295. preventScroll: true
  296. });
  297. const {
  298. native
  299. } = this.getNativeRange() || {};
  300. if (native == null || force || startNode !== native.startContainer || startOffset !== native.startOffset || endNode !== native.endContainer || endOffset !== native.endOffset) {
  301. if (startNode instanceof Element && startNode.tagName === 'BR') {
  302. // @ts-expect-error Fix me later
  303. startOffset = Array.from(startNode.parentNode.childNodes).indexOf(startNode);
  304. startNode = startNode.parentNode;
  305. }
  306. if (endNode instanceof Element && endNode.tagName === 'BR') {
  307. // @ts-expect-error Fix me later
  308. endOffset = Array.from(endNode.parentNode.childNodes).indexOf(endNode);
  309. endNode = endNode.parentNode;
  310. }
  311. const range = document.createRange();
  312. // @ts-expect-error Fix me later
  313. range.setStart(startNode, startOffset);
  314. // @ts-expect-error Fix me later
  315. range.setEnd(endNode, endOffset);
  316. selection.removeAllRanges();
  317. selection.addRange(range);
  318. }
  319. } else {
  320. selection.removeAllRanges();
  321. this.root.blur();
  322. }
  323. }
  324. setRange(range) {
  325. let force = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : false;
  326. let source = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : Emitter.sources.API;
  327. if (typeof force === 'string') {
  328. source = force;
  329. force = false;
  330. }
  331. debug.info('setRange', range);
  332. if (range != null) {
  333. const args = this.rangeToNative(range);
  334. this.setNativeRange(...args, force);
  335. } else {
  336. this.setNativeRange(null);
  337. }
  338. this.update(source);
  339. }
  340. update() {
  341. let source = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : Emitter.sources.USER;
  342. const oldRange = this.lastRange;
  343. const [lastRange, nativeRange] = this.getRange();
  344. this.lastRange = lastRange;
  345. this.lastNative = nativeRange;
  346. if (this.lastRange != null) {
  347. this.savedRange = this.lastRange;
  348. }
  349. if (!isEqual(oldRange, this.lastRange)) {
  350. if (!this.composing && nativeRange != null && nativeRange.native.collapsed && nativeRange.start.node !== this.cursor.textNode) {
  351. const range = this.cursor.restore();
  352. if (range) {
  353. this.setNativeRange(range.startNode, range.startOffset, range.endNode, range.endOffset);
  354. }
  355. }
  356. const args = [Emitter.events.SELECTION_CHANGE, cloneDeep(this.lastRange), cloneDeep(oldRange), source];
  357. this.emitter.emit(Emitter.events.EDITOR_CHANGE, ...args);
  358. if (source !== Emitter.sources.SILENT) {
  359. this.emitter.emit(...args);
  360. }
  361. }
  362. }
  363. }
  364. function contains(parent, descendant) {
  365. try {
  366. // Firefox inserts inaccessible nodes around video elements
  367. descendant.parentNode; // eslint-disable-line @typescript-eslint/no-unused-expressions
  368. } catch (e) {
  369. return false;
  370. }
  371. return parent.contains(descendant);
  372. }
  373. export default Selection;
  374. //# sourceMappingURL=selection.js.map