Skip to content

Commit

Permalink
Merge pull request #3815 from quilljs/zh-insert-contents
Browse files Browse the repository at this point in the history
Improve inserting performance
  • Loading branch information
luin authored Jul 5, 2023
2 parents 6a138bb + 3da269c commit 7938b70
Show file tree
Hide file tree
Showing 6 changed files with 490 additions and 6 deletions.
199 changes: 199 additions & 0 deletions blots/scroll.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,29 @@
import {
Blot,
ContainerBlot,
EmbedBlot,
LeafBlot,
Parent,
ParentBlot,
Registry,
Scope,
ScrollBlot,
} from 'parchment';
import Delta, { AttributeMap, Op } from 'quill-delta';
import Emitter, { EmitterSource } from '../core/emitter';
import Block, { BlockEmbed } from './block';
import Break from './break';
import Container from './container';
import { bubbleFormats } from './block';

type RenderBlock =
| {
type: 'blockEmbed';
attributes: AttributeMap;
key: string;
value: unknown;
}
| { type: 'block'; attributes: AttributeMap; delta: Delta };

function isLine(blot: unknown): blot is Block | BlockEmbed {
return blot instanceof Block || blot instanceof BlockEmbed;
Expand Down Expand Up @@ -137,6 +149,76 @@ class Scroll extends ScrollBlot {
}
}

insertContents(index: number, delta: Delta) {
const renderBlocks = this.deltaToRenderBlocks(
delta.concat(new Delta().insert('\n')),
);
const last = renderBlocks.pop();
if (last == null) return;

this.batchStart();

const first = renderBlocks.shift();
if (first) {
const shouldInsertNewlineChar =
first.type === 'block' &&
(first.delta.length() === 0 ||
(!this.descendant(BlockEmbed, index)[0] && index < this.length()));
const delta =
first.type === 'block'
? first.delta
: new Delta().insert({ [first.key]: first.value });
insertInlineContents(this, index, delta);
const newlineCharIndex = index + delta.length();
if (shouldInsertNewlineChar) {
this.insertAt(newlineCharIndex, '\n');
}

const formats = bubbleFormats(this.line(index)[0]);
const attributes = AttributeMap.diff(formats, first.attributes) || {};
Object.keys(attributes).forEach(name => {
this.formatAt(newlineCharIndex, 1, name, attributes[name]);
});

index = newlineCharIndex + 1;
}

let [refBlot, refBlotOffset] = this.children.find(index);
if (renderBlocks.length) {
if (refBlot) {
refBlot = refBlot.split(refBlotOffset);
refBlotOffset = 0;
}

renderBlocks.forEach(renderBlock => {
if (renderBlock.type === 'block') {
const block = this.createBlock(renderBlock.attributes);
this.insertBefore(block, refBlot || undefined);
insertInlineContents(block, 0, renderBlock.delta);
} else {
const blockEmbed = this.create(
renderBlock.key,
renderBlock.value,
) as EmbedBlot;
Object.keys(renderBlock.attributes).forEach(name => {
blockEmbed.format(name, renderBlock.attributes[name]);
});
this.insertBefore(blockEmbed, refBlot || undefined);
}
});
}

if (last.type === 'block' && last.delta.length()) {
const offset = refBlot
? refBlot.offset(refBlot.scroll) + refBlotOffset
: this.length();
insertInlineContents(this, offset, last.delta);
}

this.batchEnd();
this.optimize();
}

isEnabled() {
return this.domNode.getAttribute('contenteditable') === 'true';
}
Expand Down Expand Up @@ -242,6 +324,123 @@ class Scroll extends ScrollBlot {
blot.updateContent(change);
}
}

private deltaToRenderBlocks(delta: Delta) {
const renderBlocks: RenderBlock[] = [];

let currentBlockDelta = new Delta();
delta.forEach(op => {
const insert = op?.insert;
if (!insert) return;
if (typeof insert === 'string') {
const splitted = insert.split('\n');
splitted.slice(0, -1).forEach(text => {
currentBlockDelta.insert(text, op.attributes);
renderBlocks.push({
type: 'block',
delta: currentBlockDelta,
attributes: op.attributes ?? {},
});
currentBlockDelta = new Delta();
});
const last = splitted[splitted.length - 1];
if (last) {
currentBlockDelta.insert(last, op.attributes);
}
} else {
const key = Object.keys(insert)[0];
if (!key) return;
if (this.query(key, Scope.INLINE)) {
currentBlockDelta.push(op);
} else {
if (currentBlockDelta.length()) {
renderBlocks.push({
type: 'block',
delta: currentBlockDelta,
attributes: {},
});
}
currentBlockDelta = new Delta();
renderBlocks.push({
type: 'blockEmbed',
key,
value: insert[key],
attributes: op.attributes ?? {},
});
}
}
});

if (currentBlockDelta.length()) {
renderBlocks.push({
type: 'block',
delta: currentBlockDelta,
attributes: {},
});
}

return renderBlocks;
}

private createBlock(attributes: AttributeMap) {
let blotName: string | undefined;
const formats: AttributeMap = {};

Object.entries(attributes).forEach(([key, value]) => {
const isBlockBlot = this.query(key, Scope.BLOCK & Scope.BLOT) != null;
if (isBlockBlot) {
blotName = key;
} else {
formats[key] = value;
}
});

const block = this.create(
blotName || this.statics.defaultChild.blotName,
blotName ? attributes[blotName] : undefined,
) as ParentBlot;

const length = block.length();
Object.entries(formats).forEach(([key, value]) => {
block.formatAt(0, length, key, value);
});

return block;
}
}

function insertInlineContents(
parent: ParentBlot,
index: number,
inlineContents: Delta,
) {
inlineContents.reduce((index, op) => {
const length = Op.length(op);
let attributes = op.attributes || {};
if (op.insert != null) {
if (typeof op.insert === 'string') {
const text = op.insert;
parent.insertAt(index, text);
const [leaf] = parent.descendant(LeafBlot, index);
const 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;
parent.insertAt(index, key, op.insert[key]);
const isInlineEmbed = parent.scroll.query(key, Scope.INLINE) != null;
if (isInlineEmbed) {
const [leaf] = parent.descendant(LeafBlot, index);
const formats = bubbleFormats(leaf);
attributes = AttributeMap.diff(formats, attributes) || {};
}
}
}
Object.keys(attributes).forEach(key => {
parent.formatAt(index, length, key, attributes[key]);
});
return index + length;
}, index);
}

export interface ScrollConstructor {
Expand Down
7 changes: 7 additions & 0 deletions core/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,13 @@ class Editor {
.join('');
}

insertContents(index: number, contents: Delta): Delta {
const normalizedDelta = normalizeDelta(contents);
const change = new Delta().retain(index).concat(normalizedDelta);
this.scroll.insertContents(index, normalizedDelta);
return this.update(change);
}

insertEmbed(index: number, embed: string, value: unknown): Delta {
this.scroll.insertAt(index, embed, value);
return this.update(new Delta().retain(index).insert({ [embed]: value }));
Expand Down
3 changes: 1 addition & 2 deletions core/quill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -633,8 +633,7 @@ class Quill {
const length = this.getLength();
// Quill will set empty editor to \n
const delete1 = this.editor.deleteText(0, length);
// delta always applied before existing content
const applied = this.editor.applyDelta(delta);
const applied = this.editor.insertContents(0, delta);
// Remove extra \n from empty editor initialization
const delete2 = this.editor.deleteText(this.getLength() - 1, 1);
return delete1.compose(applied).compose(delete2);
Expand Down
38 changes: 34 additions & 4 deletions test/fuzz/editor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,11 +140,17 @@ const generateDocument = () => {
return delta;
};

const generateChange = (doc: Delta, changeCount: number) => {
const generateChange = (
doc: Delta,
changeCount: number,
allowedActions = ['insert', 'delete', 'retain'],
) => {
const docLength = doc.length();
const skipLength = randomInt(docLength);
const skipLength = allowedActions.includes('retain')
? randomInt(docLength)
: 0;
let change = new Delta().retain(skipLength);
const action = choose(['insert', 'delete', 'retain']);
const action = choose(allowedActions);
const nextOp = doc.slice(skipLength).ops[0];
if (!nextOp) throw new Error('nextOp expected');
const needNewline = !isLineFinished(doc.slice(0, skipLength));
Expand Down Expand Up @@ -209,7 +215,9 @@ const generateChange = (doc: Delta, changeCount: number) => {
changeCount -= 1;
return changeCount <= 0
? change
: change.compose(generateChange(doc.compose(change), changeCount));
: change.compose(
generateChange(doc.compose(change), changeCount, allowedActions),
);
};

describe('editor', () => {
Expand Down Expand Up @@ -238,4 +246,26 @@ describe('editor', () => {
}
});
});

it('insertContents() vs applyDelta()', () => {
const quill1 = new Quill(document.createElement('div'));
const quill2 = new Quill(document.createElement('div'));

runFuzz(() => {
const delta = generateDocument();
quill1.setContents(delta);
quill2.setContents(delta);

const retain = randomInt(delta.length());
const change = generateChange(delta, randomInt(20) + 1, ['insert']);

quill1.editor.insertContents(retain, change);
quill2.editor.applyDelta(new Delta().retain(retain).concat(change));

const contents1 = quill1.getContents().ops;
const contents2 = quill2.getContents().ops;

expect(contents1).toEqual(contents2);
});
});
});
11 changes: 11 additions & 0 deletions test/unit/blots/scroll.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import Emitter from '../../../core/emitter';
import Selection, { Range } from '../../../core/selection';
import Cursor from '../../../blots/cursor';
import Scroll from '../../../blots/scroll';
import Delta from 'quill-delta';

describe('Scroll', function () {
it('initialize empty document', function () {
Expand Down Expand Up @@ -88,4 +89,14 @@ describe('Scroll', function () {
expect(offset).toEqual(-1);
});
});

describe('insertContents()', function () {
it('does not mutate the input', function () {
const scroll = this.initialize(Scroll, '<p>Test</p>');
const delta = new Delta().insert('\n');
const clonedDelta = new Delta(structuredClone(delta.ops));
scroll.insertContents(0, delta);
expect(delta.ops).toEqual(clonedDelta.ops);
});
});
});
Loading

0 comments on commit 7938b70

Please sign in to comment.