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
JavaScript
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;
}