UNPKG

typewriter-editor

Version:

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

198 lines (197 loc) 7.7 kB
import { AttributeMap, Delta } from '@typewriter/document'; import { Editor, EditorChangeEvent } from '../Editor'; const httpExpr = /(https?:\/\/.)[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b[-a-zA-Z0-9@:%_+.~#?&/=]*\s$/s; const wwwExpr = /(www\.)[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b[-a-zA-Z0-9@:%_+.~#?&/=]*\s$/s; const nakedExpr = /[-a-zA-Z0-9@:%._\+~#=]{2,256}\.(com|org|net|io)\b[-a-zA-Z0-9@:%_+.~#?&/=]*\s$/s; /** * A list of [ RegExp, Function ] tuples to convert text into a formatted line with the attributes returned by the * function. The function's argument will be the captured text from the regular expression. */ export const lineReplacements = [ [/^(#{1,6}) $/, capture => ({ header: capture.length })], [/^\* $/, (_, { indent }) => ({ list: 'bullet', indent })], [/^- $/, (_, { indent }) => ({ list: 'bullet', type: 'dash', indent })], // set the type to dash to allow for styling in-app (e.g. `list-style-type: "- ";`) [/^1\. $/, (_, { indent }) => ({ list: 'ordered', indent })], [/^([AaIi])\. $/, (type, { indent }) => ({ list: 'ordered', type, indent })], [/^(-?\d+)\. $/, (start, { indent }) => ({ list: 'ordered', start, indent })], // Use /^(-?\d+)\. $/ to support lists starting at something other than 1. [ /^([A-Z])\. $/, (char, { indent }) => ({ list: 'ordered', type: 'A', indent, start: char === 'A' ? undefined : char.charCodeAt(0) - 'A'.charCodeAt(0) + 1, }), ], [ /^([a-z])\. $/, (char, { indent }) => ({ list: 'ordered', type: 'a', indent, start: char === 'a' ? undefined : char.charCodeAt(0) - 'a'.charCodeAt(0) + 1, }), ], [ /^([IVXLCDM]+)\. $/i, (chars, { indent }) => ({ list: 'ordered', type: chars[0].toUpperCase() === chars[0] ? 'I' : 'i', indent, start: chars.toUpperCase() === 'I' ? undefined : fromRomanNumeral(chars), }), ], [/^> $/, () => ({ blockquote: true })], ]; /** * A list of [ RegExp, Function ] tuples to convert text into formatted text with the attributes returned by the * function. The function's argument will be the captured text from the regular expression. */ export const markReplacements = [ [/(\*|_){3}(\b(?:(?!\1).)+\b)\1{3}((?:(?!\1).))$/s, () => ({ bold: true, italic: true })], [/(\*|_){2}(\b(?:(?!\1).)+\b)\1{2}((?:(?!\1).))$/s, () => ({ bold: true })], [/(\*|_){1}(\b(?:(?!\1).)+\b)\1{1}((?:(?!\1).))$/s, () => ({ italic: true })], ]; export const linkReplacements = [ [httpExpr, capture => ({ link: capture })], [wwwExpr, capture => ({ link: 'https://' + capture })], [nakedExpr, capture => ({ link: 'https://' + capture })], ]; /** * A list of [ RegExp, Function ] tuples to convert text into another string of text which is returned by the function. * The function's argument will be the captured text from the regular expression. */ export const textReplacements = [ [/--$/, () => '—'], [/(\S - \S)$/, _ => _.replace('-', '–')], [/\.\.\.$/, () => '…'], ]; /** * Allow text representations to format a line */ export function lineReplace(editor, index, prefix) { return lineReplacements.some(([regexp, getAttributes]) => { const match = prefix.match(regexp); if (match) { const attributes = getAttributes(match[1], editor.doc.getLineFormat(index)); if (!editor.typeset.lines.findByAttributes(attributes)) { return false; } const start = index - prefix.length; const change = editor.change.delete([start, index]).formatLine(index, attributes).select([start, start]); editor.update(change); return true; } else { return false; } }); } export function linkReplace(editor, index, prefix) { return linkReplacements.some(([regexp, getAttributes]) => { const match = prefix.match(regexp); if (match) { let text = match[0].slice(0, -1); if (text[text.length - 1] === '.') text = text.slice(0, -1); const end = index - (match[0].length - text.length); const attributes = getAttributes(text, editor.doc.getTextFormat(index)); if (!editor.typeset.formats.findByAttributes(attributes)) { return false; } editor.formatText(attributes, [end - text.length, end]); return true; } else { return false; } }); } export function markReplace(editor, index, prefix, wholeText) { return markReplacements.some(([regexp, getAttributes]) => { const match = prefix.match(regexp); if (match) { let [text, _, matched, last] = match; const attributes = getAttributes(matched, editor.doc.getTextFormat(index)); if (!editor.typeset.formats.findByAttributes(attributes)) { return false; } let selection = index - (text.length - matched.length) + last.length; if (last === ' ' && wholeText[index] === ' ') last = ''; const end = index - last.length; editor.insert(matched, attributes, [end - text.length + last.length, end]); return true; } else { return false; } }); } export function textReplace(editor, index, prefix) { return textReplacements.some(([regexp, replaceWith]) => { const match = prefix.match(regexp); if (match) { editor.insert(replaceWith(match[1]), undefined, [index - match[0].length, index]); return true; } else { return false; } }); } export const defaultHandlers = [lineReplace, textReplace, linkReplace]; export function smartEntry(handlers = defaultHandlers) { return (editor) => { let ignore = false; function onTextChange({ change, source }) { if (ignore || source === 'api' || !editor.doc.selection || !change || !isTextEntry(change.delta)) return; const index = editor.doc.selection[1]; const text = editor.doc.getText(); const lineStart = text.lastIndexOf('\n', index - 2) + 1; const prefix = text.slice(lineStart, index); ignore = true; handlers.some(handler => handler(editor, index, prefix, text)); ignore = false; } editor.on('changed', onTextChange); return { destroy() { editor.off('changed', onTextChange); }, }; }; } function isTextEntry(change) { return ((change.ops.length === 1 || (change.ops.length === 2 && change.ops[0].retain && !change.ops[0].attributes)) && change.ops[change.ops.length - 1].insert); // && // change.ops[change.ops.length - 1].insert !== '\n'; } const DIGIT_VALUES = { I: 1, V: 5, X: 10, L: 50, C: 100, D: 500, M: 1000, }; function fromRomanNumeral(romanNumeral) { romanNumeral = romanNumeral.toUpperCase(); let result = 0; for (let i = 0; i < romanNumeral.length; i++) { const currentLetter = DIGIT_VALUES[romanNumeral[i]]; const nextLetter = DIGIT_VALUES[romanNumeral[i + 1]]; if (currentLetter === undefined) return undefined; if (currentLetter < nextLetter) { result += nextLetter - currentLetter; i++; } else { result += currentLetter; } } return result; }