UNPKG

18 kBJavaScriptView Raw
1import clone from 'clone';
2import equal from 'deep-equal';
3import extend from 'extend';
4import Delta from 'quill-delta';
5import DeltaOp from 'quill-delta/lib/op';
6import Parchment from 'parchment';
7import Embed from '../blots/embed';
8import Quill from '../core/quill';
9import logger from '../core/logger';
10import Module from '../core/module';
11
12let debug = logger('quill:keyboard');
13
14const SHORTKEY = /Mac/i.test(navigator.platform) ? 'metaKey' : 'ctrlKey';
15
16
17class Keyboard extends Module {
18 static match(evt, binding) {
19 binding = normalize(binding);
20 if (['altKey', 'ctrlKey', 'metaKey', 'shiftKey'].some(function(key) {
21 return (!!binding[key] !== evt[key] && binding[key] !== null);
22 })) {
23 return false;
24 }
25 return binding.key === (evt.which || evt.keyCode);
26 }
27
28 constructor(quill, options) {
29 super(quill, options);
30 this.bindings = {};
31 Object.keys(this.options.bindings).forEach((name) => {
32 if (name === 'list autofill' &&
33 quill.scroll.whitelist != null &&
34 !quill.scroll.whitelist['list']) {
35 return;
36 }
37 if (this.options.bindings[name]) {
38 this.addBinding(this.options.bindings[name]);
39 }
40 });
41 this.addBinding({ key: Keyboard.keys.ENTER, shiftKey: null }, handleEnter);
42 this.addBinding({ key: Keyboard.keys.ENTER, metaKey: null, ctrlKey: null, altKey: null }, function() {});
43 if (/Firefox/i.test(navigator.userAgent)) {
44 // Need to handle delete and backspace for Firefox in the general case #1171
45 this.addBinding({ key: Keyboard.keys.BACKSPACE }, { collapsed: true }, handleBackspace);
46 this.addBinding({ key: Keyboard.keys.DELETE }, { collapsed: true }, handleDelete);
47 } else {
48 this.addBinding({ key: Keyboard.keys.BACKSPACE }, { collapsed: true, prefix: /^.?$/ }, handleBackspace);
49 this.addBinding({ key: Keyboard.keys.DELETE }, { collapsed: true, suffix: /^.?$/ }, handleDelete);
50 }
51 this.addBinding({ key: Keyboard.keys.BACKSPACE }, { collapsed: false }, handleDeleteRange);
52 this.addBinding({ key: Keyboard.keys.DELETE }, { collapsed: false }, handleDeleteRange);
53 this.addBinding({ key: Keyboard.keys.BACKSPACE, altKey: null, ctrlKey: null, metaKey: null, shiftKey: null },
54 { collapsed: true, offset: 0 },
55 handleBackspace);
56 this.listen();
57 }
58
59 addBinding(key, context = {}, handler = {}) {
60 let binding = normalize(key);
61 if (binding == null || binding.key == null) {
62 return debug.warn('Attempted to add invalid keyboard binding', binding);
63 }
64 if (typeof context === 'function') {
65 context = { handler: context };
66 }
67 if (typeof handler === 'function') {
68 handler = { handler: handler };
69 }
70 binding = extend(binding, context, handler);
71 this.bindings[binding.key] = this.bindings[binding.key] || [];
72 this.bindings[binding.key].push(binding);
73 }
74
75 listen() {
76 this.quill.root.addEventListener('keydown', (evt) => {
77 if (evt.defaultPrevented) return;
78 let which = evt.which || evt.keyCode;
79 let bindings = (this.bindings[which] || []).filter(function(binding) {
80 return Keyboard.match(evt, binding);
81 });
82 if (bindings.length === 0) return;
83 let range = this.quill.getSelection();
84 if (range == null || !this.quill.hasFocus()) return;
85 let [line, offset] = this.quill.getLine(range.index);
86 let [leafStart, offsetStart] = this.quill.getLeaf(range.index);
87 let [leafEnd, offsetEnd] = range.length === 0 ? [leafStart, offsetStart] : this.quill.getLeaf(range.index + range.length);
88 let prefixText = leafStart instanceof Parchment.Text ? leafStart.value().slice(0, offsetStart) : '';
89 let suffixText = leafEnd instanceof Parchment.Text ? leafEnd.value().slice(offsetEnd) : '';
90 let curContext = {
91 collapsed: range.length === 0,
92 empty: range.length === 0 && line.length() <= 1,
93 format: this.quill.getFormat(range),
94 offset: offset,
95 prefix: prefixText,
96 suffix: suffixText
97 };
98 let prevented = bindings.some((binding) => {
99 if (binding.collapsed != null && binding.collapsed !== curContext.collapsed) return false;
100 if (binding.empty != null && binding.empty !== curContext.empty) return false;
101 if (binding.offset != null && binding.offset !== curContext.offset) return false;
102 if (Array.isArray(binding.format)) {
103 // any format is present
104 if (binding.format.every(function(name) {
105 return curContext.format[name] == null;
106 })) {
107 return false;
108 }
109 } else if (typeof binding.format === 'object') {
110 // all formats must match
111 if (!Object.keys(binding.format).every(function(name) {
112 if (binding.format[name] === true) return curContext.format[name] != null;
113 if (binding.format[name] === false) return curContext.format[name] == null;
114 return equal(binding.format[name], curContext.format[name]);
115 })) {
116 return false;
117 }
118 }
119 if (binding.prefix != null && !binding.prefix.test(curContext.prefix)) return false;
120 if (binding.suffix != null && !binding.suffix.test(curContext.suffix)) return false;
121 return binding.handler.call(this, range, curContext) !== true;
122 });
123 if (prevented) {
124 evt.preventDefault();
125 }
126 });
127 }
128}
129
130Keyboard.keys = {
131 BACKSPACE: 8,
132 TAB: 9,
133 ENTER: 13,
134 ESCAPE: 27,
135 LEFT: 37,
136 UP: 38,
137 RIGHT: 39,
138 DOWN: 40,
139 DELETE: 46
140};
141
142Keyboard.DEFAULTS = {
143 bindings: {
144 'bold' : makeFormatHandler('bold'),
145 'italic' : makeFormatHandler('italic'),
146 'underline' : makeFormatHandler('underline'),
147 'indent': {
148 // highlight tab or tab at beginning of list, indent or blockquote
149 key: Keyboard.keys.TAB,
150 format: ['blockquote', 'indent', 'list'],
151 handler: function(range, context) {
152 if (context.collapsed && context.offset !== 0) return true;
153 this.quill.format('indent', '+1', Quill.sources.USER);
154 }
155 },
156 'outdent': {
157 key: Keyboard.keys.TAB,
158 shiftKey: true,
159 format: ['blockquote', 'indent', 'list'],
160 // highlight tab or tab at beginning of list, indent or blockquote
161 handler: function(range, context) {
162 if (context.collapsed && context.offset !== 0) return true;
163 this.quill.format('indent', '-1', Quill.sources.USER);
164 }
165 },
166 'outdent backspace': {
167 key: Keyboard.keys.BACKSPACE,
168 collapsed: true,
169 shiftKey: null,
170 metaKey: null,
171 ctrlKey: null,
172 altKey: null,
173 format: ['indent', 'list'],
174 offset: 0,
175 handler: function(range, context) {
176 if (context.format.indent != null) {
177 this.quill.format('indent', '-1', Quill.sources.USER);
178 } else if (context.format.list != null) {
179 this.quill.format('list', false, Quill.sources.USER);
180 }
181 }
182 },
183 'indent code-block': makeCodeBlockHandler(true),
184 'outdent code-block': makeCodeBlockHandler(false),
185 'remove tab': {
186 key: Keyboard.keys.TAB,
187 shiftKey: true,
188 collapsed: true,
189 prefix: /\t$/,
190 handler: function(range) {
191 this.quill.deleteText(range.index - 1, 1, Quill.sources.USER);
192 }
193 },
194 'tab': {
195 key: Keyboard.keys.TAB,
196 handler: function(range) {
197 this.quill.history.cutoff();
198 let delta = new Delta().retain(range.index)
199 .delete(range.length)
200 .insert('\t');
201 this.quill.updateContents(delta, Quill.sources.USER);
202 this.quill.history.cutoff();
203 this.quill.setSelection(range.index + 1, Quill.sources.SILENT);
204 }
205 },
206 'list empty enter': {
207 key: Keyboard.keys.ENTER,
208 collapsed: true,
209 format: ['list'],
210 empty: true,
211 handler: function(range, context) {
212 this.quill.format('list', false, Quill.sources.USER);
213 if (context.format.indent) {
214 this.quill.format('indent', false, Quill.sources.USER);
215 }
216 }
217 },
218 'checklist enter': {
219 key: Keyboard.keys.ENTER,
220 collapsed: true,
221 format: { list: 'checked' },
222 handler: function(range) {
223 let [line, offset] = this.quill.getLine(range.index);
224 let delta = new Delta().retain(range.index)
225 .insert('\n', { list: 'checked' })
226 .retain(line.length() - offset - 1)
227 .retain(1, { list: 'unchecked' });
228 this.quill.updateContents(delta, Quill.sources.USER);
229 this.quill.setSelection(range.index + 1, Quill.sources.SILENT);
230 this.quill.scrollIntoView();
231 }
232 },
233 'header enter': {
234 key: Keyboard.keys.ENTER,
235 collapsed: true,
236 format: ['header'],
237 suffix: /^$/,
238 handler: function(range, context) {
239 let [line, offset] = this.quill.getLine(range.index);
240 let delta = new Delta().retain(range.index)
241 .insert('\n', context.format)
242 .retain(line.length() - offset - 1)
243 .retain(1, { header: null });
244 this.quill.updateContents(delta, Quill.sources.USER);
245 this.quill.setSelection(range.index + 1, Quill.sources.SILENT);
246 this.quill.scrollIntoView();
247 }
248 },
249 'list autofill': {
250 key: ' ',
251 collapsed: true,
252 format: { list: false },
253 prefix: /^\s*?(1\.|-|\[ ?\]|\[x\])$/,
254 handler: function(range, context) {
255 let length = context.prefix.length;
256 let [line, offset] = this.quill.getLine(range.index);
257 if (offset > length) return true;
258 let value;
259 switch (context.prefix.trim()) {
260 case '[]': case '[ ]':
261 value = 'unchecked';
262 break;
263 case '[x]':
264 value = 'checked';
265 break;
266 case '-':
267 value = 'bullet';
268 break;
269 default:
270 value = 'ordered';
271 }
272 this.quill.insertText(range.index, ' ', Quill.sources.USER);
273 this.quill.history.cutoff();
274 let delta = new Delta().retain(range.index - offset)
275 .delete(length + 1)
276 .retain(line.length() - 2 - offset)
277 .retain(1, { list: value });
278 this.quill.updateContents(delta, Quill.sources.USER);
279 this.quill.history.cutoff();
280 this.quill.setSelection(range.index - length, Quill.sources.SILENT);
281 }
282 },
283 'code exit': {
284 key: Keyboard.keys.ENTER,
285 collapsed: true,
286 format: ['code-block'],
287 prefix: /\n\n$/,
288 suffix: /^\s+$/,
289 handler: function(range) {
290 const [line, offset] = this.quill.getLine(range.index);
291 const delta = new Delta()
292 .retain(range.index + line.length() - offset - 2)
293 .retain(1, { 'code-block': null })
294 .delete(1);
295 this.quill.updateContents(delta, Quill.sources.USER);
296 }
297 },
298 'embed left': makeEmbedArrowHandler(Keyboard.keys.LEFT, false),
299 'embed left shift': makeEmbedArrowHandler(Keyboard.keys.LEFT, true),
300 'embed right': makeEmbedArrowHandler(Keyboard.keys.RIGHT, false),
301 'embed right shift': makeEmbedArrowHandler(Keyboard.keys.RIGHT, true)
302 }
303};
304
305function makeEmbedArrowHandler(key, shiftKey) {
306 const where = key === Keyboard.keys.LEFT ? 'prefix' : 'suffix';
307 return {
308 key,
309 shiftKey,
310 [where]: /^$/,
311 handler: function(range) {
312 let index = range.index;
313 if (key === Keyboard.keys.RIGHT) {
314 index += (range.length + 1);
315 }
316 const [leaf, ] = this.quill.getLeaf(index);
317 if (!(leaf instanceof Embed)) return true;
318 if (key === Keyboard.keys.LEFT) {
319 if (shiftKey) {
320 this.quill.setSelection(range.index - 1, range.length + 1, Quill.sources.USER);
321 } else {
322 this.quill.setSelection(range.index - 1, Quill.sources.USER);
323 }
324 } else {
325 if (shiftKey) {
326 this.quill.setSelection(range.index, range.length + 1, Quill.sources.USER);
327 } else {
328 this.quill.setSelection(range.index + range.length + 1, Quill.sources.USER);
329 }
330 }
331 return false;
332 }
333 };
334}
335
336
337function handleBackspace(range, context) {
338 if (range.index === 0 || this.quill.getLength() <= 1) return;
339 let [line, ] = this.quill.getLine(range.index);
340 let formats = {};
341 if (context.offset === 0) {
342 let [prev, ] = this.quill.getLine(range.index - 1);
343 if (prev != null && prev.length() > 1) {
344 let curFormats = line.formats();
345 let prevFormats = this.quill.getFormat(range.index-1, 1);
346 formats = DeltaOp.attributes.diff(curFormats, prevFormats) || {};
347 }
348 }
349 // Check for astral symbols
350 let length = /[\uD800-\uDBFF][\uDC00-\uDFFF]$/.test(context.prefix) ? 2 : 1;
351 this.quill.deleteText(range.index-length, length, Quill.sources.USER);
352 if (Object.keys(formats).length > 0) {
353 this.quill.formatLine(range.index-length, length, formats, Quill.sources.USER);
354 }
355 this.quill.focus();
356}
357
358function handleDelete(range, context) {
359 // Check for astral symbols
360 let length = /^[\uD800-\uDBFF][\uDC00-\uDFFF]/.test(context.suffix) ? 2 : 1;
361 if (range.index >= this.quill.getLength() - length) return;
362 let formats = {}, nextLength = 0;
363 let [line, ] = this.quill.getLine(range.index);
364 if (context.offset >= line.length() - 1) {
365 let [next, ] = this.quill.getLine(range.index + 1);
366 if (next) {
367 let curFormats = line.formats();
368 let nextFormats = this.quill.getFormat(range.index, 1);
369 formats = DeltaOp.attributes.diff(curFormats, nextFormats) || {};
370 nextLength = next.length();
371 }
372 }
373 this.quill.deleteText(range.index, length, Quill.sources.USER);
374 if (Object.keys(formats).length > 0) {
375 this.quill.formatLine(range.index + nextLength - 1, length, formats, Quill.sources.USER);
376 }
377}
378
379function handleDeleteRange(range) {
380 let lines = this.quill.getLines(range);
381 let formats = {};
382 if (lines.length > 1) {
383 let firstFormats = lines[0].formats();
384 let lastFormats = lines[lines.length - 1].formats();
385 formats = DeltaOp.attributes.diff(lastFormats, firstFormats) || {};
386 }
387 this.quill.deleteText(range, Quill.sources.USER);
388 if (Object.keys(formats).length > 0) {
389 this.quill.formatLine(range.index, 1, formats, Quill.sources.USER);
390 }
391 this.quill.setSelection(range.index, Quill.sources.SILENT);
392 this.quill.focus();
393}
394
395function handleEnter(range, context) {
396 if (range.length > 0) {
397 this.quill.scroll.deleteAt(range.index, range.length); // So we do not trigger text-change
398 }
399 let lineFormats = Object.keys(context.format).reduce(function(lineFormats, format) {
400 if (Parchment.query(format, Parchment.Scope.BLOCK) && !Array.isArray(context.format[format])) {
401 lineFormats[format] = context.format[format];
402 }
403 return lineFormats;
404 }, {});
405 this.quill.insertText(range.index, '\n', lineFormats, Quill.sources.USER);
406 // Earlier scroll.deleteAt might have messed up our selection,
407 // so insertText's built in selection preservation is not reliable
408 this.quill.setSelection(range.index + 1, Quill.sources.SILENT);
409 this.quill.focus();
410 Object.keys(context.format).forEach((name) => {
411 if (lineFormats[name] != null) return;
412 if (Array.isArray(context.format[name])) return;
413 if (name === 'link') return;
414 this.quill.format(name, context.format[name], Quill.sources.USER);
415 });
416}
417
418function makeCodeBlockHandler(indent) {
419 return {
420 key: Keyboard.keys.TAB,
421 shiftKey: !indent,
422 format: {'code-block': true },
423 handler: function(range) {
424 let CodeBlock = Parchment.query('code-block');
425 let index = range.index, length = range.length;
426 let [block, offset] = this.quill.scroll.descendant(CodeBlock, index);
427 if (block == null) return;
428 let scrollIndex = this.quill.getIndex(block);
429 let start = block.newlineIndex(offset, true) + 1;
430 let end = block.newlineIndex(scrollIndex + offset + length);
431 let lines = block.domNode.textContent.slice(start, end).split('\n');
432 offset = 0;
433 lines.forEach((line, i) => {
434 if (indent) {
435 block.insertAt(start + offset, CodeBlock.TAB);
436 offset += CodeBlock.TAB.length;
437 if (i === 0) {
438 index += CodeBlock.TAB.length;
439 } else {
440 length += CodeBlock.TAB.length;
441 }
442 } else if (line.startsWith(CodeBlock.TAB)) {
443 block.deleteAt(start + offset, CodeBlock.TAB.length);
444 offset -= CodeBlock.TAB.length;
445 if (i === 0) {
446 index -= CodeBlock.TAB.length;
447 } else {
448 length -= CodeBlock.TAB.length;
449 }
450 }
451 offset += line.length + 1;
452 });
453 this.quill.update(Quill.sources.USER);
454 this.quill.setSelection(index, length, Quill.sources.SILENT);
455 }
456 };
457}
458
459function makeFormatHandler(format) {
460 return {
461 key: format[0].toUpperCase(),
462 shortKey: true,
463 handler: function(range, context) {
464 this.quill.format(format, !context.format[format], Quill.sources.USER);
465 }
466 };
467}
468
469function normalize(binding) {
470 if (typeof binding === 'string' || typeof binding === 'number') {
471 return normalize({ key: binding });
472 }
473 if (typeof binding === 'object') {
474 binding = clone(binding, false);
475 }
476 if (typeof binding.key === 'string') {
477 if (Keyboard.keys[binding.key.toUpperCase()] != null) {
478 binding.key = Keyboard.keys[binding.key.toUpperCase()];
479 } else if (binding.key.length === 1) {
480 binding.key = binding.key.toUpperCase().charCodeAt(0);
481 } else {
482 return null;
483 }
484 }
485 if (binding.shortKey) {
486 binding[SHORTKEY] = binding.shortKey;
487 delete binding.shortKey;
488 }
489 return binding;
490}
491
492
493export { Keyboard as default, SHORTKEY };