not really known
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

719 lines
22 KiB

  1. import clone from 'clone';
  2. import equal from 'deep-equal';
  3. import extend from 'extend';
  4. import Delta, { AttributeMap } from 'quill-delta';
  5. import { EmbedBlot, Scope, TextBlot } from 'parchment';
  6. import Quill from '../core/quill';
  7. import logger from '../core/logger';
  8. import Module from '../core/module';
  9. const debug = logger('quill:keyboard');
  10. const SHORTKEY = /Mac/i.test(navigator.platform) ? 'metaKey' : 'ctrlKey';
  11. class Keyboard extends Module {
  12. static match(evt, binding) {
  13. if (
  14. ['altKey', 'ctrlKey', 'metaKey', 'shiftKey'].some(key => {
  15. return !!binding[key] !== evt[key] && binding[key] !== null;
  16. })
  17. ) {
  18. return false;
  19. }
  20. return binding.key === evt.key || binding.key === evt.which;
  21. }
  22. constructor(quill, options) {
  23. super(quill, options);
  24. this.bindings = {};
  25. Object.keys(this.options.bindings).forEach(name => {
  26. if (this.options.bindings[name]) {
  27. this.addBinding(this.options.bindings[name]);
  28. }
  29. });
  30. this.addBinding({ key: 'Enter', shiftKey: null }, this.handleEnter);
  31. this.addBinding(
  32. { key: 'Enter', metaKey: null, ctrlKey: null, altKey: null },
  33. () => {},
  34. );
  35. if (/Firefox/i.test(navigator.userAgent)) {
  36. // Need to handle delete and backspace for Firefox in the general case #1171
  37. this.addBinding(
  38. { key: 'Backspace' },
  39. { collapsed: true },
  40. this.handleBackspace,
  41. );
  42. this.addBinding(
  43. { key: 'Delete' },
  44. { collapsed: true },
  45. this.handleDelete,
  46. );
  47. } else {
  48. this.addBinding(
  49. { key: 'Backspace' },
  50. { collapsed: true, prefix: /^.?$/ },
  51. this.handleBackspace,
  52. );
  53. this.addBinding(
  54. { key: 'Delete' },
  55. { collapsed: true, suffix: /^.?$/ },
  56. this.handleDelete,
  57. );
  58. }
  59. this.addBinding(
  60. { key: 'Backspace' },
  61. { collapsed: false },
  62. this.handleDeleteRange,
  63. );
  64. this.addBinding(
  65. { key: 'Delete' },
  66. { collapsed: false },
  67. this.handleDeleteRange,
  68. );
  69. this.addBinding(
  70. {
  71. key: 'Backspace',
  72. altKey: null,
  73. ctrlKey: null,
  74. metaKey: null,
  75. shiftKey: null,
  76. },
  77. { collapsed: true, offset: 0 },
  78. this.handleBackspace,
  79. );
  80. this.listen();
  81. }
  82. addBinding(keyBinding, context = {}, handler = {}) {
  83. const binding = normalize(keyBinding);
  84. if (binding == null) {
  85. debug.warn('Attempted to add invalid keyboard binding', binding);
  86. return;
  87. }
  88. if (typeof context === 'function') {
  89. context = { handler: context };
  90. }
  91. if (typeof handler === 'function') {
  92. handler = { handler };
  93. }
  94. const keys = Array.isArray(binding.key) ? binding.key : [binding.key];
  95. keys.forEach(key => {
  96. const singleBinding = extend({}, binding, { key }, context, handler);
  97. this.bindings[singleBinding.key] = this.bindings[singleBinding.key] || [];
  98. this.bindings[singleBinding.key].push(singleBinding);
  99. });
  100. }
  101. listen() {
  102. this.quill.root.addEventListener('keydown', evt => {
  103. if (evt.defaultPrevented) return;
  104. const bindings = (this.bindings[evt.key] || []).concat(
  105. this.bindings[evt.which] || [],
  106. );
  107. const matches = bindings.filter(binding => Keyboard.match(evt, binding));
  108. if (matches.length === 0) return;
  109. const range = this.quill.getSelection();
  110. if (range == null || !this.quill.hasFocus()) return;
  111. const [line, offset] = this.quill.getLine(range.index);
  112. const [leafStart, offsetStart] = this.quill.getLeaf(range.index);
  113. const [leafEnd, offsetEnd] =
  114. range.length === 0
  115. ? [leafStart, offsetStart]
  116. : this.quill.getLeaf(range.index + range.length);
  117. const prefixText =
  118. leafStart instanceof TextBlot
  119. ? leafStart.value().slice(0, offsetStart)
  120. : '';
  121. const suffixText =
  122. leafEnd instanceof TextBlot ? leafEnd.value().slice(offsetEnd) : '';
  123. const curContext = {
  124. collapsed: range.length === 0,
  125. empty: range.length === 0 && line.length() <= 1,
  126. format: this.quill.getFormat(range),
  127. line,
  128. offset,
  129. prefix: prefixText,
  130. suffix: suffixText,
  131. event: evt,
  132. };
  133. const prevented = matches.some(binding => {
  134. if (
  135. binding.collapsed != null &&
  136. binding.collapsed !== curContext.collapsed
  137. ) {
  138. return false;
  139. }
  140. if (binding.empty != null && binding.empty !== curContext.empty) {
  141. return false;
  142. }
  143. if (binding.offset != null && binding.offset !== curContext.offset) {
  144. return false;
  145. }
  146. if (Array.isArray(binding.format)) {
  147. // any format is present
  148. if (binding.format.every(name => curContext.format[name] == null)) {
  149. return false;
  150. }
  151. } else if (typeof binding.format === 'object') {
  152. // all formats must match
  153. if (
  154. !Object.keys(binding.format).every(name => {
  155. if (binding.format[name] === true)
  156. return curContext.format[name] != null;
  157. if (binding.format[name] === false)
  158. return curContext.format[name] == null;
  159. return equal(binding.format[name], curContext.format[name]);
  160. })
  161. ) {
  162. return false;
  163. }
  164. }
  165. if (binding.prefix != null && !binding.prefix.test(curContext.prefix)) {
  166. return false;
  167. }
  168. if (binding.suffix != null && !binding.suffix.test(curContext.suffix)) {
  169. return false;
  170. }
  171. return binding.handler.call(this, range, curContext, binding) !== true;
  172. });
  173. if (prevented) {
  174. evt.preventDefault();
  175. }
  176. });
  177. }
  178. handleBackspace(range, context) {
  179. // Check for astral symbols
  180. const length = /[\uD800-\uDBFF][\uDC00-\uDFFF]$/.test(context.prefix)
  181. ? 2
  182. : 1;
  183. if (range.index === 0 || this.quill.getLength() <= 1) return;
  184. let formats = {};
  185. const [line] = this.quill.getLine(range.index);
  186. let delta = new Delta().retain(range.index - length).delete(length);
  187. if (context.offset === 0) {
  188. // Always deleting newline here, length always 1
  189. const [prev] = this.quill.getLine(range.index - 1);
  190. if (prev) {
  191. const curFormats = line.formats();
  192. const prevFormats = this.quill.getFormat(range.index - 1, 1);
  193. formats = AttributeMap.diff(curFormats, prevFormats) || {};
  194. if (Object.keys(formats).length > 0) {
  195. // line.length() - 1 targets \n in line, another -1 for newline being deleted
  196. const formatDelta = new Delta()
  197. .retain(range.index + line.length() - 2)
  198. .retain(1, formats);
  199. delta = delta.compose(formatDelta);
  200. }
  201. }
  202. }
  203. this.quill.updateContents(delta, Quill.sources.USER);
  204. this.quill.focus();
  205. }
  206. handleDelete(range, context) {
  207. // Check for astral symbols
  208. const length = /^[\uD800-\uDBFF][\uDC00-\uDFFF]/.test(context.suffix)
  209. ? 2
  210. : 1;
  211. if (range.index >= this.quill.getLength() - length) return;
  212. let formats = {};
  213. const [line] = this.quill.getLine(range.index);
  214. let delta = new Delta().retain(range.index).delete(length);
  215. if (context.offset >= line.length() - 1) {
  216. const [next] = this.quill.getLine(range.index + 1);
  217. if (next) {
  218. const curFormats = line.formats();
  219. const nextFormats = this.quill.getFormat(range.index, 1);
  220. formats = AttributeMap.diff(curFormats, nextFormats) || {};
  221. if (Object.keys(formats).length > 0) {
  222. delta = delta.retain(next.length() - 1).retain(1, formats);
  223. }
  224. }
  225. }
  226. this.quill.updateContents(delta, Quill.sources.USER);
  227. this.quill.focus();
  228. }
  229. handleDeleteRange(range) {
  230. const lines = this.quill.getLines(range);
  231. let formats = {};
  232. if (lines.length > 1) {
  233. const firstFormats = lines[0].formats();
  234. const lastFormats = lines[lines.length - 1].formats();
  235. formats = AttributeMap.diff(lastFormats, firstFormats) || {};
  236. }
  237. this.quill.deleteText(range, Quill.sources.USER);
  238. if (Object.keys(formats).length > 0) {
  239. this.quill.formatLine(range.index, 1, formats, Quill.sources.USER);
  240. }
  241. this.quill.setSelection(range.index, Quill.sources.SILENT);
  242. this.quill.focus();
  243. }
  244. handleEnter(range, context) {
  245. const lineFormats = Object.keys(context.format).reduce(
  246. (formats, format) => {
  247. if (
  248. this.quill.scroll.query(format, Scope.BLOCK) &&
  249. !Array.isArray(context.format[format])
  250. ) {
  251. formats[format] = context.format[format];
  252. }
  253. return formats;
  254. },
  255. {},
  256. );
  257. const delta = new Delta()
  258. .retain(range.index)
  259. .delete(range.length)
  260. .insert('\n', lineFormats);
  261. this.quill.updateContents(delta, Quill.sources.USER);
  262. this.quill.setSelection(range.index + 1, Quill.sources.SILENT);
  263. this.quill.focus();
  264. Object.keys(context.format).forEach(name => {
  265. if (lineFormats[name] != null) return;
  266. if (Array.isArray(context.format[name])) return;
  267. if (name === 'code' || name === 'link') return;
  268. this.quill.format(name, context.format[name], Quill.sources.USER);
  269. });
  270. }
  271. }
  272. Keyboard.DEFAULTS = {
  273. bindings: {
  274. bold: makeFormatHandler('bold'),
  275. italic: makeFormatHandler('italic'),
  276. underline: makeFormatHandler('underline'),
  277. indent: {
  278. // highlight tab or tab at beginning of list, indent or blockquote
  279. key: 'Tab',
  280. format: ['blockquote', 'indent', 'list'],
  281. handler(range, context) {
  282. if (context.collapsed && context.offset !== 0) return true;
  283. this.quill.format('indent', '+1', Quill.sources.USER);
  284. return false;
  285. },
  286. },
  287. outdent: {
  288. key: 'Tab',
  289. shiftKey: true,
  290. format: ['blockquote', 'indent', 'list'],
  291. // highlight tab or tab at beginning of list, indent or blockquote
  292. handler(range, context) {
  293. if (context.collapsed && context.offset !== 0) return true;
  294. this.quill.format('indent', '-1', Quill.sources.USER);
  295. return false;
  296. },
  297. },
  298. 'outdent backspace': {
  299. key: 'Backspace',
  300. collapsed: true,
  301. shiftKey: null,
  302. metaKey: null,
  303. ctrlKey: null,
  304. altKey: null,
  305. format: ['indent', 'list'],
  306. offset: 0,
  307. handler(range, context) {
  308. if (context.format.indent != null) {
  309. this.quill.format('indent', '-1', Quill.sources.USER);
  310. } else if (context.format.list != null) {
  311. this.quill.format('list', false, Quill.sources.USER);
  312. }
  313. },
  314. },
  315. 'indent code-block': makeCodeBlockHandler(true),
  316. 'outdent code-block': makeCodeBlockHandler(false),
  317. 'remove tab': {
  318. key: 'Tab',
  319. shiftKey: true,
  320. collapsed: true,
  321. prefix: /\t$/,
  322. handler(range) {
  323. this.quill.deleteText(range.index - 1, 1, Quill.sources.USER);
  324. },
  325. },
  326. tab: {
  327. key: 'Tab',
  328. handler(range, context) {
  329. if (context.format.table) return true;
  330. this.quill.history.cutoff();
  331. const delta = new Delta()
  332. .retain(range.index)
  333. .delete(range.length)
  334. .insert('\t');
  335. this.quill.updateContents(delta, Quill.sources.USER);
  336. this.quill.history.cutoff();
  337. this.quill.setSelection(range.index + 1, Quill.sources.SILENT);
  338. return false;
  339. },
  340. },
  341. 'blockquote empty enter': {
  342. key: 'Enter',
  343. collapsed: true,
  344. format: ['blockquote'],
  345. empty: true,
  346. handler() {
  347. this.quill.format('blockquote', false, Quill.sources.USER);
  348. },
  349. },
  350. 'list empty enter': {
  351. key: 'Enter',
  352. collapsed: true,
  353. format: ['list'],
  354. empty: true,
  355. handler(range, context) {
  356. const formats = { list: false };
  357. if (context.format.indent) {
  358. formats.indent = false;
  359. }
  360. this.quill.formatLine(
  361. range.index,
  362. range.length,
  363. formats,
  364. Quill.sources.USER,
  365. );
  366. },
  367. },
  368. 'checklist enter': {
  369. key: 'Enter',
  370. collapsed: true,
  371. format: { list: 'checked' },
  372. handler(range) {
  373. const [line, offset] = this.quill.getLine(range.index);
  374. const formats = extend({}, line.formats(), { list: 'checked' });
  375. const delta = new Delta()
  376. .retain(range.index)
  377. .insert('\n', formats)
  378. .retain(line.length() - offset - 1)
  379. .retain(1, { list: 'unchecked' });
  380. this.quill.updateContents(delta, Quill.sources.USER);
  381. this.quill.setSelection(range.index + 1, Quill.sources.SILENT);
  382. this.quill.scrollIntoView();
  383. },
  384. },
  385. 'header enter': {
  386. key: 'Enter',
  387. collapsed: true,
  388. format: ['header'],
  389. suffix: /^$/,
  390. handler(range, context) {
  391. const [line, offset] = this.quill.getLine(range.index);
  392. const delta = new Delta()
  393. .retain(range.index)
  394. .insert('\n', context.format)
  395. .retain(line.length() - offset - 1)
  396. .retain(1, { header: null });
  397. this.quill.updateContents(delta, Quill.sources.USER);
  398. this.quill.setSelection(range.index + 1, Quill.sources.SILENT);
  399. this.quill.scrollIntoView();
  400. },
  401. },
  402. 'table backspace': {
  403. key: 'Backspace',
  404. format: ['table'],
  405. collapsed: true,
  406. offset: 0,
  407. handler() {},
  408. },
  409. 'table delete': {
  410. key: 'Delete',
  411. format: ['table'],
  412. collapsed: true,
  413. suffix: /^$/,
  414. handler() {},
  415. },
  416. 'table enter': {
  417. key: 'Enter',
  418. shiftKey: null,
  419. format: ['table'],
  420. handler(range) {
  421. const module = this.quill.getModule('table');
  422. if (module) {
  423. const [table, row, cell, offset] = module.getTable(range);
  424. const shift = tableSide(table, row, cell, offset);
  425. if (shift == null) return;
  426. let index = table.offset();
  427. if (shift < 0) {
  428. const delta = new Delta().retain(index).insert('\n');
  429. this.quill.updateContents(delta, Quill.sources.USER);
  430. this.quill.setSelection(
  431. range.index + 1,
  432. range.length,
  433. Quill.sources.SILENT,
  434. );
  435. } else if (shift > 0) {
  436. index += table.length();
  437. const delta = new Delta().retain(index).insert('\n');
  438. this.quill.updateContents(delta, Quill.sources.USER);
  439. this.quill.setSelection(index, Quill.sources.USER);
  440. }
  441. }
  442. },
  443. },
  444. 'table tab': {
  445. key: 'Tab',
  446. shiftKey: null,
  447. format: ['table'],
  448. handler(range, context) {
  449. const { event, line: cell } = context;
  450. const offset = cell.offset(this.quill.scroll);
  451. if (event.shiftKey) {
  452. this.quill.setSelection(offset - 1, Quill.sources.USER);
  453. } else {
  454. this.quill.setSelection(offset + cell.length(), Quill.sources.USER);
  455. }
  456. },
  457. },
  458. 'list autofill': {
  459. key: ' ',
  460. shiftKey: null,
  461. collapsed: true,
  462. format: {
  463. list: false,
  464. 'code-block': false,
  465. blockquote: false,
  466. header: false,
  467. table: false,
  468. },
  469. prefix: /^\s*?(\d+\.|-|\*|\[ ?\]|\[x\])$/,
  470. handler(range, context) {
  471. if (this.quill.scroll.query('list') == null) return true;
  472. const { length } = context.prefix;
  473. const [line, offset] = this.quill.getLine(range.index);
  474. if (offset > length) return true;
  475. let value;
  476. switch (context.prefix.trim()) {
  477. case '[]':
  478. case '[ ]':
  479. value = 'unchecked';
  480. break;
  481. case '[x]':
  482. value = 'checked';
  483. break;
  484. case '-':
  485. case '*':
  486. value = 'bullet';
  487. break;
  488. default:
  489. value = 'ordered';
  490. }
  491. this.quill.insertText(range.index, ' ', Quill.sources.USER);
  492. this.quill.history.cutoff();
  493. const delta = new Delta()
  494. .retain(range.index - offset)
  495. .delete(length + 1)
  496. .retain(line.length() - 2 - offset)
  497. .retain(1, { list: value });
  498. this.quill.updateContents(delta, Quill.sources.USER);
  499. this.quill.history.cutoff();
  500. this.quill.setSelection(range.index - length, Quill.sources.SILENT);
  501. return false;
  502. },
  503. },
  504. 'code exit': {
  505. key: 'Enter',
  506. collapsed: true,
  507. format: ['code-block'],
  508. prefix: /^$/,
  509. suffix: /^\s*$/,
  510. handler(range) {
  511. const [line, offset] = this.quill.getLine(range.index);
  512. let numLines = 2;
  513. let cur = line;
  514. while (
  515. cur != null &&
  516. cur.length() <= 1 &&
  517. cur.formats()['code-block']
  518. ) {
  519. cur = cur.prev;
  520. numLines -= 1;
  521. // Requisite prev lines are empty
  522. if (numLines <= 0) {
  523. const delta = new Delta()
  524. .retain(range.index + line.length() - offset - 2)
  525. .retain(1, { 'code-block': null })
  526. .delete(1);
  527. this.quill.updateContents(delta, Quill.sources.USER);
  528. this.quill.setSelection(range.index - 1, Quill.sources.SILENT);
  529. return false;
  530. }
  531. }
  532. return true;
  533. },
  534. },
  535. 'embed left': makeEmbedArrowHandler('ArrowLeft', false),
  536. 'embed left shift': makeEmbedArrowHandler('ArrowLeft', true),
  537. 'embed right': makeEmbedArrowHandler('ArrowRight', false),
  538. 'embed right shift': makeEmbedArrowHandler('ArrowRight', true),
  539. 'table down': makeTableArrowHandler(false),
  540. 'table up': makeTableArrowHandler(true),
  541. },
  542. };
  543. function makeCodeBlockHandler(indent) {
  544. return {
  545. key: 'Tab',
  546. shiftKey: !indent,
  547. format: { 'code-block': true },
  548. handler(range) {
  549. const CodeBlock = this.quill.scroll.query('code-block');
  550. const lines =
  551. range.length === 0
  552. ? this.quill.getLines(range.index, 1)
  553. : this.quill.getLines(range);
  554. let { index, length } = range;
  555. lines.forEach((line, i) => {
  556. if (indent) {
  557. line.insertAt(0, CodeBlock.TAB);
  558. if (i === 0) {
  559. index += CodeBlock.TAB.length;
  560. } else {
  561. length += CodeBlock.TAB.length;
  562. }
  563. } else if (line.domNode.textContent.startsWith(CodeBlock.TAB)) {
  564. line.deleteAt(0, CodeBlock.TAB.length);
  565. if (i === 0) {
  566. index -= CodeBlock.TAB.length;
  567. } else {
  568. length -= CodeBlock.TAB.length;
  569. }
  570. }
  571. });
  572. this.quill.update(Quill.sources.USER);
  573. this.quill.setSelection(index, length, Quill.sources.SILENT);
  574. },
  575. };
  576. }
  577. function makeEmbedArrowHandler(key, shiftKey) {
  578. const where = key === 'ArrowLeft' ? 'prefix' : 'suffix';
  579. return {
  580. key,
  581. shiftKey,
  582. altKey: null,
  583. [where]: /^$/,
  584. handler(range) {
  585. let { index } = range;
  586. if (key === 'ArrowRight') {
  587. index += range.length + 1;
  588. }
  589. const [leaf] = this.quill.getLeaf(index);
  590. if (!(leaf instanceof EmbedBlot)) return true;
  591. if (key === 'ArrowLeft') {
  592. if (shiftKey) {
  593. this.quill.setSelection(
  594. range.index - 1,
  595. range.length + 1,
  596. Quill.sources.USER,
  597. );
  598. } else {
  599. this.quill.setSelection(range.index - 1, Quill.sources.USER);
  600. }
  601. } else if (shiftKey) {
  602. this.quill.setSelection(
  603. range.index,
  604. range.length + 1,
  605. Quill.sources.USER,
  606. );
  607. } else {
  608. this.quill.setSelection(
  609. range.index + range.length + 1,
  610. Quill.sources.USER,
  611. );
  612. }
  613. return false;
  614. },
  615. };
  616. }
  617. function makeFormatHandler(format) {
  618. return {
  619. key: format[0],
  620. shortKey: true,
  621. handler(range, context) {
  622. this.quill.format(format, !context.format[format], Quill.sources.USER);
  623. },
  624. };
  625. }
  626. function makeTableArrowHandler(up) {
  627. return {
  628. key: up ? 'ArrowUp' : 'ArrowDown',
  629. collapsed: true,
  630. format: ['table'],
  631. handler(range, context) {
  632. // TODO move to table module
  633. const key = up ? 'prev' : 'next';
  634. const cell = context.line;
  635. const targetRow = cell.parent[key];
  636. if (targetRow != null) {
  637. if (targetRow.statics.blotName === 'table-row') {
  638. let targetCell = targetRow.children.head;
  639. let cur = cell;
  640. while (cur.prev != null) {
  641. cur = cur.prev;
  642. targetCell = targetCell.next;
  643. }
  644. const index =
  645. targetCell.offset(this.quill.scroll) +
  646. Math.min(context.offset, targetCell.length() - 1);
  647. this.quill.setSelection(index, 0, Quill.sources.USER);
  648. }
  649. } else {
  650. const targetLine = cell.table()[key];
  651. if (targetLine != null) {
  652. if (up) {
  653. this.quill.setSelection(
  654. targetLine.offset(this.quill.scroll) + targetLine.length() - 1,
  655. 0,
  656. Quill.sources.USER,
  657. );
  658. } else {
  659. this.quill.setSelection(
  660. targetLine.offset(this.quill.scroll),
  661. 0,
  662. Quill.sources.USER,
  663. );
  664. }
  665. }
  666. }
  667. return false;
  668. },
  669. };
  670. }
  671. function normalize(binding) {
  672. if (typeof binding === 'string' || typeof binding === 'number') {
  673. binding = { key: binding };
  674. } else if (typeof binding === 'object') {
  675. binding = clone(binding, false);
  676. } else {
  677. return null;
  678. }
  679. if (binding.shortKey) {
  680. binding[SHORTKEY] = binding.shortKey;
  681. delete binding.shortKey;
  682. }
  683. return binding;
  684. }
  685. function tableSide(table, row, cell, offset) {
  686. if (row.prev == null && row.next == null) {
  687. if (cell.prev == null && cell.next == null) {
  688. return offset === 0 ? -1 : 1;
  689. }
  690. return cell.prev == null ? -1 : 1;
  691. }
  692. if (row.prev == null) {
  693. return -1;
  694. }
  695. if (row.next == null) {
  696. return 1;
  697. }
  698. return null;
  699. }
  700. export { Keyboard as default, SHORTKEY, normalize };