UNPKG

typewriter-editor

Version:

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

244 lines (243 loc) 9.51 kB
import { Delta, TextChange, diff, normalizeRange } from '@typewriter/document'; import { Editor } from '../Editor'; import { Source } from '../Source'; import { cleanText, deltaFromDom } from '../rendering/html'; import { getIndexFromNode } from '../rendering/position'; import { getLineNodeEnd, getLineNodeStart } from '../rendering/rendering'; import { getSelection } from '../rendering/selection'; const isIPad = navigator.maxTouchPoints > 2 && /MacIntel/.test(navigator.platform); const isIOS = isIPad || (/iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream); const isAndroid = !isIOS && /Mobi|Android/.test(navigator.userAgent) && !window.MSStream; const MUTATION_OPTIONS = { characterData: true, characterDataOldValue: true, subtree: true, childList: true, }; export function input(editor) { let gboardEnter = false; // Composition systems want to take full control over browser content while they operate. // Let them, deferring handling all mutation events until after the composition is complete. let isComposing = false; let cachedMutations = []; function onCompositionStart(event) { isComposing = true; } function onCompositionEnd(event) { isComposing = false; if (cachedMutations.length) { onMutate(cachedMutations); cachedMutations = []; } } // Browsers have had issues in the past with mutation observers firing consistently, so use the observer with the input // event as fallback function onInput() { if (isComposing) return; const mutations = observer.takeRecords(); if (mutations.length) onMutate(mutations); } // for Gboard fix -- checks if start of line is an insert br function isBr(change) { let isBr = false; const lastOp = change.ops[change.ops.length - 1]; if (lastOp?.insert) { const insert = lastOp.insert; if (insert.br) { isBr = true; } } return isBr; } // Final fallback. Handles composition text etc. Detects text changes from e.g. spell-check or Opt+E to produce function onMutate(list) { if (isComposing) { cachedMutations.push(...list); return; } if (!editor.enabled) { return editor.render(); } // Optimize for text changes (typing text) let change = getTextChange(list); let selection = getSelection(editor); if (!change) { const range = getChangedLineRange(editor.root, list); change = getChangeFromRange(range); } // Gboard fix to move to next line if (gboardEnter) { // Sometimes gBoard adds a br instead of a new line (seen with h2) if (isBr(change)) { change.ops.pop(); change.insert('\n'); } // advance to next line if (selection !== null) { selection[0]++; selection[1]++; } gboardEnter = false; } if (change && change.ops.length) { cleanText(change); const old = editor.doc; editor.update(new TextChange(editor.doc, change, selection, editor.activeFormats), Source.input); if (editor.doc.lines === old.lines) { editor.render(); } } } function getTextChange(list) { const mutation = getTextChangeMutation(list); if (!mutation || mutation.oldValue == null || mutation.target.nodeValue == null) return null; const change = new Delta(); const index = getIndexFromNode(editor, mutation.target); change.retain(index); let relativeEditLocation = undefined; if (editor.doc.selection) { const selection = normalizeRange(editor.doc.selection); relativeEditLocation = selection[0] - index; if (relativeEditLocation < 0) { relativeEditLocation = 0; } } const diffs = diff(mutation.oldValue.replace(/\xA0/g, ' '), mutation.target.nodeValue.replace(/\xA0/g, ' '), relativeEditLocation); diffs.forEach(([action, string]) => { if (action === diff.EQUAL) change.retain(string.length); else if (action === diff.DELETE) change.delete(string.length); else if (action === diff.INSERT) { change.insert(string, editor.activeFormats); } }); change.chop(); return change; } // Fallback to commit whatever was changed, least performant function getChangeFromRange(range) { const { doc } = editor; if (range) { const [startNode, endNode] = range; const start = getLineNodeStart(editor.root, startNode); const end = getLineNodeEnd(editor.root, endNode); const delta = deltaFromDom(editor, { startNode, endNode: endNode.nextElementSibling || undefined, collapseWhitespace: false, }); let change = doc.toDelta().slice(start, end).diff(delta); if (change.ops.length && start) change = new Delta().retain(start).concat(change); return change; } else { const delta = deltaFromDom(editor, { collapseWhitespace: false }); return doc.toDelta().diff(delta); } } const observer = new window.MutationObserver(onMutate); // Don't observe the changes that occur when the view updates, we only want to respond to changes that happen // outside of our API to read them back in function onRendering() { observer.disconnect(); } function onRender() { observer.observe(editor.root, MUTATION_OPTIONS); } // Function to detect if Gboard is sending new lines with composed input function onBeforeInput(event) { if (event.data && event.data?.includes('\n')) { gboardEnter = true; } } return { allowComposition(value = true) { if (value) { editor.root.addEventListener('compositionstart', onCompositionStart); editor.root.addEventListener('compositionend', onCompositionEnd); } else { editor.root.removeEventListener('compositionstart', onCompositionStart); editor.root.removeEventListener('compositionend', onCompositionEnd); } }, init() { editor.root.addEventListener('input', onInput); editor.on('rendering', onRendering); editor.on('render', onRender); if (isAndroid) { editor.root.addEventListener('beforeinput', onBeforeInput); // needed for Gboard fix } }, destroy() { observer.disconnect(); editor.root.removeEventListener('input', onInput); editor.root.removeEventListener('compositionstart', onCompositionStart); editor.root.removeEventListener('compositionend', onCompositionEnd); editor.off('rendering', onRendering); editor.off('render', onRender); if (isAndroid) { editor.root.removeEventListener('beforeinput', onBeforeInput); // gboard fix } }, }; } function getTextChangeMutation(list) { // Shrink the list down to one entry per text node const textNodes = new Set(); list = list.filter(record => { if (record.type !== 'characterData') return true; if (textNodes.has(record.target)) return false; textNodes.add(record.target); return true; }); if (list.length > 3) return null; const text = list.find(record => record.type === 'characterData'); if (!text) return null; const textAdd = list.find(record => record.addedNodes.length === 1 && record.addedNodes[0].nodeName === '#text'); const brAddRemove = list.find(record => { return ((record.addedNodes.length === 1 && record.addedNodes[0].nodeName === 'BR') || (record.removedNodes.length === 1 && record.removedNodes[0].nodeName === 'BR')); }); const count = 1 + (textAdd ? 1 : 0) + (brAddRemove ? 1 : 0); if (count < list.length) return null; if (textAdd && textAdd.addedNodes[0] !== text.target) return null; return text; } function getChangedLineRange(root, records) { let start, end; for (let i = 0; i < records.length; i++) { const record = records[i]; if (record.target === root) return; // line added/removed const line = getTopLine(root, record.target); if (line && line.key) { if (!start || getLineNodeStart(root, line) < getLineNodeStart(root, start)) start = line; if (!end || getLineNodeStart(root, line) > getLineNodeStart(root, end)) end = line; } else { // If a line is deleted or new line added we will return null and diff the whole thing (rare fallback case) return; } } if (start && end) return [start, end]; } function getTopLine(root, node) { while (node && node.parentNode !== root) node = node.parentNode; return node; }