UNPKG

typewriter-editor

Version:

A rich text editor using the Delta format with decorations and rendered with a tiny virtual dom

219 lines (218 loc) 8.73 kB
import { Line, normalizeRange } from '@typewriter/document'; import { Editor } from '../Editor'; import { Source } from '../Source'; import { Types } from '../typesetting'; import { ShortcutEvent, addShortcutsToEvent } from './shortcutFromEvent'; // A list of bad characters that we don't want coming in from pasted content (e.g. "\f" aka line feed) const EMPTY_OBJ = {}; const IS_CHROME = window.chrome && typeof window.chrome === 'object'; // Basic keyboard module. export function keyboard(editor) { function onEnter(event) { if (event.defaultPrevented) return; if (editor.doc.selection) { const { lines } = editor.typeset; const selected = editor.doc.getLinesAt(editor.doc.selection); if (selected.length) { const format = lines.findByAttributes(selected[0].attributes); if (format?.onEnter && selected.every(line => format === lines.findByAttributes(line.attributes))) { event.preventDefault(); format.onEnter(editor); return; } } } const { typeset: { lines }, doc, } = editor; let { selection } = doc; if (!selection) return; event.preventDefault(); const [at, to] = selection; const isCollapsed = at === to; const line = doc.getLineAt(selection[0]); const [start, end] = doc.getLineRange(selection[0]); let { id, ...attributes } = line.attributes; let options; const type = lines.findByAttributes(attributes, true); const atStart = to === start; const atEnd = to === end - 1; if (isCollapsed && isEmpty(line)) { const explicitUnindent = type.onEmptyEnter && type.onEmptyEnter(editor, line); const nativeUnindent = !type.onEmptyEnter && type !== lines.default && !type.contained && !type.defaultFollows && !type.frozen; if (explicitUnindent || nativeUnindent) { // Convert a bullet point into a paragraph if (unindent(lines, doc.getLineAt(at))) return; } } if (at === start && to === end && type.frozen) { if (at === 0) { // if single selection and line element (hr, image etc) insert new line before options = { dontFixNewline: true }; selection = [at, at]; } else if (to === doc.length) { selection = [to - 1, to - 1]; } else { options = { dontFixNewline: true }; selection = [to, to]; } attributes = type.nextLineAttributes ? type.nextLineAttributes(attributes) : EMPTY_OBJ; } else if (atEnd && (type.nextLineAttributes || type.defaultFollows || type.frozen)) { attributes = type.nextLineAttributes ? type.nextLineAttributes(attributes) : EMPTY_OBJ; } else if (atStart && !atEnd) { if (type.defaultFollows) attributes = EMPTY_OBJ; options = { dontFixNewline: true }; } editor.insert('\n', attributes, selection, options); if (at === start && to === end && type.frozen) { editor.select(at === 0 ? 0 : to); } } function onShiftEnter(event) { if (event.defaultPrevented) return; const { typeset, doc } = editor; if (!typeset.embeds.get('br')) return onEnter(event); if (!doc.selection) return; event.preventDefault(); editor.insert({ br: true }); } function onBackspace(event) { handleDelete(event, -1); } function onDelete(event) { handleDelete(event, 1); } function handleDelete(event, direction) { if (event.defaultPrevented) return; const { typeset: { lines }, doc, } = editor; const { selection } = doc; if (!selection) return; const [at, to] = selection; const isCollapsed = at === to; const [start, end] = doc.getLineRange(at); // Allow the system to handle non-line-collapsing deletes // (Bug in Chrome where backspace at the end of a span can delete an entire paragraph) if (isCollapsed && (!IS_CHROME || event.ctrlKey || event.altKey || event.metaKey)) { if (direction === -1 && at !== start) return; if (direction === 1 && at !== end - 1) return; } event.preventDefault(); if (direction === -1 && selection[0] + selection[1] === 0) { // At the beginning of the document unindent(lines, doc.getLineAt(at), true); } else { const range = normalizeRange(selection); const line = doc.getLineAt(range[0]); const type = lines.findByAttributes(line.attributes, true); // If the deletion will move outside a line (collapsing 2 lines) const outside = isCollapsed && ((direction === -1 && at === start) || (direction === 1 && at === end - 1)); if (outside && !type.contained) { // Delete the next line if it is empty const mergingLine = doc.lines[doc.lines.indexOf(line) + direction]; const [first, second] = direction === 1 ? [line, mergingLine] : [mergingLine, line]; if (first && isEmpty(first) && second && !isEmpty(second)) { return editor.update(editor.change.delete([range[0] + direction, range[0]], { dontFixNewline: true }), Source.input); } } editor.delete(direction, { dontFixNewline: type.frozen }); } } function unindent(lines, line, force) { if (!line) return; const type = lines.findByAttributes(line.attributes, true); if (!type) return; if (type.indentable && line.attributes.indent) { editor.outdent(); return true; } if (force || (type !== lines.default && !type.defaultFollows)) { editor.formatLine(EMPTY_OBJ); return true; } } function onTab(event) { if (event.defaultPrevented) return; if (editor.doc.selection) { const { lines } = editor.typeset; const selected = editor.doc.getLinesAt(editor.doc.selection); if (selected.length) { const format = lines.findByAttributes(selected[0].attributes); if (format?.onTab && selected.every(line => format === lines.findByAttributes(line.attributes))) { event.preventDefault(); format.onTab(editor, event.shiftKey); return; } } } event.preventDefault(); const shortcut = event.modShortcut; if (shortcut === 'Tab' || shortcut === 'Mod+]') editor.indent(); else editor.outdent(); } function onKeyDown(event) { if (event.isComposing) return; addShortcutsToEvent(event); const checkShortcut = (shortcut) => { const command = shortcut && editor.shortcuts[shortcut]; if (command && editor.commands[command]) { event.preventDefault(); return editor.commands[command]() !== false; } }; if (!editor.root.dispatchEvent(ShortcutEvent.fromKeyboardEvent(event)) || checkShortcut(event.shortcut) || checkShortcut(event.osShortcut) || checkShortcut(event.modShortcut)) { event.preventDefault(); return; } switch (event.modShortcut) { case 'Enter': return onEnter(event); case 'Shift+Enter': return onShiftEnter(event); case 'Tab': case 'Shift+Tab': return onTab(event); } switch (event.modShortcut?.split('+').pop()) { case 'Backspace': return onBackspace(event); case 'Delete': return onDelete(event); default: return; } } function isEmpty(line) { return line.length === 1 && !editor.typeset.lines.findByAttributes(line.attributes)?.frozen; } return { init() { editor.root.addEventListener('keydown', onKeyDown); }, destroy() { editor.root.removeEventListener('keydown', onKeyDown); }, }; }