selection.ts 14 KB

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