typewriter-editor
Version:
A rich text editor using the Delta format with decorations and rendered with a tiny virtual dom
509 lines (508 loc) • 19.5 kB
JavaScript
import { AttributeMap, Delta, Line, TextChange, TextDocument, hasFormat, isEqual, normalizeRange, } from '@typewriter/document';
import { Source } from './Source';
import { defaultModules } from './modules/defaults';
import { docFromHTML, docToHTML } from './rendering/html';
import { getBoudingBrowserRange, getIndexFromPoint } from './rendering/position';
import { defaultTypes } from './typesetting/defaults';
import { Types, Typeset } from './typesetting/typeset';
import { EventDispatcher } from './util/EventDispatcher';
const EMPTY_OBJ = {};
const EMPTY_ARR = [];
const PROXIED_EVENTS = ['focus', 'blur', 'keydown', 'mousedown', 'mouseup', 'click'];
const eventProxies = new WeakMap();
export class EditorChangeEvent extends Event {
old;
doc;
change;
changedLines;
source;
constructor(type, init) {
super(type, init);
this.old = init.old;
this.doc = init.doc;
this.change = init.change;
this.changedLines = init.changedLines;
this.source = init.source;
// Fix Safari bug, see https://stackoverflow.com/a/58471803
Object.setPrototypeOf(this, EditorChangeEvent.prototype);
}
// Modify the data during a "changing" event before doc is committed
modify(delta) {
if (!this.cancelable)
throw new Error('Cannot modify an applied change, listen to the "changing" event');
this.doc = this.doc.apply(delta);
if (this.change)
this.change.delta = this.change.delta.compose(delta);
if (this.changedLines) {
this.changedLines = this.old.lines === this.doc.lines ? EMPTY_ARR : getChangedLines(this.old, this.doc);
}
}
}
export class EditorFormatEvent extends Event {
formats;
constructor(type, init) {
super(type, init);
this.formats = init.formats;
}
}
export class Editor extends EventDispatcher {
identifier;
typeset;
doc;
activeFormats = EMPTY_OBJ;
commands = {};
shortcuts = {};
modules = {};
catchErrors;
throwOnError;
_root;
_modules;
_enabled;
constructor(options = {}) {
super();
this.catchErrors = !options.dev;
this.identifier = options.identifier;
this.typeset = new Typeset(options.types || defaultTypes);
if (options.doc) {
this.doc = options.doc;
}
else if (options.html) {
this.doc = docFromHTML(this, options.html);
}
else if (options.text) {
this.doc = new TextDocument(new Delta().insert(options.text));
}
else {
this.doc = new TextDocument();
}
this.throwOnError = options.throwOnError || false;
this._enabled = options.enabled === undefined ? true : options.enabled;
const includeDefaultModules = options.includeDefaultModules ?? true;
this._modules = includeDefaultModules ? { ...defaultModules, ...options.modules } : { ...options.modules };
if (options.root)
this.setRoot(options.root);
}
get root() {
if (!this._root) {
this.setRoot(document.createElement('div'));
}
return this._root;
}
get enabled() {
return this._enabled;
}
set enabled(value) {
value = !!value;
const changed = this._enabled !== value;
if (!value && this.doc.selection)
this.select(null, Source.api);
this._enabled = value;
if (this._root)
this._root.contentEditable = value ? 'true' : 'inherit';
if (changed)
this.dispatchEvent(new Event('enabledchange'));
}
get change() {
const change = new TextChange(this.doc);
change.apply = (source = Source.user) => this.update(change, source);
return change;
}
setRoot(root) {
if (!root)
throw new TypeError('Root must be set, cannot be ' + root);
this.destroy();
this._root = root;
this.init();
this.dispatchEvent(new Event('root'));
return this;
}
update(changeOrDelta, source = Source.user) {
if (!this.enabled && source !== Source.api) {
return this;
}
const change = changeOrDelta.ops
? new TextChange(this.doc, changeOrDelta)
: changeOrDelta;
const old = this.doc;
const doc = old.apply(change, undefined, this.throwOnError);
const changedLines = old.lines === doc.lines ? EMPTY_ARR : getChangedLines(old, doc);
this.set(doc, source, change, changedLines);
return this;
}
set(docOrDelta, source = Source.user, change, changedLines) {
const old = this.doc;
const doc = docOrDelta.ops ? new TextDocument(docOrDelta) : docOrDelta;
if ((!this.enabled && source !== Source.api) || !doc || old.equals(doc)) {
return this;
}
const changingEvent = new EditorChangeEvent('changing', {
cancelable: true,
old,
doc,
change,
changedLines,
source,
});
this.dispatchEvent(changingEvent, this.catchErrors);
if (changingEvent.defaultPrevented || old.equals(changingEvent.doc))
return this;
this.activeFormats = change?.activeFormats ? change.activeFormats : getActiveFormats(this, changingEvent.doc);
this.doc = changingEvent.doc;
this.dispatchEvent(new EditorChangeEvent('change', { ...changingEvent, cancelable: false }), this.catchErrors);
this.dispatchEvent(new EditorChangeEvent('changed', { ...changingEvent, cancelable: false }), this.catchErrors);
return this;
}
getHTML() {
return docToHTML(this, this.doc);
}
setHTML(html, selection = this.doc.selection, source) {
return this.set(docFromHTML(this, html, selection), source);
}
getDelta() {
return this.doc.toDelta();
}
setDelta(delta, selection = this.doc.selection, source) {
return this.set(new TextDocument(delta, selection), source);
}
getText(range) {
return this.doc.getText(range);
}
setText(text, selection = this.doc.selection, source) {
return this.set(new TextDocument(new Delta().insert(text), selection), source);
}
trimSelection(selection) {
if (!selection)
return selection;
const selectedText = this.getText(selection);
const [from, to] = normalizeRange([...selection]);
// don't trim if all spaces are selected
if (selectedText.trim()) {
const [_, lead, text, tail] = selectedText.match(/(^ *)((?:.|\r|\n)*?)( *$)/);
if (text && (lead || tail)) {
return [from + lead.length, to - tail.length];
}
}
return selection;
}
getActive() {
const { selection } = this.doc;
let active = selection
? selection[0] === selection[1]
? { ...this.activeFormats, ...this.doc.getLineFormat(selection) }
: { ...this.doc.getFormats(selection) }
: {};
Object.values(this.modules).forEach(module => {
if (module.getActive)
active = { ...active, ...module.getActive() };
});
return active;
}
select(at, source) {
return this.update(this.change.select(at), source);
}
insert(insert, format, selection = this.doc.selection, options) {
if (!selection)
return this;
const inPlace = isEqual(selection, this.doc.selection);
if (format == null && typeof insert === 'string' && insert !== '\n') {
format = inPlace ? this.activeFormats : getActiveFormats(this, this.doc, selection);
}
const type = this.typeset.lines.findByAttributes(format, true);
const change = this.change.delete(selection);
const at = normalizeRange(selection)[0];
if (inPlace)
change.setActiveFormats((insert !== '\n' && format) || getActiveFormats(this, this.doc, selection));
if (insert === '\n' && type.frozen) {
const lineFormat = { ...this.doc.getLineFormat(at) };
const secondLine = { ...format };
let lastLine = { ...lineFormat };
const newlines = new Delta().insert('\n', lineFormat);
if (this.doc.getLineRange(at)[1] - 1 !== at) {
newlines.insert('\n', secondLine);
}
else {
lastLine = secondLine;
}
change
.insertContent(at, newlines)
.formatLine(at, lastLine)
.select(at + 2);
}
else {
change.insert(at, insert, format, options);
}
return this.update(change);
}
insertContent(content, selection = this.doc.selection) {
if (!selection)
return this;
const change = this.change.delete(selection).insertContent(selection[0], content);
return this.update(change);
}
delete(directionOrSelection, options) {
let range;
let selectOffset = 0;
const { typeset: { lines }, doc, } = this;
if (Array.isArray(directionOrSelection)) {
range = normalizeRange(directionOrSelection);
}
else {
if (!this.doc.selection)
return this;
range = normalizeRange(this.doc.selection);
if (directionOrSelection) {
if (range[0] === range[1]) {
if (directionOrSelection < 0)
range = [range[0] + directionOrSelection, range[1]];
else
range = [range[0], Math.min(range[1] + directionOrSelection, this.doc.length - 1)];
}
else if (directionOrSelection < 0 &&
lines.findByAttributes(doc.getLineAt(range[0]).attributes, true).frozen) {
selectOffset = -1;
}
}
}
const formats = getActiveFormats(this, this.doc, [range[0] + 1, range[0] + 1]);
const docLength = this.doc.length - (range[1] - range[0]);
let select = Math.max(0, Math.min(docLength - 1, range[0] + selectOffset));
const change = this.change.delete(range, options).select(select).setActiveFormats(formats);
return this.update(change);
}
formatText(format, selection = this.doc.selection) {
if (!selection)
return this;
if (typeof format === 'string')
format = { [format]: true };
if (selection[0] === selection[1]) {
this.activeFormats = AttributeMap.compose(this.activeFormats, format) || EMPTY_OBJ;
this.dispatchEvent(new EditorFormatEvent('format', { formats: this.activeFormats }));
return this;
}
changeFormat(this, 'formatText', format, selection);
return this;
}
toggleTextFormat(format, selection = this.doc.selection) {
if (!selection)
return this;
if (typeof format === 'string')
format = { [format]: true };
if (selection[0] === selection[1]) {
if (hasFormat(format, this.activeFormats))
format = AttributeMap.invert(format);
this.activeFormats = AttributeMap.compose(this.activeFormats, format) || EMPTY_OBJ;
this.dispatchEvent(new EditorFormatEvent('format', { formats: this.activeFormats }));
return this;
}
changeFormat(this, 'toggleTextFormat', format, selection);
return this;
}
formatLine(format, selection = this.doc.selection) {
if (typeof format === 'string')
format = { [format]: true };
changeFormat(this, 'formatLine', format, selection);
return this;
}
toggleLineFormat(format, selection = this.doc.selection) {
if (typeof format === 'string')
format = { [format]: true };
changeFormat(this, 'toggleLineFormat', format, selection);
return this;
}
indent() {
indentLines(this, 1);
return this;
}
outdent() {
indentLines(this, -1);
return this;
}
removeFormat(selection = this.doc.selection) {
changeFormat(this, 'removeFormat', null, selection);
return this;
}
getBounds(range, relativeTo, relativeInside) {
if (typeof range === 'number')
range = [range, range];
if (!range)
return undefined;
let rect = getBoudingBrowserRange(this, range)?.getBoundingClientRect();
if (rect && relativeTo) {
const relative = relativeTo.getBoundingClientRect();
const leftOffset = (relativeInside ? relativeTo.scrollLeft : 0) - relative.x;
const topOffset = (relativeInside ? relativeTo.scrollTop : 0) - relative.y;
rect = new DOMRect(rect.x + leftOffset, rect.y + topOffset, rect.width, rect.height);
}
return rect;
}
getAllBounds(range, relativeTo, relativeInside) {
if (typeof range === 'number')
range = [range, range];
const collection = getBoudingBrowserRange(this, range)?.getClientRects();
let list = collection && Array.from(collection);
if (list && relativeTo) {
const relative = relativeTo.getBoundingClientRect();
const leftOffset = (relativeInside ? relativeTo.scrollLeft : 0) - relative.x;
const topOffset = (relativeInside ? relativeTo.scrollTop : 0) - relative.y;
list = list.map(rect => new DOMRect(rect.x + leftOffset, rect.y + topOffset, rect.width, rect.height));
}
return list;
}
getIndexFromPoint(x, y) {
return getIndexFromPoint(this, x, y);
}
render() {
this.modules.decorations?.gatherDecorations();
this.modules.rendering?.render();
this.modules.selection?.renderSelection();
return this;
}
init() {
const root = this._root;
if (root.editor)
root.editor.destroy();
root.editor = this;
this.enabled = this._enabled;
this.commands = {};
PROXIED_EVENTS.forEach(type => this._root.addEventListener(type, getEventProxy(this)));
this.typeset.lines.list.forEach(type => type.commands && mergeCommands(this, type.name, type.commands(this)));
this.typeset.formats.list.forEach(type => type.commands && mergeCommands(this, type.name, type.commands(this)));
this.typeset.embeds.list.forEach(type => type.commands && mergeCommands(this, type.name, type.commands(this)));
Object.keys(this._modules).forEach(key => {
if (!this._modules[key])
return;
const module = (this.modules[key] = this._modules[key](this));
if (module.commands)
mergeCommands(this, key, module.commands);
});
this.shortcuts = createShortcutMap(this);
Object.keys(this.modules).forEach(key => this.modules[key].init?.());
this.render();
}
destroy() {
const root = this._root;
if (!root)
return;
PROXIED_EVENTS.forEach(type => root.removeEventListener(type, getEventProxy(this)));
Object.values(this.modules).forEach(module => module.destroy && module.destroy());
this._root = undefined;
delete root.editor;
}
}
function changeFormat(editor, op, format, selection) {
if (!selection)
return;
selection =
typeof selection === 'number' ? [selection, selection] : editor?.trimSelection(selection);
if (!(op in editor.change))
throw new Error('Invalid operation: ' + op);
const change = editor.change[op](selection, format);
editor.update(change);
}
function getActiveFormats(editor, doc, selection = doc.selection) {
const { formats } = editor.typeset;
if (!selection || selection[0] === 0)
return EMPTY_OBJ;
const at = normalizeRange(selection)[0];
// If start of a non-empty line, use the format of the first character, otherwise use the format of the preceeding
let formatAt = at;
let formatTo = at + 1;
const attributes = doc.getTextFormat(formatAt);
const nextAttributes = doc.getTextFormat(formatTo);
const format = {};
// Sort them by the order found in marks and be efficient
Object.keys(attributes).forEach(name => {
const type = formats.get(name);
if (type && type.greedy !== false) {
format[name] = attributes[name];
}
});
Object.keys(nextAttributes).forEach(name => {
const type = formats.get(name);
if (type && type.greedy === false) {
format[name] = attributes[name];
}
});
return format;
}
function getChangedLines(oldDoc, newDoc) {
const set = new Set(oldDoc.lines);
return newDoc.lines.filter(line => !set.has(line));
}
function mergeCommands(editor, name, other) {
if (!other)
return;
if (typeof other === 'function')
editor.commands[name] = enhanceCommand(editor, other);
else
Object.keys(other).forEach(key => (editor.commands[key] = enhanceCommand(editor, other[key])));
}
function enhanceCommand(editor, command) {
return (...args) => {
const result = command(...args);
if (editor.doc.selection)
editor.root.focus();
return result;
};
}
function indentLines(editor, direction = 1) {
const { typeset: { lines }, doc, } = editor;
const { selection } = doc;
if (!selection)
return doc;
const change = editor.change;
doc.getLinesAt(selection).forEach(line => {
const type = lines.findByAttributes(line.attributes, true);
if (!type.indentable)
return;
const range = doc.getLineRange(line);
let indent = (line.attributes.indent || 0) + direction;
if (indent === 0)
indent = null;
change.formatLine(range[0], indent < 0 ? EMPTY_OBJ : { ...line.attributes, indent });
});
editor.update(change);
}
function getEventProxy(editor) {
let proxy = eventProxies.get(editor);
if (!proxy) {
proxy = eventProxy.bind(editor);
eventProxies.set(editor, proxy);
}
return proxy;
}
function eventProxy(event) {
this.dispatchEvent(event);
}
function createShortcutMap(editor) {
const all = {};
const { typeset: { lines, formats, embeds }, modules, } = editor;
mergeTypeShortcuts(lines, all);
mergeTypeShortcuts(formats, all);
mergeTypeShortcuts(embeds, all);
mergeModuleShortcuts(modules, all);
return all;
}
function mergeTypeShortcuts(types, shortcuts) {
types.list.forEach(type => {
const typeShortcuts = type.shortcuts;
if (!typeShortcuts)
return;
if (typeof typeShortcuts === 'string') {
shortcuts[typeShortcuts] = type.name;
}
else {
mergeShortcuts(typeShortcuts, shortcuts);
}
});
}
function mergeModuleShortcuts(modules, all) {
Object.keys(modules).forEach(name => {
const shortcuts = modules[name]?.shortcuts;
if (shortcuts)
mergeShortcuts(shortcuts, all);
});
}
function mergeShortcuts(shortcuts, all) {
Object.keys(shortcuts).forEach(shortcut => (all[shortcut] = shortcuts[shortcut]));
}