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 f1924c0553..23c0764b60 100644 --- a/test/fuzz/editor.test.ts +++ b/test/fuzz/editor.test.ts @@ -140,11 +140,15 @@ const generateDocument = () => { return delta; }; -const generateChange = (doc: Delta, changeCount: number) => { +const generateChange = ( + doc: Delta, + changeCount: number, + allowedChildren = ['insert', 'delete', 'retain'], +) => { const docLength = doc.length(); const skipLength = randomInt(docLength); let change = new Delta().retain(skipLength); - const action = choose(['insert', 'delete', 'retain']); + const action = choose(allowedChildren); const nextOp = doc.slice(skipLength).ops[0]; if (!nextOp) throw new Error('nextOp expected'); const needNewline = !isLineFinished(doc.slice(0, skipLength)); @@ -209,7 +213,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, allowedChildren), + ); }; describe('editor', () => { @@ -238,4 +244,26 @@ describe('editor', () => { } } }); + + it('insertContents() vs applyDelta()', () => { + const quill1 = new Quill(document.createElement('div')); + const quill2 = new Quill(document.createElement('div')); + + for (let i = 0; i < 200; i += 1) { + 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

');