From f6e556b308ada8f60a1cdd0278e15e3069592c95 Mon Sep 17 00:00:00 2001 From: Dmitry Levkovskiy Date: Fri, 29 Oct 2021 10:27:14 +0300 Subject: [PATCH] Fetch upstream (#22) * Merge pull request #2882 from tzyl/fix/insert-inline-embed-before-block-embed-with-delete Fix insert inline embed with delete before block embed (cherry picked from commit 58b1747855b6aa7ba71e388b9007d3df8b2bc700) * use Op.length (cherry picked from commit 738a19629a9c398fb7dba26ad9c8d375a3d90ccc) * add failing tests (cherry picked from commit a02978f5629d8e61a20bf5963530cfce2d18d770) * track all implicit newline indexes and shift for delete (cherry picked from commit cf101f681158a48b1b477c1544680cc9614425ae) * add test for tracking indexes between insert/delete (cherry picked from commit 50dbbeda88d8d6d070e794db4fb227d41a34ee1d) * add fix and failing test for implicit newline insertion (cherry picked from commit 99bfdcde76323c21803b2b85a424e7f672ba0ea9) * fix linter (cherry picked from commit 7e98bc271162bfc5e01bd9c3ecac231947fa1488) * prefer file over html when uploading - file should include the image data - copying image from slack will include both, but image src is inaccessible without login (cherry picked from commit e164f12603f646d919c853a7db02137399dbf696) * fix mixed html/file test and handle image only case (cherry picked from commit ead3355fc9c6248035406bfc8e510a29b03f3030) * fix formatting * tiny refactoring Co-authored-by: Jason Chen --- core/editor.js | 42 +++---- modules/clipboard.js | 28 ++++- test/unit/core/editor.js | 197 +++++++++++++++++++++++++++++++++ test/unit/modules/clipboard.js | 30 ++++- 4 files changed, 269 insertions(+), 28 deletions(-) diff --git a/core/editor.js b/core/editor.js index e21f3f2de1..a6c82de088 100644 --- a/core/editor.js +++ b/core/editor.js @@ -1,8 +1,8 @@ import cloneDeep from 'lodash.clonedeep'; import isEqual from 'lodash.isequal'; import merge from 'lodash.merge'; -import Delta, { AttributeMap } from 'quill-delta'; -import { LeafBlot } from 'parchment'; +import Delta, { AttributeMap, Op } from 'quill-delta'; +import { LeafBlot, Scope } from 'parchment'; import { Range } from './selection'; import CursorBlot from '../blots/cursor'; import Block, { BlockEmbed, bubbleFormats } from '../blots/block'; @@ -24,28 +24,23 @@ class Editor { } applyDelta(delta) { - let consumeNextNewline = false; this.scroll.update(); let scrollLength = this.scroll.length(); this.scroll.batchStart(); const normalizedDelta = normalizeDelta(delta); + const deleteDelta = new Delta(); normalizedDelta.reduce((index, op) => { - const length = op.retain || op.delete || op.insert.length || 1; + const length = Op.length(op); let attributes = op.attributes || {}; + let addedNewline = false; if (op.insert != null) { + deleteDelta.retain(length); if (typeof op.insert === 'string') { - let text = op.insert; - if (text.endsWith('\n') && consumeNextNewline) { - consumeNextNewline = false; - text = text.slice(0, -1); - } - if ( - (index >= scrollLength || - this.scroll.descendant(BlockEmbed, index)[0]) && - !text.endsWith('\n') - ) { - consumeNextNewline = true; - } + const text = op.insert; + addedNewline = + !text.endsWith('\n') && + (scrollLength <= index || + this.scroll.descendant(BlockEmbed, index)[0]); this.scroll.insertAt(index, text); const [line, offset] = this.scroll.line(index); let formats = merge({}, bubbleFormats(line)); @@ -57,9 +52,15 @@ class Editor { } else if (typeof op.insert === 'object') { const key = Object.keys(op.insert)[0]; // There should only be one key if (key == null) return index; + addedNewline = + this.scroll.query(key, Scope.INLINE) != null && + (scrollLength <= index || + this.scroll.descendant(BlockEmbed, index)[0]); this.scroll.insertAt(index, key, op.insert[key]); } scrollLength += length; + } else { + deleteDelta.push(op); } const keys = Object.keys(attributes); this.immediateFormats.forEach(format => { @@ -71,14 +72,17 @@ class Editor { Object.keys(attributes).forEach(name => { this.scroll.formatAt(index, length, name, attributes[name]); }); - return index + length; + const addedLength = addedNewline ? 1 : 0; + scrollLength += addedLength; + deleteDelta.delete(addedLength); + return index + length + addedLength; }, 0); - normalizedDelta.reduce((index, op) => { + deleteDelta.reduce((index, op) => { if (typeof op.delete === 'number') { this.scroll.deleteAt(index, op.delete); return index; } - return index + (op.retain || op.insert.length || 1); + return index + Op.length(op); }, 0); this.scroll.batchEnd(); this.scroll.optimize(); diff --git a/modules/clipboard.js b/modules/clipboard.js index f4882ce3b8..e3f6f0f7e2 100644 --- a/modules/clipboard.js +++ b/modules/clipboard.js @@ -177,7 +177,9 @@ class Clipboard extends Module { } onCapturePaste(e) { - if (e.defaultPrevented || !this.quill.isEnabled()) return; + if (e.defaultPrevented || !this.quill.isEnabled()) { + return; + } this.raiseCallback('onPaste', e); @@ -189,15 +191,31 @@ class Clipboard extends Module { const range = this.quill.getSelection(true); - if (range == null) return; + if (range == null) { + return; + } + const html = e.clipboardData.getData('text/html'); - const text = e.clipboardData.getData('text/plain'); const files = Array.from(e.clipboardData.files || []); if (!html && files.length > 0) { this.quill.uploader.upload(range, files); - } else { - this.onPaste(range, { html, text }); + return; } + + if (html && files.length > 0) { + const { body } = new DOMParser().parseFromString(html, 'text/html'); + const documentContainsImage = + body.childElementCount === 1 && + body.firstElementChild.tagName === 'IMG'; + + if (documentContainsImage) { + this.quill.uploader.upload(range, files); + return; + } + } + + const text = e.clipboardData.getData('text/plain'); + this.onPaste(range, { html, text }); } raiseCallback(name, event) { diff --git a/test/unit/core/editor.js b/test/unit/core/editor.js index 5224624d5f..f4ce692f92 100644 --- a/test/unit/core/editor.js +++ b/test/unit/core/editor.js @@ -406,6 +406,203 @@ describe('Editor', function() { ); }); + it('multiple inserts and deletes', function() { + const editor = this.initialize(Editor, '

0123

'); + editor.applyDelta( + new Delta() + .retain(1) + .insert('a') + .delete(2) + .insert('cd') + .delete(1) + .insert('efg'), + ); + expect(this.container).toEqualHTML('

0acdefg

'); + }); + + it('insert text with delete in existing block', function() { + const editor = this.initialize( + Editor, + '

0123

', + ); + editor.applyDelta( + new Delta() + .retain(4) + .insert('abc') + // Retain newline at end of block being inserted into. + .retain(1) + .delete(1), + ); + expect(this.container).toEqualHTML('

0123abc

'); + }); + + it('insert text with delete before block embed', function() { + const editor = this.initialize( + Editor, + '

0123

', + ); + editor.applyDelta( + new Delta() + .retain(5) + // Explicit newline required to maintain correct index calculation for the delete. + .insert('abc\n') + .delete(1), + ); + expect(this.container).toEqualHTML('

0123

abc

'); + }); + + it('insert inline embed with delete in existing block', function() { + const editor = this.initialize( + Editor, + '

0123

', + ); + editor.applyDelta( + new Delta() + .retain(4) + .insert({ image: '/assets/favicon.png' }) + // Retain newline at end of block being inserted into. + .retain(1) + .delete(1), + ); + expect(this.container).toEqualHTML( + '

0123

', + ); + }); + + it('insert inline embed with delete before block embed', function() { + const editor = this.initialize( + Editor, + '

0123

', + ); + editor.applyDelta( + new Delta() + .retain(5) + .insert({ image: '/assets/favicon.png' }) + // Explicit newline required to maintain correct index calculation for the delete. + .insert('\n') + .delete(1), + ); + expect(this.container).toEqualHTML( + '

0123

', + ); + }); + + it('insert inline embed with delete before block embed using delete op first', function() { + const editor = this.initialize( + Editor, + '

0123

', + ); + editor.applyDelta( + new Delta() + .retain(5) + .delete(1) + .insert({ image: '/assets/favicon.png' }) + // Explicit newline required to maintain correct index calculation for the delete. + .insert('\n'), + ); + expect(this.container).toEqualHTML( + '

0123

', + ); + }); + + it('insert inline embed and text with delete before block embed', function() { + const editor = this.initialize( + Editor, + '

0123

', + ); + editor.applyDelta( + new Delta() + .retain(5) + .insert({ image: '/assets/favicon.png' }) + // Explicit newline required to maintain correct index calculation for the delete. + .insert('abc\n') + .delete(1), + ); + expect(this.container).toEqualHTML( + '

0123

abc

', + ); + }); + + it('insert block embed with delete before block embed', function() { + const editor = this.initialize( + Editor, + '

0123

', + ); + editor.applyDelta( + new Delta() + .retain(5) + .insert({ video: '#changed' }) + .delete(1), + ); + expect(this.container).toEqualHTML( + '

0123

', + ); + }); + + it('deletes block embed and appends text', function() { + const editor = this.initialize( + Editor, + `


b

`, + ); + editor.applyDelta( + new Delta() + .retain(1) + .insert('a') + .delete(1), + ); + expect(this.container).toEqualHTML('


ab

'); + }); + + it('multiple delete block embed and append texts', function() { + const editor = this.initialize( + Editor, + `


b

`, + ); + editor.applyDelta( + new Delta() + .retain(1) + .insert('a') + .delete(1) + .insert('!') + .delete(1), + ); + expect(this.container).toEqualHTML('


a!b

'); + }); + + it('multiple nonconsecutive delete block embed and append texts', function() { + const editor = this.initialize( + Editor, + `


+ +

a

+ +

bb

+ +

ccc

+ +

dddd

`, + ); + const old = editor.getDelta(); + const delta = new Delta() + .retain(1) + .insert('1') + .delete(1) + .retain(2) + .insert('2') + .delete(1) + .retain(3) + .insert('3') + .delete(1) + .retain(4) + .insert('4') + .delete(1); + editor.applyDelta(delta); + expect(editor.getDelta()).toEqual(old.compose(delta)); + expect(this.container).toEqualHTML( + '


1a

2bb

3ccc

4dddd

', + ); + }); + it('improper block embed insert', function() { const editor = this.initialize(Editor, '

0123

'); editor.applyDelta(new Delta().retain(2).insert({ video: '#' })); diff --git a/test/unit/modules/clipboard.js b/test/unit/modules/clipboard.js index 03b3b1eb7a..044d1ab1a2 100644 --- a/test/unit/modules/clipboard.js +++ b/test/unit/modules/clipboard.js @@ -32,12 +32,16 @@ describe('Clipboard', function() { }, 2); }); + // Copying from Word includes both html and files it('pastes html data if present with file', function(done) { const upload = spyOn(this.quill.uploader, 'upload'); - this.quill.clipboard.onCapturePaste( - // eslint-disable-next-line prefer-object-spread - Object.assign({}, this.clipboardEvent, { files: ['file '] }), - ); + this.quill.clipboard.onCapturePaste({ + ...this.clipboardEvent, + clipboardData: { + ...this.clipboardEvent.clipboardData, + files: ['file'], + }, + }); setTimeout(() => { expect(upload).not.toHaveBeenCalled(); expect(this.quill.root).toEqualHTML( @@ -48,6 +52,24 @@ describe('Clipboard', function() { }, 2); }); + it('pastes image file if present with image only html', function(done) { + const upload = spyOn(this.quill.uploader, 'upload'); + this.quill.clipboard.onCapturePaste({ + ...this.clipboardEvent, + clipboardData: { + getData: type => + type === 'text/html' + ? `` + : '|', + files: ['file'], + }, + }); + setTimeout(() => { + expect(upload).toHaveBeenCalled(); + done(); + }, 2); + }); + it('does not fire selection-change', function(done) { const change = jasmine.createSpy('change'); this.quill.on('selection-change', change);