diff --git a/packages/quill/src/core/editor.ts b/packages/quill/src/core/editor.ts index a19485840d..b6391a7cd4 100644 --- a/packages/quill/src/core/editor.ts +++ b/packages/quill/src/core/editor.ts @@ -370,7 +370,8 @@ function convertHTML( return blot.html(index, length); } if (blot instanceof TextBlot) { - return escapeText(blot.value().slice(index, index + length)); + const escapedText = escapeText(blot.value().slice(index, index + length)); + return escapedText.replaceAll(' ', ' '); } if (blot instanceof ParentBlot) { // TODO fix API diff --git a/packages/quill/src/modules/clipboard.ts b/packages/quill/src/modules/clipboard.ts index e4c3f755b5..ea3df80403 100644 --- a/packages/quill/src/modules/clipboard.ts +++ b/packages/quill/src/modules/clipboard.ts @@ -624,9 +624,12 @@ function matchTable( return delta; } +const NBSP = '\u00a0'; +const SPACE_EXCLUDE_NBSP = `[^\\S${NBSP}]`; + function matchText(node: HTMLElement, delta: Delta, scroll: ScrollBlot) { // @ts-expect-error - let text = node.data; + let text = node.data as string; // Word represents empty line with   if (node.parentElement?.tagName === 'O:P') { return delta.insert(text.trim()); @@ -639,12 +642,8 @@ function matchText(node: HTMLElement, delta: Delta, scroll: ScrollBlot) { ) { return delta; } - const replacer = (collapse: unknown, match: string) => { - const replaced = match.replace(/[^\u00a0]/g, ''); // \u00a0 is nbsp; - return replaced.length < 1 && collapse ? ' ' : replaced; - }; text = text.replace(/\r\n/g, ' ').replace(/\n/g, ' '); - text = text.replace(/\s\s+/g, replacer.bind(replacer, true)); // collapse whitespace + text = text.replace(new RegExp(`${SPACE_EXCLUDE_NBSP}{2,}`, 'g'), ' '); // collapse whitespace if ( (node.previousSibling == null && node.parentElement != null && @@ -652,7 +651,7 @@ function matchText(node: HTMLElement, delta: Delta, scroll: ScrollBlot) { (node.previousSibling instanceof Element && isLine(node.previousSibling, scroll)) ) { - text = text.replace(/^\s+/, replacer.bind(replacer, false)); + text = text.replace(new RegExp(`^${SPACE_EXCLUDE_NBSP}+`), ''); } if ( (node.nextSibling == null && @@ -660,8 +659,9 @@ function matchText(node: HTMLElement, delta: Delta, scroll: ScrollBlot) { isLine(node.parentElement, scroll)) || (node.nextSibling instanceof Element && isLine(node.nextSibling, scroll)) ) { - text = text.replace(/\s+$/, replacer.bind(replacer, false)); + text = text.replace(new RegExp(`${SPACE_EXCLUDE_NBSP}+$`), ''); } + text = text.replaceAll(NBSP, ' '); } return delta.insert(text); } diff --git a/packages/quill/test/unit/core/editor.spec.ts b/packages/quill/test/unit/core/editor.spec.ts index 0c595332bb..2d47a1dec9 100644 --- a/packages/quill/test/unit/core/editor.spec.ts +++ b/packages/quill/test/unit/core/editor.spec.ts @@ -28,9 +28,11 @@ import { ColorClass } from '../../../src/formats/color.js'; import Quill from '../../../src/core.js'; import { normalizeHTML } from '../__helpers__/utils.js'; -const createEditor = (html: string) => { +const createEditor = (htmlOrContents: string | Delta) => { const container = document.createElement('div'); - container.innerHTML = normalizeHTML(html); + if (typeof htmlOrContents === 'string') { + container.innerHTML = normalizeHTML(htmlOrContents); + } document.body.appendChild(container); const quill = new Quill(container, { registry: createRegistry([ @@ -54,6 +56,9 @@ const createEditor = (html: string) => { SizeClass, ]), }); + if (typeof htmlOrContents !== 'string') { + quill.setContents(htmlOrContents); + } return quill.editor; }; @@ -1246,6 +1251,25 @@ describe('Editor', () => { ); }); + test('collapsible spaces', () => { + expect( + createEditor('

123 123 123

').getHTML( + 0, + 11, + ), + ).toEqual('123 123 123'); + + expect(createEditor(new Delta().insert('1 2\n')).getHTML(0, 5)).toEqual( + '1   2', + ); + + expect( + createEditor( + new Delta().insert(' 123', { bold: true }).insert('\n'), + ).getHTML(0, 5), + ).toEqual('  123'); + }); + test('mixed list', () => { const editor = createEditor( ` diff --git a/packages/quill/test/unit/modules/clipboard.spec.ts b/packages/quill/test/unit/modules/clipboard.spec.ts index 0ba7c159ed..69142690a2 100644 --- a/packages/quill/test/unit/modules/clipboard.spec.ts +++ b/packages/quill/test/unit/modules/clipboard.spec.ts @@ -244,6 +244,12 @@ describe('Clipboard', () => { expect(delta).toEqual(new Delta().insert('0\n1 2 3 4\n5 6 7 8')); }); + test('multiple whitespaces', () => { + const html = '
1 2 3
'; + const delta = createClipboard().convert({ html }); + expect(delta).toEqual(new Delta().insert('1 2 3')); + }); + test('inline whitespace', () => { const html = '

0 1 2

'; const delta = createClipboard().convert({ html }); @@ -256,19 +262,23 @@ describe('Clipboard', () => { const html = '1 2'; const delta = createClipboard().convert({ html }); expect(delta).toEqual( - new Delta() - .insert('0\u00a0') - .insert('1', { bold: true }) - .insert('\u00a02'), + new Delta().insert('0 ').insert('1', { bold: true }).insert(' 2'), ); }); test('consecutive intentional whitespace', () => { const html = '  1  '; const delta = createClipboard().convert({ html }); - expect(delta).toEqual( - new Delta().insert('\u00a0\u00a01\u00a0\u00a0', { bold: true }), - ); + expect(delta).toEqual(new Delta().insert(' 1 ', { bold: true })); + }); + + test('intentional whitespace at line start/end', () => { + expect( + createClipboard().convert({ html: '

0  

  2

' }), + ).toEqual(new Delta().insert('0 \n 2')); + expect( + createClipboard().convert({ html: '

 2

' }), + ).toEqual(new Delta().insert('0 \n 2')); }); test('newlines between inline elements', () => { diff --git a/packages/quill/tsconfig.json b/packages/quill/tsconfig.json index ad105b7c1e..062af2c011 100644 --- a/packages/quill/tsconfig.json +++ b/packages/quill/tsconfig.json @@ -7,7 +7,7 @@ "compilerOptions": { "outDir": "./dist", "allowSyntheticDefaultImports": true, - "target": "ES2020", + "target": "ES2021", "sourceMap": true, "resolveJsonModule": true, "declaration": false, diff --git a/tsconfig.json b/tsconfig.json index 87860003ec..146b141a1f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,7 +7,7 @@ }, "compilerOptions": { "allowSyntheticDefaultImports": true, - "target": "ES2020", + "target": "ES2021", "sourceMap": true, "declaration": true, "module": "ES2020",