diff --git a/blots/scroll.ts b/blots/scroll.ts index eb55634568..89efb3a783 100644 --- a/blots/scroll.ts +++ b/blots/scroll.ts @@ -1,6 +1,7 @@ import { Blot, ContainerBlot, + EmbedBlot, LeafBlot, Parent, ParentBlot, @@ -8,10 +9,21 @@ import { 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; @@ -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'; } @@ -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 { diff --git a/core/editor.ts b/core/editor.ts index 3657a286c1..961785ced5 100644 --- a/core/editor.ts +++ b/core/editor.ts @@ -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 })); 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); 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

');