UNPKG

17.9 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 value;
257 switch (context.prefix.trim()) {
258 case '[]': case '[ ]':
259 value = 'unchecked';
260 break;
261 case '[x]':
262 value = 'checked';
263 break;
264 case '-':
265 value = 'bullet';
266 break;
267 default:
268 value = 'ordered';
269 }
270 this.quill.insertText(range.index, ' ', Quill.sources.USER);
271 this.quill.history.cutoff();
272 let [line, offset] = this.quill.getLine(range.index + 1);
273 let delta = new Delta().retain(range.index + 1 - offset)
274 .delete(length + 1)
275 .retain(line.length() - 1 - offset)
276 .retain(1, { list: value });
277 this.quill.updateContents(delta, Quill.sources.USER);
278 this.quill.history.cutoff();
279 this.quill.setSelection(range.index - length, Quill.sources.SILENT);
280 }
281 },
282 'code exit': {
283 key: Keyboard.keys.ENTER,
284 collapsed: true,
285 format: ['code-block'],
286 prefix: /\n\n$/,
287 suffix: /^\s+$/,
288 handler: function(range) {
289 const [line, offset] = this.quill.getLine(range.index);
290 const delta = new Delta()
291 .retain(range.index + line.length() - offset - 2)
292 .retain(1, { 'code-block': null })
293 .delete(1);
294 this.quill.updateContents(delta, Quill.sources.USER);
295 }
296 },
297 'embed left': makeEmbedArrowHandler(Keyboard.keys.LEFT, false),
298 'embed left shift': makeEmbedArrowHandler(Keyboard.keys.LEFT, true),
299 'embed right': makeEmbedArrowHandler(Keyboard.keys.RIGHT, false),
300 'embed right shift': makeEmbedArrowHandler(Keyboard.keys.RIGHT, true)
301 }
302};
303
304function makeEmbedArrowHandler(key, shiftKey) {
305 const where = key === Keyboard.keys.LEFT ? 'prefix' : 'suffix';
306 return {
307 key,
308 shiftKey,
309 [where]: /^$/,
310 handler: function(range) {
311 let index = range.index;
312 if (key === Keyboard.keys.RIGHT) {
313 index += (range.length + 1);
314 }
315 const [leaf, ] = this.quill.getLeaf(index);
316 if (!(leaf instanceof Embed)) return true;
317 if (key === Keyboard.keys.LEFT) {
318 if (shiftKey) {
319 this.quill.setSelection(range.index - 1, range.length + 1, Quill.sources.USER);
320 } else {
321 this.quill.setSelection(range.index - 1, Quill.sources.USER);
322 }
323 } else {
324 if (shiftKey) {
325 this.quill.setSelection(range.index, range.length + 1, Quill.sources.USER);
326 } else {
327 this.quill.setSelection(range.index + range.length + 1, Quill.sources.USER);
328 }
329 }
330 return false;
331 }
332 };
333}
334
335
336function handleBackspace(range, context) {
337 if (range.index === 0 || this.quill.getLength() <= 1) return;
338 let [line, ] = this.quill.getLine(range.index);
339 let formats = {};
340 if (context.offset === 0) {
341 let [prev, ] = this.quill.getLine(range.index - 1);
342 if (prev != null && prev.length() > 1) {
343 let curFormats = line.formats();
344 let prevFormats = this.quill.getFormat(range.index-1, 1);
345 formats = DeltaOp.attributes.diff(curFormats, prevFormats) || {};
346 }
347 }
348 // Check for astral symbols
349 let length = /[\uD800-\uDBFF][\uDC00-\uDFFF]$/.test(context.prefix) ? 2 : 1;
350 this.quill.deleteText(range.index-length, length, Quill.sources.USER);
351 if (Object.keys(formats).length > 0) {
352 this.quill.formatLine(range.index-length, length, formats, Quill.sources.USER);
353 }
354 this.quill.focus();
355}
356
357function handleDelete(range, context) {
358 // Check for astral symbols
359 let length = /^[\uD800-\uDBFF][\uDC00-\uDFFF]/.test(context.suffix) ? 2 : 1;
360 if (range.index >= this.quill.getLength() - length) return;
361 let formats = {}, nextLength = 0;
362 let [line, ] = this.quill.getLine(range.index);
363 if (context.offset >= line.length() - 1) {
364 let [next, ] = this.quill.getLine(range.index + 1);
365 if (next) {
366 let curFormats = line.formats();
367 let nextFormats = this.quill.getFormat(range.index, 1);
368 formats = DeltaOp.attributes.diff(curFormats, nextFormats) || {};
369 nextLength = next.length();
370 }
371 }
372 this.quill.deleteText(range.index, length, Quill.sources.USER);
373 if (Object.keys(formats).length > 0) {
374 this.quill.formatLine(range.index + nextLength - 1, length, formats, Quill.sources.USER);
375 }
376}
377
378function handleDeleteRange(range) {
379 let lines = this.quill.getLines(range);
380 let formats = {};
381 if (lines.length > 1) {
382 let firstFormats = lines[0].formats();
383 let lastFormats = lines[lines.length - 1].formats();
384 formats = DeltaOp.attributes.diff(lastFormats, firstFormats) || {};
385 }
386 this.quill.deleteText(range, Quill.sources.USER);
387 if (Object.keys(formats).length > 0) {
388 this.quill.formatLine(range.index, 1, formats, Quill.sources.USER);
389 }
390 this.quill.setSelection(range.index, Quill.sources.SILENT);
391 this.quill.focus();
392}
393
394function handleEnter(range, context) {
395 if (range.length > 0) {
396 this.quill.scroll.deleteAt(range.index, range.length); // So we do not trigger text-change
397 }
398 let lineFormats = Object.keys(context.format).reduce(function(lineFormats, format) {
399 if (Parchment.query(format, Parchment.Scope.BLOCK) && !Array.isArray(context.format[format])) {
400 lineFormats[format] = context.format[format];
401 }
402 return lineFormats;
403 }, {});
404 this.quill.insertText(range.index, '\n', lineFormats, Quill.sources.USER);
405 // Earlier scroll.deleteAt might have messed up our selection,
406 // so insertText's built in selection preservation is not reliable
407 this.quill.setSelection(range.index + 1, Quill.sources.SILENT);
408 this.quill.focus();
409 Object.keys(context.format).forEach((name) => {
410 if (lineFormats[name] != null) return;
411 if (Array.isArray(context.format[name])) return;
412 if (name === 'link') return;
413 this.quill.format(name, context.format[name], Quill.sources.USER);
414 });
415}
416
417function makeCodeBlockHandler(indent) {
418 return {
419 key: Keyboard.keys.TAB,
420 shiftKey: !indent,
421 format: {'code-block': true },
422 handler: function(range) {
423 let CodeBlock = Parchment.query('code-block');
424 let index = range.index, length = range.length;
425 let [block, offset] = this.quill.scroll.descendant(CodeBlock, index);
426 if (block == null) return;
427 let scrollIndex = this.quill.getIndex(block);
428 let start = block.newlineIndex(offset, true) + 1;
429 let end = block.newlineIndex(scrollIndex + offset + length);
430 let lines = block.domNode.textContent.slice(start, end).split('\n');
431 offset = 0;
432 lines.forEach((line, i) => {
433 if (indent) {
434 block.insertAt(start + offset, CodeBlock.TAB);
435 offset += CodeBlock.TAB.length;
436 if (i === 0) {
437 index += CodeBlock.TAB.length;
438 } else {
439 length += CodeBlock.TAB.length;
440 }
441 } else if (line.startsWith(CodeBlock.TAB)) {
442 block.deleteAt(start + offset, CodeBlock.TAB.length);
443 offset -= CodeBlock.TAB.length;
444 if (i === 0) {
445 index -= CodeBlock.TAB.length;
446 } else {
447 length -= CodeBlock.TAB.length;
448 }
449 }
450 offset += line.length + 1;
451 });
452 this.quill.update(Quill.sources.USER);
453 this.quill.setSelection(index, length, Quill.sources.SILENT);
454 }
455 };
456}
457
458function makeFormatHandler(format) {
459 return {
460 key: format[0].toUpperCase(),
461 shortKey: true,
462 handler: function(range, context) {
463 this.quill.format(format, !context.format[format], Quill.sources.USER);
464 }
465 };
466}
467
468function normalize(binding) {
469 if (typeof binding === 'string' || typeof binding === 'number') {
470 return normalize({ key: binding });
471 }
472 if (typeof binding === 'object') {
473 binding = clone(binding, false);
474 }
475 if (typeof binding.key === 'string') {
476 if (Keyboard.keys[binding.key.toUpperCase()] != null) {
477 binding.key = Keyboard.keys[binding.key.toUpperCase()];
478 } else if (binding.key.length === 1) {
479 binding.key = binding.key.toUpperCase().charCodeAt(0);
480 } else {
481 return null;
482 }
483 }
484 if (binding.shortKey) {
485 binding[SHORTKEY] = binding.shortKey;
486 delete binding.shortKey;
487 }
488 return binding;
489}
490
491
492export { Keyboard as default, SHORTKEY };