UNPKG

quill

Version:

Your powerful, rich text editor

713 lines (711 loc) 23.3 kB
import { cloneDeep, isEqual } from 'lodash-es'; import Delta, { AttributeMap } from 'quill-delta'; import { EmbedBlot, Scope, TextBlot } from 'parchment'; import Quill from '../core/quill.js'; import logger from '../core/logger.js'; import Module from '../core/module.js'; const debug = logger('quill:keyboard'); const SHORTKEY = /Mac/i.test(navigator.platform) ? 'metaKey' : 'ctrlKey'; class Keyboard extends Module { static match(evt, binding) { if (['altKey', 'ctrlKey', 'metaKey', 'shiftKey'].some(key => { return !!binding[key] !== evt[key] && binding[key] !== null; })) { return false; } return binding.key === evt.key || binding.key === evt.which; } constructor(quill, options) { super(quill, options); this.bindings = {}; // @ts-expect-error Fix me later Object.keys(this.options.bindings).forEach(name => { // @ts-expect-error Fix me later if (this.options.bindings[name]) { // @ts-expect-error Fix me later this.addBinding(this.options.bindings[name]); } }); this.addBinding({ key: 'Enter', shiftKey: null }, this.handleEnter); this.addBinding({ key: 'Enter', metaKey: null, ctrlKey: null, altKey: null }, () => {}); if (/Firefox/i.test(navigator.userAgent)) { // Need to handle delete and backspace for Firefox in the general case #1171 this.addBinding({ key: 'Backspace' }, { collapsed: true }, this.handleBackspace); this.addBinding({ key: 'Delete' }, { collapsed: true }, this.handleDelete); } else { this.addBinding({ key: 'Backspace' }, { collapsed: true, prefix: /^.?$/ }, this.handleBackspace); this.addBinding({ key: 'Delete' }, { collapsed: true, suffix: /^.?$/ }, this.handleDelete); } this.addBinding({ key: 'Backspace' }, { collapsed: false }, this.handleDeleteRange); this.addBinding({ key: 'Delete' }, { collapsed: false }, this.handleDeleteRange); this.addBinding({ key: 'Backspace', altKey: null, ctrlKey: null, metaKey: null, shiftKey: null }, { collapsed: true, offset: 0 }, this.handleBackspace); this.listen(); } addBinding(keyBinding) { let context = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; let handler = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : {}; const binding = normalize(keyBinding); if (binding == null) { debug.warn('Attempted to add invalid keyboard binding', binding); return; } if (typeof context === 'function') { context = { handler: context }; } if (typeof handler === 'function') { handler = { handler }; } const keys = Array.isArray(binding.key) ? binding.key : [binding.key]; keys.forEach(key => { const singleBinding = { ...binding, key, ...context, ...handler }; this.bindings[singleBinding.key] = this.bindings[singleBinding.key] || []; this.bindings[singleBinding.key].push(singleBinding); }); } listen() { this.quill.root.addEventListener('keydown', evt => { if (evt.defaultPrevented || evt.isComposing) return; // evt.isComposing is false when pressing Enter/Backspace when composing in Safari // https://bugs.webkit.org/show_bug.cgi?id=165004 const isComposing = evt.keyCode === 229 && (evt.key === 'Enter' || evt.key === 'Backspace'); if (isComposing) return; const bindings = (this.bindings[evt.key] || []).concat(this.bindings[evt.which] || []); const matches = bindings.filter(binding => Keyboard.match(evt, binding)); if (matches.length === 0) return; // @ts-expect-error const blot = Quill.find(evt.target, true); if (blot && blot.scroll !== this.quill.scroll) return; const range = this.quill.getSelection(); if (range == null || !this.quill.hasFocus()) return; const [line, offset] = this.quill.getLine(range.index); const [leafStart, offsetStart] = this.quill.getLeaf(range.index); const [leafEnd, offsetEnd] = range.length === 0 ? [leafStart, offsetStart] : this.quill.getLeaf(range.index + range.length); const prefixText = leafStart instanceof TextBlot ? leafStart.value().slice(0, offsetStart) : ''; const suffixText = leafEnd instanceof TextBlot ? leafEnd.value().slice(offsetEnd) : ''; const curContext = { collapsed: range.length === 0, // @ts-expect-error Fix me later empty: range.length === 0 && line.length() <= 1, format: this.quill.getFormat(range), line, offset, prefix: prefixText, suffix: suffixText, event: evt }; const prevented = matches.some(binding => { if (binding.collapsed != null && binding.collapsed !== curContext.collapsed) { return false; } if (binding.empty != null && binding.empty !== curContext.empty) { return false; } if (binding.offset != null && binding.offset !== curContext.offset) { return false; } if (Array.isArray(binding.format)) { // any format is present if (binding.format.every(name => curContext.format[name] == null)) { return false; } } else if (typeof binding.format === 'object') { // all formats must match if (!Object.keys(binding.format).every(name => { // @ts-expect-error Fix me later if (binding.format[name] === true) return curContext.format[name] != null; // @ts-expect-error Fix me later if (binding.format[name] === false) return curContext.format[name] == null; // @ts-expect-error Fix me later return isEqual(binding.format[name], curContext.format[name]); })) { return false; } } if (binding.prefix != null && !binding.prefix.test(curContext.prefix)) { return false; } if (binding.suffix != null && !binding.suffix.test(curContext.suffix)) { return false; } // @ts-expect-error Fix me later return binding.handler.call(this, range, curContext, binding) !== true; }); if (prevented) { evt.preventDefault(); } }); } handleBackspace(range, context) { // Check for astral symbols const length = /[\uD800-\uDBFF][\uDC00-\uDFFF]$/.test(context.prefix) ? 2 : 1; if (range.index === 0 || this.quill.getLength() <= 1) return; let formats = {}; const [line] = this.quill.getLine(range.index); let delta = new Delta().retain(range.index - length).delete(length); if (context.offset === 0) { // Always deleting newline here, length always 1 const [prev] = this.quill.getLine(range.index - 1); if (prev) { const isPrevLineEmpty = prev.statics.blotName === 'block' && prev.length() <= 1; if (!isPrevLineEmpty) { // @ts-expect-error Fix me later const curFormats = line.formats(); const prevFormats = this.quill.getFormat(range.index - 1, 1); formats = AttributeMap.diff(curFormats, prevFormats) || {}; if (Object.keys(formats).length > 0) { // line.length() - 1 targets \n in line, another -1 for newline being deleted const formatDelta = new Delta() // @ts-expect-error Fix me later .retain(range.index + line.length() - 2).retain(1, formats); delta = delta.compose(formatDelta); } } } } this.quill.updateContents(delta, Quill.sources.USER); this.quill.focus(); } handleDelete(range, context) { // Check for astral symbols const length = /^[\uD800-\uDBFF][\uDC00-\uDFFF]/.test(context.suffix) ? 2 : 1; if (range.index >= this.quill.getLength() - length) return; let formats = {}; const [line] = this.quill.getLine(range.index); let delta = new Delta().retain(range.index).delete(length); // @ts-expect-error Fix me later if (context.offset >= line.length() - 1) { const [next] = this.quill.getLine(range.index + 1); if (next) { // @ts-expect-error Fix me later const curFormats = line.formats(); const nextFormats = this.quill.getFormat(range.index, 1); formats = AttributeMap.diff(curFormats, nextFormats) || {}; if (Object.keys(formats).length > 0) { delta = delta.retain(next.length() - 1).retain(1, formats); } } } this.quill.updateContents(delta, Quill.sources.USER); this.quill.focus(); } handleDeleteRange(range) { deleteRange({ range, quill: this.quill }); this.quill.focus(); } handleEnter(range, context) { const lineFormats = Object.keys(context.format).reduce((formats, format) => { if (this.quill.scroll.query(format, Scope.BLOCK) && !Array.isArray(context.format[format])) { formats[format] = context.format[format]; } return formats; }, {}); const delta = new Delta().retain(range.index).delete(range.length).insert('\n', lineFormats); this.quill.updateContents(delta, Quill.sources.USER); this.quill.setSelection(range.index + 1, Quill.sources.SILENT); this.quill.focus(); } } const defaultOptions = { bindings: { bold: makeFormatHandler('bold'), italic: makeFormatHandler('italic'), underline: makeFormatHandler('underline'), indent: { // highlight tab or tab at beginning of list, indent or blockquote key: 'Tab', format: ['blockquote', 'indent', 'list'], handler(range, context) { if (context.collapsed && context.offset !== 0) return true; this.quill.format('indent', '+1', Quill.sources.USER); return false; } }, outdent: { key: 'Tab', shiftKey: true, format: ['blockquote', 'indent', 'list'], // highlight tab or tab at beginning of list, indent or blockquote handler(range, context) { if (context.collapsed && context.offset !== 0) return true; this.quill.format('indent', '-1', Quill.sources.USER); return false; } }, 'outdent backspace': { key: 'Backspace', collapsed: true, shiftKey: null, metaKey: null, ctrlKey: null, altKey: null, format: ['indent', 'list'], offset: 0, handler(range, context) { if (context.format.indent != null) { this.quill.format('indent', '-1', Quill.sources.USER); } else if (context.format.list != null) { this.quill.format('list', false, Quill.sources.USER); } } }, 'indent code-block': makeCodeBlockHandler(true), 'outdent code-block': makeCodeBlockHandler(false), 'remove tab': { key: 'Tab', shiftKey: true, collapsed: true, prefix: /\t$/, handler(range) { this.quill.deleteText(range.index - 1, 1, Quill.sources.USER); } }, tab: { key: 'Tab', handler(range, context) { if (context.format.table) return true; this.quill.history.cutoff(); const delta = new Delta().retain(range.index).delete(range.length).insert('\t'); this.quill.updateContents(delta, Quill.sources.USER); this.quill.history.cutoff(); this.quill.setSelection(range.index + 1, Quill.sources.SILENT); return false; } }, 'blockquote empty enter': { key: 'Enter', collapsed: true, format: ['blockquote'], empty: true, handler() { this.quill.format('blockquote', false, Quill.sources.USER); } }, 'list empty enter': { key: 'Enter', collapsed: true, format: ['list'], empty: true, handler(range, context) { const formats = { list: false }; if (context.format.indent) { formats.indent = false; } this.quill.formatLine(range.index, range.length, formats, Quill.sources.USER); } }, 'checklist enter': { key: 'Enter', collapsed: true, format: { list: 'checked' }, handler(range) { const [line, offset] = this.quill.getLine(range.index); const formats = { // @ts-expect-error Fix me later ...line.formats(), list: 'checked' }; const delta = new Delta().retain(range.index).insert('\n', formats) // @ts-expect-error Fix me later .retain(line.length() - offset - 1).retain(1, { list: 'unchecked' }); this.quill.updateContents(delta, Quill.sources.USER); this.quill.setSelection(range.index + 1, Quill.sources.SILENT); this.quill.scrollSelectionIntoView(); } }, 'header enter': { key: 'Enter', collapsed: true, format: ['header'], suffix: /^$/, handler(range, context) { const [line, offset] = this.quill.getLine(range.index); const delta = new Delta().retain(range.index).insert('\n', context.format) // @ts-expect-error Fix me later .retain(line.length() - offset - 1).retain(1, { header: null }); this.quill.updateContents(delta, Quill.sources.USER); this.quill.setSelection(range.index + 1, Quill.sources.SILENT); this.quill.scrollSelectionIntoView(); } }, 'table backspace': { key: 'Backspace', format: ['table'], collapsed: true, offset: 0, handler() {} }, 'table delete': { key: 'Delete', format: ['table'], collapsed: true, suffix: /^$/, handler() {} }, 'table enter': { key: 'Enter', shiftKey: null, format: ['table'], handler(range) { const module = this.quill.getModule('table'); if (module) { // @ts-expect-error const [table, row, cell, offset] = module.getTable(range); const shift = tableSide(table, row, cell, offset); if (shift == null) return; let index = table.offset(); if (shift < 0) { const delta = new Delta().retain(index).insert('\n'); this.quill.updateContents(delta, Quill.sources.USER); this.quill.setSelection(range.index + 1, range.length, Quill.sources.SILENT); } else if (shift > 0) { index += table.length(); const delta = new Delta().retain(index).insert('\n'); this.quill.updateContents(delta, Quill.sources.USER); this.quill.setSelection(index, Quill.sources.USER); } } } }, 'table tab': { key: 'Tab', shiftKey: null, format: ['table'], handler(range, context) { const { event, line: cell } = context; const offset = cell.offset(this.quill.scroll); if (event.shiftKey) { this.quill.setSelection(offset - 1, Quill.sources.USER); } else { this.quill.setSelection(offset + cell.length(), Quill.sources.USER); } } }, 'list autofill': { key: ' ', shiftKey: null, collapsed: true, format: { 'code-block': false, blockquote: false, table: false }, prefix: /^\s*?(\d+\.|-|\*|\[ ?\]|\[x\])$/, handler(range, context) { if (this.quill.scroll.query('list') == null) return true; const { length } = context.prefix; const [line, offset] = this.quill.getLine(range.index); if (offset > length) return true; let value; switch (context.prefix.trim()) { case '[]': case '[ ]': value = 'unchecked'; break; case '[x]': value = 'checked'; break; case '-': case '*': value = 'bullet'; break; default: value = 'ordered'; } this.quill.insertText(range.index, ' ', Quill.sources.USER); this.quill.history.cutoff(); const delta = new Delta().retain(range.index - offset).delete(length + 1) // @ts-expect-error Fix me later .retain(line.length() - 2 - offset).retain(1, { list: value }); this.quill.updateContents(delta, Quill.sources.USER); this.quill.history.cutoff(); this.quill.setSelection(range.index - length, Quill.sources.SILENT); return false; } }, 'code exit': { key: 'Enter', collapsed: true, format: ['code-block'], prefix: /^$/, suffix: /^\s*$/, handler(range) { const [line, offset] = this.quill.getLine(range.index); let numLines = 2; let cur = line; while (cur != null && cur.length() <= 1 && cur.formats()['code-block']) { // @ts-expect-error cur = cur.prev; numLines -= 1; // Requisite prev lines are empty if (numLines <= 0) { const delta = new Delta() // @ts-expect-error Fix me later .retain(range.index + line.length() - offset - 2).retain(1, { 'code-block': null }).delete(1); this.quill.updateContents(delta, Quill.sources.USER); this.quill.setSelection(range.index - 1, Quill.sources.SILENT); return false; } } return true; } }, 'embed left': makeEmbedArrowHandler('ArrowLeft', false), 'embed left shift': makeEmbedArrowHandler('ArrowLeft', true), 'embed right': makeEmbedArrowHandler('ArrowRight', false), 'embed right shift': makeEmbedArrowHandler('ArrowRight', true), 'table down': makeTableArrowHandler(false), 'table up': makeTableArrowHandler(true) } }; Keyboard.DEFAULTS = defaultOptions; function makeCodeBlockHandler(indent) { return { key: 'Tab', shiftKey: !indent, format: { 'code-block': true }, handler(range, _ref) { let { event } = _ref; const CodeBlock = this.quill.scroll.query('code-block'); // @ts-expect-error const { TAB } = CodeBlock; if (range.length === 0 && !event.shiftKey) { this.quill.insertText(range.index, TAB, Quill.sources.USER); this.quill.setSelection(range.index + TAB.length, Quill.sources.SILENT); return; } const lines = range.length === 0 ? this.quill.getLines(range.index, 1) : this.quill.getLines(range); let { index, length } = range; lines.forEach((line, i) => { if (indent) { line.insertAt(0, TAB); if (i === 0) { index += TAB.length; } else { length += TAB.length; } // @ts-expect-error Fix me later } else if (line.domNode.textContent.startsWith(TAB)) { line.deleteAt(0, TAB.length); if (i === 0) { index -= TAB.length; } else { length -= TAB.length; } } }); this.quill.update(Quill.sources.USER); this.quill.setSelection(index, length, Quill.sources.SILENT); } }; } function makeEmbedArrowHandler(key, shiftKey) { const where = key === 'ArrowLeft' ? 'prefix' : 'suffix'; return { key, shiftKey, altKey: null, [where]: /^$/, handler(range) { let { index } = range; if (key === 'ArrowRight') { index += range.length + 1; } const [leaf] = this.quill.getLeaf(index); if (!(leaf instanceof EmbedBlot)) return true; if (key === 'ArrowLeft') { if (shiftKey) { this.quill.setSelection(range.index - 1, range.length + 1, Quill.sources.USER); } else { this.quill.setSelection(range.index - 1, Quill.sources.USER); } } else if (shiftKey) { this.quill.setSelection(range.index, range.length + 1, Quill.sources.USER); } else { this.quill.setSelection(range.index + range.length + 1, Quill.sources.USER); } return false; } }; } function makeFormatHandler(format) { return { key: format[0], shortKey: true, handler(range, context) { this.quill.format(format, !context.format[format], Quill.sources.USER); } }; } function makeTableArrowHandler(up) { return { key: up ? 'ArrowUp' : 'ArrowDown', collapsed: true, format: ['table'], handler(range, context) { // TODO move to table module const key = up ? 'prev' : 'next'; const cell = context.line; const targetRow = cell.parent[key]; if (targetRow != null) { if (targetRow.statics.blotName === 'table-row') { // @ts-expect-error let targetCell = targetRow.children.head; let cur = cell; while (cur.prev != null) { // @ts-expect-error cur = cur.prev; targetCell = targetCell.next; } const index = targetCell.offset(this.quill.scroll) + Math.min(context.offset, targetCell.length() - 1); this.quill.setSelection(index, 0, Quill.sources.USER); } } else { // @ts-expect-error const targetLine = cell.table()[key]; if (targetLine != null) { if (up) { this.quill.setSelection(targetLine.offset(this.quill.scroll) + targetLine.length() - 1, 0, Quill.sources.USER); } else { this.quill.setSelection(targetLine.offset(this.quill.scroll), 0, Quill.sources.USER); } } } return false; } }; } function normalize(binding) { if (typeof binding === 'string' || typeof binding === 'number') { binding = { key: binding }; } else if (typeof binding === 'object') { binding = cloneDeep(binding); } else { return null; } if (binding.shortKey) { binding[SHORTKEY] = binding.shortKey; delete binding.shortKey; } return binding; } // TODO: Move into quill.ts or editor.ts function deleteRange(_ref2) { let { quill, range } = _ref2; const lines = quill.getLines(range); let formats = {}; if (lines.length > 1) { const firstFormats = lines[0].formats(); const lastFormats = lines[lines.length - 1].formats(); formats = AttributeMap.diff(lastFormats, firstFormats) || {}; } quill.deleteText(range, Quill.sources.USER); if (Object.keys(formats).length > 0) { quill.formatLine(range.index, 1, formats, Quill.sources.USER); } quill.setSelection(range.index, Quill.sources.SILENT); } function tableSide(_table, row, cell, offset) { if (row.prev == null && row.next == null) { if (cell.prev == null && cell.next == null) { return offset === 0 ? -1 : 1; } return cell.prev == null ? -1 : 1; } if (row.prev == null) { return -1; } if (row.next == null) { return 1; } return null; } export { Keyboard as default, SHORTKEY, normalize, deleteRange }; //# sourceMappingURL=keyboard.js.map