quill
Version:
Your powerful, rich text editor
351 lines (327 loc) • 11.5 kB
JavaScript
import clone from 'clone';
import equal from 'deep-equal';
import extend from 'extend';
import Delta, { AttributeMap } from 'quill-delta';
import { LeafBlot } from 'parchment';
import { Range } from './selection';
import CursorBlot from '../blots/cursor';
import Block, { BlockEmbed, bubbleFormats } from '../blots/block';
import Break from '../blots/break';
import TextBlot, { escapeText } from '../blots/text';
const ASCII = /^[ -~]*$/;
class Editor {
constructor(scroll) {
this.scroll = scroll;
this.delta = this.getDelta();
}
applyDelta(delta) {
let consumeNextNewline = false;
this.scroll.update();
let scrollLength = this.scroll.length();
this.scroll.batchStart();
const normalizedDelta = normalizeDelta(delta);
normalizedDelta.reduce((index, op) => {
const length = op.retain || op.delete || op.insert.length || 1;
let attributes = op.attributes || {};
if (op.insert != null) {
if (typeof op.insert === 'string') {
let text = op.insert;
if (text.endsWith('\n') && consumeNextNewline) {
consumeNextNewline = false;
text = text.slice(0, -1);
}
if (
(index >= scrollLength ||
this.scroll.descendant(BlockEmbed, index)[0]) &&
!text.endsWith('\n')
) {
consumeNextNewline = true;
}
this.scroll.insertAt(index, text);
const [line, offset] = this.scroll.line(index);
let formats = extend({}, bubbleFormats(line));
if (line instanceof Block) {
const [leaf] = line.descendant(LeafBlot, offset);
formats = extend(formats, bubbleFormats(leaf));
}
attributes = AttributeMap.diff(formats, attributes) || {};
} else if (typeof op.insert === 'object') {
const key = Object.keys(op.insert)[0]; // There should only be one key
if (key == null) return index;
this.scroll.insertAt(index, key, op.insert[key]);
}
scrollLength += length;
}
Object.keys(attributes).forEach(name => {
this.scroll.formatAt(index, length, name, attributes[name]);
});
return index + length;
}, 0);
normalizedDelta.reduce((index, op) => {
if (typeof op.delete === 'number') {
this.scroll.deleteAt(index, op.delete);
return index;
}
return index + (op.retain || op.insert.length || 1);
}, 0);
this.scroll.batchEnd();
this.scroll.optimize();
return this.update(normalizedDelta);
}
deleteText(index, length) {
this.scroll.deleteAt(index, length);
return this.update(new Delta().retain(index).delete(length));
}
formatLine(index, length, formats = {}) {
this.scroll.update();
Object.keys(formats).forEach(format => {
this.scroll.lines(index, Math.max(length, 1)).forEach(line => {
line.format(format, formats[format]);
});
});
this.scroll.optimize();
const delta = new Delta().retain(index).retain(length, clone(formats));
return this.update(delta);
}
formatText(index, length, formats = {}) {
Object.keys(formats).forEach(format => {
this.scroll.formatAt(index, length, format, formats[format]);
});
const delta = new Delta().retain(index).retain(length, clone(formats));
return this.update(delta);
}
getContents(index, length) {
return this.delta.slice(index, index + length);
}
getDelta() {
return this.scroll.lines().reduce((delta, line) => {
return delta.concat(line.delta());
}, new Delta());
}
getFormat(index, length = 0) {
let lines = [];
let leaves = [];
if (length === 0) {
this.scroll.path(index).forEach(path => {
const [blot] = path;
if (blot instanceof Block) {
lines.push(blot);
} else if (blot instanceof LeafBlot) {
leaves.push(blot);
}
});
} else {
lines = this.scroll.lines(index, length);
leaves = this.scroll.descendants(LeafBlot, index, length);
}
const formatsArr = [lines, leaves].map(blots => {
if (blots.length === 0) return {};
let formats = bubbleFormats(blots.shift());
while (Object.keys(formats).length > 0) {
const blot = blots.shift();
if (blot == null) return formats;
formats = combineFormats(bubbleFormats(blot), formats);
}
return formats;
});
return extend.apply(extend, formatsArr);
}
getHTML(index, length) {
const [line, lineOffset] = this.scroll.line(index);
if (line.length() >= lineOffset + length) {
return convertHTML(line, lineOffset, length, true);
}
return convertHTML(this.scroll, index, length, true);
}
getText(index, length) {
return this.getContents(index, length)
.filter(op => typeof op.insert === 'string')
.map(op => op.insert)
.join('');
}
insertEmbed(index, embed, value) {
this.scroll.insertAt(index, embed, value);
return this.update(new Delta().retain(index).insert({ [embed]: value }));
}
insertText(index, text, formats = {}) {
text = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
this.scroll.insertAt(index, text);
Object.keys(formats).forEach(format => {
this.scroll.formatAt(index, text.length, format, formats[format]);
});
return this.update(new Delta().retain(index).insert(text, clone(formats)));
}
isBlank() {
if (this.scroll.children.length === 0) return true;
if (this.scroll.children.length > 1) return false;
const block = this.scroll.children.head;
if (block.statics.blotName !== Block.blotName) return false;
if (block.children.length > 1) return false;
return block.children.head instanceof Break;
}
removeFormat(index, length) {
const text = this.getText(index, length);
const [line, offset] = this.scroll.line(index + length);
let suffixLength = 0;
let suffix = new Delta();
if (line != null) {
suffixLength = line.length() - offset;
suffix = line
.delta()
.slice(offset, offset + suffixLength - 1)
.insert('\n');
}
const contents = this.getContents(index, length + suffixLength);
const diff = contents.diff(new Delta().insert(text).concat(suffix));
const delta = new Delta().retain(index).concat(diff);
return this.applyDelta(delta);
}
update(change, mutations = [], selectionInfo = undefined) {
const oldDelta = this.delta;
if (
mutations.length === 1 &&
mutations[0].type === 'characterData' &&
mutations[0].target.data.match(ASCII) &&
this.scroll.find(mutations[0].target)
) {
// Optimization for character changes
const textBlot = this.scroll.find(mutations[0].target);
const formats = bubbleFormats(textBlot);
const index = textBlot.offset(this.scroll);
const oldValue = mutations[0].oldValue.replace(CursorBlot.CONTENTS, '');
const oldText = new Delta().insert(oldValue);
const newText = new Delta().insert(textBlot.value());
const relativeSelectionInfo = selectionInfo && {
oldRange: shiftRange(selectionInfo.oldRange, -index),
newRange: shiftRange(selectionInfo.newRange, -index),
};
const diffDelta = new Delta()
.retain(index)
.concat(oldText.diff(newText, relativeSelectionInfo));
change = diffDelta.reduce((delta, op) => {
if (op.insert) {
return delta.insert(op.insert, formats);
}
return delta.push(op);
}, new Delta());
this.delta = oldDelta.compose(change);
} else {
this.delta = this.getDelta();
if (!change || !equal(oldDelta.compose(change), this.delta)) {
change = oldDelta.diff(this.delta, selectionInfo);
}
}
return change;
}
}
function convertListHTML(items, lastIndent, types) {
if (items.length === 0) {
const [endTag] = getListType(types.pop());
if (lastIndent <= 0) {
return `</li></${endTag}>`;
}
return `</li></${endTag}>${convertListHTML([], lastIndent - 1, types)}`;
}
const [{ child, offset, length, indent, type }, ...rest] = items;
const [tag, attribute] = getListType(type);
if (indent > lastIndent) {
types.push(type);
if (indent === lastIndent + 1) {
return `<${tag}><li${attribute}>${convertHTML(
child,
offset,
length,
)}${convertListHTML(rest, indent, types)}`;
}
return `<${tag}><li>${convertListHTML(items, lastIndent + 1, types)}`;
}
const previousType = types[types.length - 1];
if (indent === lastIndent && type === previousType) {
return `</li><li${attribute}>${convertHTML(
child,
offset,
length,
)}${convertListHTML(rest, indent, types)}`;
}
const [endTag] = getListType(types.pop());
return `</li></${endTag}>${convertListHTML(items, lastIndent - 1, types)}`;
}
function convertHTML(blot, index, length, isRoot = false) {
if (typeof blot.html === 'function') {
return blot.html(index, length);
}
if (blot instanceof TextBlot) {
return escapeText(blot.value().slice(index, index + length));
}
if (blot.children) {
// TODO fix API
if (blot.statics.blotName === 'list-container') {
const items = [];
blot.children.forEachAt(index, length, (child, offset, childLength) => {
const formats = child.formats();
items.push({
child,
offset,
length: childLength,
indent: formats.indent || 0,
type: formats.list,
});
});
return convertListHTML(items, -1, []);
}
const parts = [];
blot.children.forEachAt(index, length, (child, offset, childLength) => {
parts.push(convertHTML(child, offset, childLength));
});
if (isRoot || blot.statics.blotName === 'list') {
return parts.join('');
}
const { outerHTML, innerHTML } = blot.domNode;
const [start, end] = outerHTML.split(`>${innerHTML}<`);
// TODO cleanup
if (start === '<table') {
return `<table style="border: 1px solid #000;">${parts.join('')}<${end}`;
}
return `${start}>${parts.join('')}<${end}`;
}
return blot.domNode.outerHTML;
}
function combineFormats(formats, combined) {
return Object.keys(combined).reduce((merged, name) => {
if (formats[name] == null) return merged;
if (combined[name] === formats[name]) {
merged[name] = combined[name];
} else if (Array.isArray(combined[name])) {
if (combined[name].indexOf(formats[name]) < 0) {
merged[name] = combined[name].concat([formats[name]]);
}
} else {
merged[name] = [combined[name], formats[name]];
}
return merged;
}, {});
}
function getListType(type) {
const tag = type === 'ordered' ? 'ol' : 'ul';
switch (type) {
case 'checked':
return [tag, ' data-list="checked"'];
case 'unchecked':
return [tag, ' data-list="unchecked"'];
default:
return [tag, ''];
}
}
function normalizeDelta(delta) {
return delta.reduce((normalizedDelta, op) => {
if (typeof op.insert === 'string') {
const text = op.insert.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
return normalizedDelta.insert(text, op.attributes);
}
return normalizedDelta.push(op);
}, new Delta());
}
function shiftRange({ index, length }, amount) {
return new Range(index + amount, length);
}
export default Editor;