Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve inserting performance #3815

Merged
merged 4 commits into from
Jul 5, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changing to:

    return block.statics.requiredContainer
      ? block.wrap(block.statics.requiredContainer.blotName)
      : block;

Decreases the rendering time for /stress by 500ms but not super sure if it's safe so this PR leaves it out.

}
}

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