From fa50c4201221f0ead421de612c472547452d597e Mon Sep 17 00:00:00 2001 From: Jason Chen Date: Wed, 21 Jun 2023 17:45:57 -0700 Subject: [PATCH 1/4] add insertContents for scroll and block --- blots/block.ts | 27 ++++++++++++++++++++++- blots/scroll.ts | 57 +++++++++++++++++++++++++++++++++++++++++++++++++ core/editor.ts | 6 ++++++ core/quill.ts | 3 +-- 4 files changed, 90 insertions(+), 3 deletions(-) diff --git a/blots/block.ts b/blots/block.ts index 38eb365e85..c1e7133494 100644 --- a/blots/block.ts +++ b/blots/block.ts @@ -6,7 +6,7 @@ import { LeafBlot, Scope, } from 'parchment'; -import Delta from 'quill-delta'; +import Delta, { AttributeMap, Op } from 'quill-delta'; import Break from './break'; import Inline from './inline'; import TextBlot from './text'; @@ -82,6 +82,31 @@ class Block extends BlockBlot { this.cache = {}; } + // TODO: Either handle \n in deltas or move insertAt splitting responsibility to scroll + insertContents(index: number, delta: Delta) { + delta.reduce((index: number, op: Op) => { + const length = Op.length(op); + let attributes = op.attributes || {}; + if (op.insert != null) { + if (typeof op.insert === 'string') { + const text = op.insert; + this.insertAt(index, text); + const [leaf] = this.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; + this.insertAt(index, key, op.insert[key]); + } + } + Object.keys(attributes).forEach(name => { + this.formatAt(index, length, name, attributes[name]); + }); + return index + length; + }, index); + } + length() { if (this.cache.length == null) { this.cache.length = super.length() + NEWLINE_LENGTH; diff --git a/blots/scroll.ts b/blots/scroll.ts index eb55634568..2ef177c4f2 100644 --- a/blots/scroll.ts +++ b/blots/scroll.ts @@ -8,6 +8,7 @@ import { Scope, ScrollBlot, } from 'parchment'; +import Delta, { AttributeMap } from 'quill-delta'; import Emitter, { EmitterSource } from '../core/emitter'; import Block, { BlockEmbed } from './block'; import Break from './break'; @@ -25,6 +26,20 @@ function isUpdatable(blot: Blot): blot is Blot & UpdatableEmbed { return typeof (blot as unknown as any).updateContent === 'function'; } +function deltaToLines(delta: Delta) { + const lines: { delta: Delta; attributes: AttributeMap }[] = []; + // eachLine can't tell if we end in newline or not + // add trailing newline to differentiate + // 'hello\nworld' -> ['hello', 'world'] + // 'hello\nworld\n' -> ['hello', 'world', ''] + + // TODO: insert() here modifies original delta + delta.insert('\n').eachLine((lineDelta, attributes) => { + lines.push({ delta: lineDelta, attributes }); + }); + return lines; +} + class Scroll extends ScrollBlot { static blotName = 'scroll'; static className = 'ql-editor'; @@ -137,6 +152,48 @@ class Scroll extends ScrollBlot { } } + insertContents(index: number, delta: Delta) { + const [child, offset] = this.children.find(index); + if (child == null) return; + const lines = deltaToLines(delta); + const first = lines.shift(); + if (first == null) return; + this.batchStart(); + // @ts-ignore + child.insertContents(offset, first.delta); + const last = lines.pop(); + let after; + if (last != null) { + after = child.split(offset + first.delta.length()); + Object.keys(first.attributes).forEach(name => { + // @ts-ignore + child.format(name, first.attributes[name]); + }); + after.insertContents(0, last.delta); + } + + lines.forEach(({ delta: lineDelta, attributes }) => { + const blockAttribute = Object.keys(attributes).find( + key => + this.query( + key, + // eslint-disable-next-line no-bitwise + Scope.BLOCK & Scope.BLOT, + ) != null, + ); + const block = this.create( + blockAttribute || this.statics.defaultChild.blotName, + blockAttribute ? attributes[blockAttribute] : undefined, + ); + // @ts-ignore + block.insertContents(0, lineDelta); + this.insertBefore(block, after); + }); + + this.batchEnd(); + this.optimize(); + } + isEnabled() { return this.domNode.getAttribute('contenteditable') === 'true'; } diff --git a/core/editor.ts b/core/editor.ts index 3657a286c1..df1b741386 100644 --- a/core/editor.ts +++ b/core/editor.ts @@ -209,6 +209,12 @@ class Editor { .join(''); } + insertContents(index: number, contents: Delta): Delta { + const change = new Delta().retain(index).concat(contents); + this.scroll.insertContents(index, contents); + 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 })); diff --git a/core/quill.ts b/core/quill.ts index 0f9a1f7c69..b4a7388148 100644 --- a/core/quill.ts +++ b/core/quill.ts @@ -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); From a8598a93107626664932aaf739fae12046b5fccd Mon Sep 17 00:00:00 2001 From: Jason Chen Date: Wed, 21 Jun 2023 17:54:37 -0700 Subject: [PATCH 2/4] always format in optimal order --- blots/block.ts | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/blots/block.ts b/blots/block.ts index c1e7133494..4fb2aa6892 100644 --- a/blots/block.ts +++ b/blots/block.ts @@ -28,17 +28,28 @@ class Block extends BlockBlot { this.cache = {}; } - formatAt(index, length, name, value) { + formatAt(index, length, format, value?) { if (length <= 0) return; - if (this.scroll.query(name, Scope.BLOCK)) { + if (typeof format != 'string') { + const order = Inline.order.slice().reverse(); + Object.keys(format) + .sort((a, b) => { + const aOrder = order.indexOf(a); + const bOrder = order.indexOf(b); + return aOrder - bOrder; + }) + .forEach(key => { + this.formatAt(index, length, key, format[key]); + }); + } else if (this.scroll.query(format, Scope.BLOCK)) { if (index + length === this.length()) { - this.format(name, value); + this.format(format, value); } } else { super.formatAt( index, Math.min(length, this.length() - index - 1), - name, + format, value, ); } @@ -100,9 +111,7 @@ class Block extends BlockBlot { this.insertAt(index, key, op.insert[key]); } } - Object.keys(attributes).forEach(name => { - this.formatAt(index, length, name, attributes[name]); - }); + this.formatAt(index, length, attributes); return index + length; }, index); } From e99457002afe83e11eef6e21ff5abbe228591791 Mon Sep 17 00:00:00 2001 From: Zihua Li Date: Sat, 24 Jun 2023 16:13:35 +0800 Subject: [PATCH 3/4] insertContents fixes --- blots/scroll.ts | 17 ++++++++++++++--- core/editor.ts | 5 +++-- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/blots/scroll.ts b/blots/scroll.ts index 2ef177c4f2..74e2093189 100644 --- a/blots/scroll.ts +++ b/blots/scroll.ts @@ -164,10 +164,10 @@ class Scroll extends ScrollBlot { const last = lines.pop(); let after; if (last != null) { - after = child.split(offset + first.delta.length()); + after = child.split(offset + first.delta.length(), true); Object.keys(first.attributes).forEach(name => { // @ts-ignore - child.format(name, first.attributes[name]); + after.prev.format(name, first.attributes[name]); }); after.insertContents(0, last.delta); } @@ -185,9 +185,20 @@ class Scroll extends ScrollBlot { blockAttribute || this.statics.defaultChild.blotName, blockAttribute ? attributes[blockAttribute] : undefined, ); + this.insertBefore(block, after); // @ts-ignore block.insertContents(0, lineDelta); - this.insertBefore(block, after); + Object.keys(attributes).forEach(key => { + if ( + this.query( + key, + // eslint-disable-next-line no-bitwise + Scope.BLOCK & Scope.BLOT, + ) == null + ) { + block.formatAt(0, block.length(), key, attributes[key]); + } + }); }); this.batchEnd(); diff --git a/core/editor.ts b/core/editor.ts index df1b741386..961785ced5 100644 --- a/core/editor.ts +++ b/core/editor.ts @@ -210,8 +210,9 @@ class Editor { } insertContents(index: number, contents: Delta): Delta { - const change = new Delta().retain(index).concat(contents); - this.scroll.insertContents(index, contents); + const normalizedDelta = normalizeDelta(contents); + const change = new Delta().retain(index).concat(normalizedDelta); + this.scroll.insertContents(index, normalizedDelta); return this.update(change); } From 3da269cfb3352d021b36713dc4a3e754125b9d64 Mon Sep 17 00:00:00 2001 From: Zihua Li Date: Wed, 28 Jun 2023 13:04:00 +0800 Subject: [PATCH 4/4] Improve inserting performance --- blots/block.ts | 44 +------ blots/scroll.ts | 243 +++++++++++++++++++++++++++++--------- test/fuzz/editor.test.ts | 38 +++++- test/unit/blots/scroll.js | 11 ++ test/unit/core/editor.js | 238 +++++++++++++++++++++++++++++++++++++ 5 files changed, 475 insertions(+), 99 deletions(-) diff --git a/blots/block.ts b/blots/block.ts index 4fb2aa6892..38eb365e85 100644 --- a/blots/block.ts +++ b/blots/block.ts @@ -6,7 +6,7 @@ import { LeafBlot, Scope, } from 'parchment'; -import Delta, { AttributeMap, Op } from 'quill-delta'; +import Delta from 'quill-delta'; import Break from './break'; import Inline from './inline'; import TextBlot from './text'; @@ -28,28 +28,17 @@ class Block extends BlockBlot { this.cache = {}; } - formatAt(index, length, format, value?) { + formatAt(index, length, name, value) { if (length <= 0) return; - if (typeof format != 'string') { - const order = Inline.order.slice().reverse(); - Object.keys(format) - .sort((a, b) => { - const aOrder = order.indexOf(a); - const bOrder = order.indexOf(b); - return aOrder - bOrder; - }) - .forEach(key => { - this.formatAt(index, length, key, format[key]); - }); - } else if (this.scroll.query(format, Scope.BLOCK)) { + if (this.scroll.query(name, Scope.BLOCK)) { if (index + length === this.length()) { - this.format(format, value); + this.format(name, value); } } else { super.formatAt( index, Math.min(length, this.length() - index - 1), - format, + name, value, ); } @@ -93,29 +82,6 @@ class Block extends BlockBlot { this.cache = {}; } - // TODO: Either handle \n in deltas or move insertAt splitting responsibility to scroll - insertContents(index: number, delta: Delta) { - delta.reduce((index: number, op: Op) => { - const length = Op.length(op); - let attributes = op.attributes || {}; - if (op.insert != null) { - if (typeof op.insert === 'string') { - const text = op.insert; - this.insertAt(index, text); - const [leaf] = this.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; - this.insertAt(index, key, op.insert[key]); - } - } - this.formatAt(index, length, attributes); - return index + length; - }, index); - } - length() { if (this.cache.length == null) { this.cache.length = super.length() + NEWLINE_LENGTH; diff --git a/blots/scroll.ts b/blots/scroll.ts index 74e2093189..89efb3a783 100644 --- a/blots/scroll.ts +++ b/blots/scroll.ts @@ -1,6 +1,7 @@ import { Blot, ContainerBlot, + EmbedBlot, LeafBlot, Parent, ParentBlot, @@ -8,11 +9,21 @@ import { Scope, ScrollBlot, } from 'parchment'; -import Delta, { AttributeMap } from 'quill-delta'; +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; @@ -26,20 +37,6 @@ function isUpdatable(blot: Blot): blot is Blot & UpdatableEmbed { return typeof (blot as unknown as any).updateContent === 'function'; } -function deltaToLines(delta: Delta) { - const lines: { delta: Delta; attributes: AttributeMap }[] = []; - // eachLine can't tell if we end in newline or not - // add trailing newline to differentiate - // 'hello\nworld' -> ['hello', 'world'] - // 'hello\nworld\n' -> ['hello', 'world', ''] - - // TODO: insert() here modifies original delta - delta.insert('\n').eachLine((lineDelta, attributes) => { - lines.push({ delta: lineDelta, attributes }); - }); - return lines; -} - class Scroll extends ScrollBlot { static blotName = 'scroll'; static className = 'ql-editor'; @@ -153,53 +150,70 @@ class Scroll extends ScrollBlot { } insertContents(index: number, delta: Delta) { - const [child, offset] = this.children.find(index); - if (child == null) return; - const lines = deltaToLines(delta); - const first = lines.shift(); - if (first == null) return; + const renderBlocks = this.deltaToRenderBlocks( + delta.concat(new Delta().insert('\n')), + ); + const last = renderBlocks.pop(); + if (last == null) return; + this.batchStart(); - // @ts-ignore - child.insertContents(offset, first.delta); - const last = lines.pop(); - let after; - if (last != null) { - after = child.split(offset + first.delta.length(), true); - Object.keys(first.attributes).forEach(name => { - // @ts-ignore - after.prev.format(name, first.attributes[name]); + + 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]); }); - after.insertContents(0, last.delta); + + index = newlineCharIndex + 1; } - lines.forEach(({ delta: lineDelta, attributes }) => { - const blockAttribute = Object.keys(attributes).find( - key => - this.query( - key, - // eslint-disable-next-line no-bitwise - Scope.BLOCK & Scope.BLOT, - ) != null, - ); - const block = this.create( - blockAttribute || this.statics.defaultChild.blotName, - blockAttribute ? attributes[blockAttribute] : undefined, - ); - this.insertBefore(block, after); - // @ts-ignore - block.insertContents(0, lineDelta); - Object.keys(attributes).forEach(key => { - if ( - this.query( - key, - // eslint-disable-next-line no-bitwise - Scope.BLOCK & Scope.BLOT, - ) == null - ) { - block.formatAt(0, block.length(), key, attributes[key]); + 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(); @@ -310,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 { diff --git a/test/fuzz/editor.test.ts b/test/fuzz/editor.test.ts index c7698496cf..08a707ccd5 100644 --- a/test/fuzz/editor.test.ts +++ b/test/fuzz/editor.test.ts @@ -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)); @@ -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', () => { @@ -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); + }); + }); }); diff --git a/test/unit/blots/scroll.js b/test/unit/blots/scroll.js index 2c9582414e..e09f60d9eb 100644 --- a/test/unit/blots/scroll.js +++ b/test/unit/blots/scroll.js @@ -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 () { @@ -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, '

Test

'); + const delta = new Delta().insert('\n'); + const clonedDelta = new Delta(structuredClone(delta.ops)); + scroll.insertContents(0, delta); + expect(delta.ops).toEqual(clonedDelta.ops); + }); + }); }); diff --git a/test/unit/core/editor.js b/test/unit/core/editor.js index bcdb2e180e..fd6318e75c 100644 --- a/test/unit/core/editor.js +++ b/test/unit/core/editor.js @@ -781,6 +781,244 @@ describe('Editor', function () { }); }); + describe('insertContents', function () { + const video = + ''; + + it('ignores empty delta', function () { + const editor = this.initialize(Editor, '

1

'); + editor.insertContents(0, new Delta()); + expect(editor.getDelta().ops).toEqual([{ insert: '1\n' }]); + + editor.insertContents(0, new Delta().retain(100)); + expect(editor.getDelta().ops).toEqual([{ insert: '1\n' }]); + }); + + it('prepend to paragraph', function () { + const editor = this.initialize(Editor, '

2

'); + editor.insertContents(0, new Delta().insert('1')); + expect(editor.getDelta().ops).toEqual([{ insert: '12\n' }]); + + editor.insertContents( + 0, + new Delta() + .insert('a', { bold: true }) + .insert('\n', { header: 1 }) + .insert('b', { bold: true }), + ); + + expect(editor.getDelta().ops).toEqual([ + { insert: 'a', attributes: { bold: true } }, + { insert: '\n', attributes: { header: 1 } }, + { insert: 'b', attributes: { bold: true } }, + { insert: '12\n' }, + ]); + }); + + it('prepend to list item', function () { + const editor = this.initialize( + Editor, + '
  1. 2
', + ); + editor.insertContents(0, new Delta().insert('1')); + expect(editor.getDelta().ops).toEqual([ + { insert: '12' }, + { insert: '\n', attributes: { list: 'bullet' } }, + ]); + + editor.insertContents( + 0, + new Delta() + .insert('a', { bold: true }) + .insert('\n', { header: 1 }) + .insert('b', { bold: true }), + ); + + expect(editor.getDelta().ops).toEqual([ + { insert: 'a', attributes: { bold: true } }, + { insert: '\n', attributes: { header: 1 } }, + { insert: 'b', attributes: { bold: true } }, + { insert: '12' }, + { insert: '\n', attributes: { list: 'bullet' } }, + ]); + }); + + describe('prepend to block embed', function () { + it('without ending with \\n', function () { + const editor = this.initialize(Editor, `${video}`); + editor.insertContents(0, new Delta().insert('a')); + expect(editor.getDelta().ops).toEqual([ + { insert: 'a\n' }, + { insert: { video: '#' } }, + ]); + }); + + it('empty first line', function () { + const editor = this.initialize(Editor, `

${video}`); + editor.insertContents(1, new Delta().insert('\nworld\n')); + expect(editor.getDelta().ops).toEqual([ + { insert: '\n\nworld\n' }, + { insert: { video: '#' } }, + ]); + }); + + it('multiple lines', function () { + const editor = this.initialize(Editor, `${video}`); + editor.insertContents( + 0, + new Delta().insert('a').insert('\n', { header: 1 }), + ); + expect(editor.getDelta().ops).toEqual([ + { insert: 'a' }, + { insert: '\n', attributes: { header: 1 } }, + { insert: { video: '#' } }, + ]); + }); + }); + + describe('append', function () { + it('appends to editor', function () { + const editor = this.initialize(Editor, '

1

'); + editor.insertContents(2, new Delta().insert('a')); + expect(editor.getDelta().ops).toEqual([{ insert: '1\na\n' }]); + editor.insertContents( + 4, + new Delta().insert('b').insert('\n', { header: 1 }), + ); + expect(editor.getDelta().ops).toEqual([ + { insert: '1\na\nb' }, + { insert: '\n', attributes: { header: 1 } }, + ]); + }); + + it('appends to paragraph', function () { + const editor = this.initialize(Editor, '

1

2

'); + editor.insertContents(2, new Delta().insert('a')); + expect(editor.getDelta().ops).toEqual([{ insert: '1\na2\n' }]); + editor.insertContents( + 2, + new Delta().insert('b').insert('\n', { header: 1 }), + ); + expect(editor.getDelta().ops).toEqual([ + { insert: '1\nb' }, + { insert: '\n', attributes: { header: 1 } }, + { insert: 'a2\n' }, + ]); + }); + + it('appends to block embed', function () { + const editor = this.initialize(Editor, `${video}

2

`); + editor.insertContents(1, new Delta().insert('1')); + expect(editor.getDelta().ops).toEqual([ + { insert: { video: '#' } }, + { insert: '12\n' }, + ]); + editor.insertContents( + 1, + new Delta().insert('b').insert('\n', { header: 1 }), + ); + expect(editor.getDelta().ops).toEqual([ + { insert: { video: '#' } }, + { insert: 'b' }, + { insert: '\n', attributes: { header: 1 } }, + { insert: '12\n' }, + ]); + }); + }); + + it('inserts formatted block embeds', function () { + const editor = this.initialize(Editor, `

`); + editor.insertContents( + 0, + new Delta() + .insert('a\n') + .insert({ video: '#' }, { width: '300' }) + .insert({ video: '#' }, { width: '300' }) + .insert('\nd'), + ); + expect(editor.getDelta().ops).toEqual([ + { insert: 'a\n' }, + { insert: { video: '#' }, attributes: { width: '300' } }, + { insert: { video: '#' }, attributes: { width: '300' } }, + { insert: '\nd\n' }, + ]); + }); + + it('inserts inline embeds to bold text', function () { + const editor = this.initialize(Editor, `

ab

`); + editor.insertContents(1, new Delta().insert({ image: '#' })); + expect(editor.getDelta().ops).toEqual([ + { insert: 'a', attributes: { bold: true } }, + { insert: { image: '#' } }, + { insert: 'b', attributes: { bold: true } }, + { insert: '\n' }, + ]); + }); + + it('inserts multiple lines to a container', function () { + const editor = this.initialize( + Editor, + `
`, + ); + editor.insertContents( + 0, + new Delta() + .insert('world', { font: 'monospace' }) + .insert('\n', { list: 'bullet' }) + .insert('\n'), + ); + expect(editor.getDelta().ops).toEqual([ + { insert: 'world', attributes: { font: 'monospace' } }, + { insert: '\n', attributes: { list: 'bullet' } }, + { insert: '\n' }, + { insert: '\n', attributes: { list: 'ordered' } }, + ]); + }); + + describe('invalid delta', function () { + const getEditorDelta = (context, modify) => { + const editor = context.initialize(Editor, `

`); + modify(editor); + return editor.getDelta().ops; + }; + + it('conflict block formats', function () { + const change = new Delta() + .insert('a') + .insert('\n', { header: 1, list: 'bullet' }) + .insert('b') + .insert('\n', { header: 1, list: 'bullet' }); + + expect( + getEditorDelta(this, editor => editor.insertContents(0, change)), + ).toEqual(getEditorDelta(this, editor => editor.applyDelta(change))); + }); + + it('block embeds with line formats', function () { + const change = new Delta() + .insert('a\n') + .insert({ video: '#' }, { header: 1 }) + .insert({ video: '#' }, { header: 1 }) + .insert('\n', { header: 1 }); + + expect( + getEditorDelta(this, editor => editor.insertContents(0, change)), + ).toEqual(getEditorDelta(this, editor => editor.applyDelta(change))); + }); + + it('missing \\n before block embeds', function () { + const change = new Delta() + .insert('a') + .insert({ video: '#' }) + .insert('b\n'); + + expect( + getEditorDelta(this, editor => editor.insertContents(0, change)), + ).toEqual(getEditorDelta(this, editor => editor.applyDelta(change))); + }); + }); + }); + describe('getFormat()', function () { it('unformatted', function () { const editor = this.initialize(Editor, '

0123

');