404 lines
15 KiB
JavaScript
404 lines
15 KiB
JavaScript
import { cloneDeep, isEqual, merge } from 'lodash-es';
|
|
import { LeafBlot, EmbedBlot, Scope, ParentBlot } from 'parchment';
|
|
import Delta, { AttributeMap, Op } from 'quill-delta';
|
|
import Block, { BlockEmbed, bubbleFormats } from '../blots/block.js';
|
|
import Break from '../blots/break.js';
|
|
import CursorBlot from '../blots/cursor.js';
|
|
import TextBlot, { escapeText } from '../blots/text.js';
|
|
import { Range } from './selection.js';
|
|
const ASCII = /^[ -~]*$/;
|
|
class Editor {
|
|
constructor(scroll) {
|
|
this.scroll = scroll;
|
|
this.delta = this.getDelta();
|
|
}
|
|
applyDelta(delta) {
|
|
this.scroll.update();
|
|
let scrollLength = this.scroll.length();
|
|
this.scroll.batchStart();
|
|
const normalizedDelta = normalizeDelta(delta);
|
|
const deleteDelta = new Delta();
|
|
const normalizedOps = splitOpLines(normalizedDelta.ops.slice());
|
|
normalizedOps.reduce((index, op) => {
|
|
const length = Op.length(op);
|
|
let attributes = op.attributes || {};
|
|
let isImplicitNewlinePrepended = false;
|
|
let isImplicitNewlineAppended = false;
|
|
if (op.insert != null) {
|
|
deleteDelta.retain(length);
|
|
if (typeof op.insert === 'string') {
|
|
const text = op.insert;
|
|
isImplicitNewlineAppended = !text.endsWith('\n') && (scrollLength <= index || !!this.scroll.descendant(BlockEmbed, index)[0]);
|
|
this.scroll.insertAt(index, text);
|
|
const [line, offset] = this.scroll.line(index);
|
|
let formats = merge({}, bubbleFormats(line));
|
|
if (line instanceof Block) {
|
|
const [leaf] = line.descendant(LeafBlot, offset);
|
|
if (leaf) {
|
|
formats = merge(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;
|
|
const isInlineEmbed = this.scroll.query(key, Scope.INLINE) != null;
|
|
if (isInlineEmbed) {
|
|
if (scrollLength <= index || !!this.scroll.descendant(BlockEmbed, index)[0]) {
|
|
isImplicitNewlineAppended = true;
|
|
}
|
|
} else if (index > 0) {
|
|
const [leaf, offset] = this.scroll.descendant(LeafBlot, index - 1);
|
|
if (leaf instanceof TextBlot) {
|
|
const text = leaf.value();
|
|
if (text[offset] !== '\n') {
|
|
isImplicitNewlinePrepended = true;
|
|
}
|
|
} else if (leaf instanceof EmbedBlot && leaf.statics.scope === Scope.INLINE_BLOT) {
|
|
isImplicitNewlinePrepended = true;
|
|
}
|
|
}
|
|
this.scroll.insertAt(index, key, op.insert[key]);
|
|
if (isInlineEmbed) {
|
|
const [leaf] = this.scroll.descendant(LeafBlot, index);
|
|
if (leaf) {
|
|
const formats = merge({}, bubbleFormats(leaf));
|
|
attributes = AttributeMap.diff(formats, attributes) || {};
|
|
}
|
|
}
|
|
}
|
|
scrollLength += length;
|
|
} else {
|
|
deleteDelta.push(op);
|
|
if (op.retain !== null && typeof op.retain === 'object') {
|
|
const key = Object.keys(op.retain)[0];
|
|
if (key == null) return index;
|
|
this.scroll.updateEmbedAt(index, key, op.retain[key]);
|
|
}
|
|
}
|
|
Object.keys(attributes).forEach(name => {
|
|
this.scroll.formatAt(index, length, name, attributes[name]);
|
|
});
|
|
const prependedLength = isImplicitNewlinePrepended ? 1 : 0;
|
|
const addedLength = isImplicitNewlineAppended ? 1 : 0;
|
|
scrollLength += prependedLength + addedLength;
|
|
deleteDelta.retain(prependedLength);
|
|
deleteDelta.delete(addedLength);
|
|
return index + length + prependedLength + addedLength;
|
|
}, 0);
|
|
deleteDelta.reduce((index, op) => {
|
|
if (typeof op.delete === 'number') {
|
|
this.scroll.deleteAt(index, op.delete);
|
|
return index;
|
|
}
|
|
return index + Op.length(op);
|
|
}, 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) {
|
|
let formats = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : {};
|
|
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, cloneDeep(formats));
|
|
return this.update(delta);
|
|
}
|
|
formatText(index, length) {
|
|
let formats = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : {};
|
|
Object.keys(formats).forEach(format => {
|
|
this.scroll.formatAt(index, length, format, formats[format]);
|
|
});
|
|
const delta = new Delta().retain(index).retain(length, cloneDeep(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) {
|
|
let length = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 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 [lineFormats, leafFormats] = [lines, leaves].map(blots => {
|
|
const blot = blots.shift();
|
|
if (blot == null) return {};
|
|
let formats = bubbleFormats(blot);
|
|
while (Object.keys(formats).length > 0) {
|
|
const blot = blots.shift();
|
|
if (blot == null) return formats;
|
|
formats = combineFormats(bubbleFormats(blot), formats);
|
|
}
|
|
return formats;
|
|
});
|
|
return {
|
|
...lineFormats,
|
|
...leafFormats
|
|
};
|
|
}
|
|
getHTML(index, length) {
|
|
const [line, lineOffset] = this.scroll.line(index);
|
|
if (line) {
|
|
const lineLength = line.length();
|
|
const isWithinLine = line.length() >= lineOffset + length;
|
|
if (isWithinLine && !(lineOffset === 0 && length === lineLength)) {
|
|
return convertHTML(line, lineOffset, length, true);
|
|
}
|
|
return convertHTML(this.scroll, index, length, true);
|
|
}
|
|
return '';
|
|
}
|
|
getText(index, length) {
|
|
return this.getContents(index, length).filter(op => typeof op.insert === 'string').map(op => op.insert).join('');
|
|
}
|
|
insertContents(index, contents) {
|
|
const normalizedDelta = normalizeDelta(contents);
|
|
const change = new Delta().retain(index).concat(normalizedDelta);
|
|
this.scroll.insertContents(index, normalizedDelta);
|
|
return this.update(change);
|
|
}
|
|
insertEmbed(index, embed, value) {
|
|
this.scroll.insertAt(index, embed, value);
|
|
return this.update(new Delta().retain(index).insert({
|
|
[embed]: value
|
|
}));
|
|
}
|
|
insertText(index, text) {
|
|
let formats = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : {};
|
|
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, cloneDeep(formats)));
|
|
}
|
|
isBlank() {
|
|
if (this.scroll.children.length === 0) return true;
|
|
if (this.scroll.children.length > 1) return false;
|
|
const blot = this.scroll.children.head;
|
|
if (blot?.statics.blotName !== Block.blotName) return false;
|
|
const block = blot;
|
|
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) {
|
|
let mutations = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : [];
|
|
let selectionInfo = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : undefined;
|
|
const oldDelta = this.delta;
|
|
if (mutations.length === 1 && mutations[0].type === 'characterData' &&
|
|
// @ts-expect-error Fix me later
|
|
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);
|
|
// @ts-expect-error Fix me later
|
|
const oldValue = mutations[0].oldValue.replace(CursorBlot.CONTENTS, '');
|
|
const oldText = new Delta().insert(oldValue);
|
|
// @ts-expect-error
|
|
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 || !isEqual(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) {
|
|
let isRoot = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : false;
|
|
if ('html' in blot && typeof blot.html === 'function') {
|
|
return blot.html(index, length);
|
|
}
|
|
if (blot instanceof TextBlot) {
|
|
const escapedText = escapeText(blot.value().slice(index, index + length));
|
|
return escapedText.replaceAll(' ', ' ');
|
|
}
|
|
if (blot instanceof ParentBlot) {
|
|
// TODO fix API
|
|
if (blot.statics.blotName === 'list-container') {
|
|
const items = [];
|
|
blot.children.forEachAt(index, length, (child, offset, childLength) => {
|
|
const formats = 'formats' in child && typeof child.formats === 'function' ? 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 instanceof Element ? blot.domNode.outerHTML : '';
|
|
}
|
|
function combineFormats(formats, combined) {
|
|
return Object.keys(combined).reduce((merged, name) => {
|
|
if (formats[name] == null) return merged;
|
|
const combinedValue = combined[name];
|
|
if (combinedValue === formats[name]) {
|
|
merged[name] = combinedValue;
|
|
} else if (Array.isArray(combinedValue)) {
|
|
if (combinedValue.indexOf(formats[name]) < 0) {
|
|
merged[name] = combinedValue.concat([formats[name]]);
|
|
} else {
|
|
// If style already exists, don't add to an array, but don't lose other styles
|
|
merged[name] = combinedValue;
|
|
}
|
|
} else {
|
|
merged[name] = [combinedValue, 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(_ref, amount) {
|
|
let {
|
|
index,
|
|
length
|
|
} = _ref;
|
|
return new Range(index + amount, length);
|
|
}
|
|
function splitOpLines(ops) {
|
|
const split = [];
|
|
ops.forEach(op => {
|
|
if (typeof op.insert === 'string') {
|
|
const lines = op.insert.split('\n');
|
|
lines.forEach((line, index) => {
|
|
if (index) split.push({
|
|
insert: '\n',
|
|
attributes: op.attributes
|
|
});
|
|
if (line) split.push({
|
|
insert: line,
|
|
attributes: op.attributes
|
|
});
|
|
});
|
|
} else {
|
|
split.push(op);
|
|
}
|
|
});
|
|
return split;
|
|
}
|
|
export default Editor;
|
|
//# sourceMappingURL=editor.js.map
|