diff --git a/packages/quill/src/blots/block.ts b/packages/quill/src/blots/block.ts index e4c4f98747..7e8828a71d 100644 --- a/packages/quill/src/blots/block.ts +++ b/packages/quill/src/blots/block.ts @@ -10,8 +10,10 @@ import Delta from 'quill-delta'; import Break from './break.js'; import Inline from './inline.js'; import TextBlot from './text.js'; +import SoftBreak, { SOFT_BREAK_CHARACTER } from './soft-break.js'; const NEWLINE_LENGTH = 1; +const softBreakRegex = new RegExp(`(${SOFT_BREAK_CHARACTER})`, 'g'); class Block extends BlockBlot { cache: { delta?: Delta | null; length?: number } = {}; @@ -25,6 +27,7 @@ class Block extends BlockBlot { deleteAt(index: number, length: number) { super.deleteAt(index, length); + this.optimizeChildren(); this.cache = {}; } @@ -42,6 +45,7 @@ class Block extends BlockBlot { value, ); } + this.optimizeChildren(); this.cache = {}; } @@ -55,11 +59,35 @@ class Block extends BlockBlot { const lines = value.split('\n'); const text = lines.shift() as string; if (text.length > 0) { - if (index < this.length() - 1 || this.children.tail == null) { - super.insertAt(Math.min(index, this.length() - 1), text); - } else { - this.children.tail.insertAt(this.children.tail.length(), text); - } + const softLines = text.split(softBreakRegex); + let i = index; + softLines.forEach((str) => { + if (i < this.length() - 1 || this.children.tail == null) { + const insertIndex = Math.min(i, this.length() - 1); + if (str === SOFT_BREAK_CHARACTER) { + super.insertAt( + insertIndex, + SoftBreak.blotName, + SOFT_BREAK_CHARACTER, + ); + } else { + super.insertAt(insertIndex, str); + } + } else { + const insertIndex = this.children.tail.length(); + if (str === SOFT_BREAK_CHARACTER) { + this.children.tail.insertAt( + insertIndex, + SoftBreak.blotName, + SOFT_BREAK_CHARACTER, + ); + } else { + this.children.tail.insertAt(insertIndex, str); + } + } + i += str.length; + }); + this.cache = {}; } // TODO: Fix this next time the file is edited. @@ -74,11 +102,8 @@ class Block extends BlockBlot { } insertBefore(blot: Blot, ref?: Blot | null) { - const { head } = this.children; super.insertBefore(blot, ref); - if (head instanceof Break) { - head.remove(); - } + this.optimizeChildren(); this.cache = {}; } @@ -96,6 +121,17 @@ class Block extends BlockBlot { optimize(context: { [key: string]: any }) { super.optimize(context); + const lastLeafInBlock = this.descendants(LeafBlot).at(-1); + + // in order for an end-of-block soft break to be rendered properly by the browser, we need a trailing break + if ( + lastLeafInBlock != null && + lastLeafInBlock.statics.blotName === SoftBreak.blotName && + this.children.tail?.statics.blotName !== Break.blotName + ) { + const breakBlot = this.scroll.create(Break.blotName); + super.insertBefore(breakBlot, null); + } this.cache = {}; } @@ -122,6 +158,14 @@ class Block extends BlockBlot { this.cache = {}; return next; } + + private optimizeChildren() { + this.children.forEach((child) => { + if (child instanceof Break) { + child.optimize(); + } + }); + } } Block.blotName = 'block'; Block.tagName = 'P'; diff --git a/packages/quill/src/blots/break.ts b/packages/quill/src/blots/break.ts index db4c2a7faf..b65588da7c 100644 --- a/packages/quill/src/blots/break.ts +++ b/packages/quill/src/blots/break.ts @@ -1,12 +1,25 @@ -import { EmbedBlot } from 'parchment'; +import { EmbedBlot, LeafBlot, ParentBlot } from 'parchment'; +import SoftBreak from './soft-break.js'; class Break extends EmbedBlot { static value() { return undefined; } - optimize() { - if (this.prev || this.next) { + optimize(): void { + const thisIsLastBlotInParent = this.next == null; + const thisIsFirstBlotInParent = this.prev == null; + const thisIsOnlyBlotInParent = + thisIsLastBlotInParent && thisIsFirstBlotInParent; + const prevLeaf = + this.prev instanceof ParentBlot + ? this.prev.descendants(LeafBlot).at(-1) + : this.prev; + const prevLeafIsSoftBreak = + prevLeaf != null && prevLeaf.statics.blotName == SoftBreak.blotName; + const shouldRender = + thisIsOnlyBlotInParent || (thisIsLastBlotInParent && prevLeafIsSoftBreak); + if (!shouldRender) { this.remove(); } } diff --git a/packages/quill/src/blots/soft-break.ts b/packages/quill/src/blots/soft-break.ts new file mode 100644 index 0000000000..b3cc3dd2b2 --- /dev/null +++ b/packages/quill/src/blots/soft-break.ts @@ -0,0 +1,21 @@ +import { EmbedBlot } from 'parchment'; + +export const SOFT_BREAK_CHARACTER = '\u2028'; + +export default class SoftBreak extends EmbedBlot { + static tagName = 'BR'; + static blotName: string = 'soft-break'; + static className: string = 'soft-break'; + + length(): number { + return 1; + } + + value(): string { + return SOFT_BREAK_CHARACTER; + } + + optimize(): void { + return; + } +} diff --git a/packages/quill/src/core.ts b/packages/quill/src/core.ts index 5b8946ad5f..65d0df47d9 100644 --- a/packages/quill/src/core.ts +++ b/packages/quill/src/core.ts @@ -15,6 +15,7 @@ import Embed from './blots/embed.js'; import Inline from './blots/inline.js'; import Scroll from './blots/scroll.js'; import TextBlot from './blots/text.js'; +import SoftBreak from './blots/soft-break.js'; import Clipboard from './modules/clipboard.js'; import History from './modules/history.js'; @@ -38,6 +39,7 @@ Quill.register({ 'blots/block': Block, 'blots/block/embed': BlockEmbed, 'blots/break': Break, + 'blots/soft-break': SoftBreak, 'blots/container': Container, 'blots/cursor': Cursor, 'blots/embed': Embed, diff --git a/packages/quill/src/core/editor.ts b/packages/quill/src/core/editor.ts index a19485840d..bf4845d542 100644 --- a/packages/quill/src/core/editor.ts +++ b/packages/quill/src/core/editor.ts @@ -219,6 +219,7 @@ class Editor { const normalizedDelta = normalizeDelta(contents); const change = new Delta().retain(index).concat(normalizedDelta); this.scroll.insertContents(index, normalizedDelta); + this.scroll.optimize(); return this.update(change); } diff --git a/packages/quill/src/modules/clipboard.ts b/packages/quill/src/modules/clipboard.ts index e4c3f755b5..62f26351d3 100644 --- a/packages/quill/src/modules/clipboard.ts +++ b/packages/quill/src/modules/clipboard.ts @@ -23,6 +23,7 @@ import { FontStyle } from '../formats/font.js'; import { SizeStyle } from '../formats/size.js'; import { deleteRange } from './keyboard.js'; import normalizeExternalHTML from './normalizeExternalHTML/index.js'; +import { SOFT_BREAK_CHARACTER } from '../blots/soft-break.js'; const debug = logger('quill:clipboard'); @@ -489,11 +490,46 @@ function matchBlot(node: Node, delta: Delta, scroll: ScrollBlot) { return delta; } -function matchBreak(node: Node, delta: Delta) { - if (!deltaEndsWith(delta, '\n')) { - delta.insert('\n'); +function matchBreak(node: Node, delta: Delta, scroll: ScrollBlot) { + const parentLineElement = getParentLine(node, scroll); + if (parentLineElement == null) { + //
tags pasted without a parent will be treated as soft breaks + return new Delta().insert(SOFT_BREAK_CHARACTER); } - return delta; + if (isPre(parentLineElement)) { + // code blocks don't allow soft breaks + return new Delta().insert('\n'); + } + if (isInLastPositionOfParentLine(node, parentLineElement)) { + // ignore trailing breaks + return delta; + } + return new Delta().insert(SOFT_BREAK_CHARACTER); +} + +function getParentLine(node: Node, scroll: ScrollBlot): HTMLElement | null { + let current: Node = node; + while (current.parentElement != null) { + if (isLine(current.parentElement, scroll)) { + return current.parentElement; + } + current = current.parentElement; + } + return null; +} + +function isInLastPositionOfParentLine( + node: Node, + parentLineElement: HTMLElement, +): boolean { + let current: Node = node; + while (current.nextSibling == null && current.parentElement != null) { + if (current.parentElement === parentLineElement) { + return true; + } + current = current.parentElement; + } + return false; } function matchCodeBlock(node: Node, delta: Delta, scroll: ScrollBlot) { diff --git a/packages/quill/src/modules/keyboard.ts b/packages/quill/src/modules/keyboard.ts index 7941b370f5..5375f263f6 100644 --- a/packages/quill/src/modules/keyboard.ts +++ b/packages/quill/src/modules/keyboard.ts @@ -7,6 +7,7 @@ import logger from '../core/logger.js'; import Module from '../core/module.js'; import type { BlockEmbed } from '../blots/block.js'; import type { Range } from '../core/selection.js'; +import { SOFT_BREAK_CHARACTER } from '../blots/soft-break.js'; const debug = logger('quill:keyboard'); @@ -84,6 +85,13 @@ class Keyboard extends Module { this.addBinding(this.options.bindings[name]); } }); + this.addBinding( + { + key: 'Enter', + shiftKey: true, + }, + this.handleShiftEnter, + ); this.addBinding({ key: 'Enter', shiftKey: null }, this.handleEnter); this.addBinding( { key: 'Enter', metaKey: null, ctrlKey: null, altKey: null }, @@ -352,6 +360,15 @@ class Keyboard extends Module { this.quill.setSelection(range.index + 1, Quill.sources.SILENT); this.quill.focus(); } + + handleShiftEnter(range: Range) { + this.quill.insertText( + range.index, + SOFT_BREAK_CHARACTER, + Quill.sources.USER, + ); + this.quill.setSelection(range.index + 1, Quill.sources.SILENT); + } } const defaultOptions: KeyboardOptions = { diff --git a/packages/quill/test/e2e/__dev_server__/index.html b/packages/quill/test/e2e/__dev_server__/index.html index f69d3ffbeb..10f88f2843 100644 --- a/packages/quill/test/e2e/__dev_server__/index.html +++ b/packages/quill/test/e2e/__dev_server__/index.html @@ -1,74 +1,75 @@ - + + + + + + Quill E2E Tests + + + + - - - - - Quill E2E Tests - - - - - - -
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
-
-
- - - \ No newline at end of file + + diff --git a/packages/quill/test/unit/__helpers__/factory.ts b/packages/quill/test/unit/__helpers__/factory.ts index 6fa4b7f49d..3a06596985 100644 --- a/packages/quill/test/unit/__helpers__/factory.ts +++ b/packages/quill/test/unit/__helpers__/factory.ts @@ -10,6 +10,7 @@ import ListItem, { ListContainer } from '../../../src/formats/list.js'; import Inline from '../../../src/blots/inline.js'; import Emitter from '../../../src/core/emitter.js'; import { normalizeHTML } from './utils.js'; +import SoftBreak from '../../../src/blots/soft-break.js'; export const createRegistry = (formats: unknown[] = []) => { const registry = new Registry(); @@ -19,6 +20,7 @@ export const createRegistry = (formats: unknown[] = []) => { }); registry.register(Block); registry.register(Break); + registry.register(SoftBreak); registry.register(Cursor); registry.register(Inline); registry.register(Scroll); diff --git a/packages/quill/test/unit/core/editor.spec.ts b/packages/quill/test/unit/core/editor.spec.ts index 0c595332bb..585fff5add 100644 --- a/packages/quill/test/unit/core/editor.spec.ts +++ b/packages/quill/test/unit/core/editor.spec.ts @@ -27,6 +27,9 @@ import IndentClass from '../../../src/formats/indent.js'; import { ColorClass } from '../../../src/formats/color.js'; import Quill from '../../../src/core.js'; import { normalizeHTML } from '../__helpers__/utils.js'; +import SoftBreak, { + SOFT_BREAK_CHARACTER, +} from '../../../src/blots/soft-break.js'; const createEditor = (html: string) => { const container = document.createElement('div'); @@ -52,6 +55,7 @@ const createEditor = (html: string) => { CodeBlockContainer, Blockquote, SizeClass, + SoftBreak, ]), }); return quill.editor; @@ -157,6 +161,42 @@ describe('Editor', () => {


`); }); + test('insert soft line break', () => { + const editor = createEditor('

0123

'); + editor.insertText(3, SOFT_BREAK_CHARACTER); + expect(editor.getDelta()).toEqual( + new Delta() + .insert(`012${SOFT_BREAK_CHARACTER}3`, { bold: true }) + .insert('\n'), + ); + expect(editor.scroll.domNode).toEqualHTML(` +

012
3

`); + }); + + test('append soft line break', () => { + const editor = createEditor('
  1. 0123
'); + editor.insertText(4, SOFT_BREAK_CHARACTER); + expect(editor.getDelta()).toEqual( + new Delta() + .insert(`0123${SOFT_BREAK_CHARACTER}`) + .insert('\n', { list: 'bullet' }), + ); + expect(editor.scroll.domNode).toEqualHTML(` +
  1. 0123

`); + }); + + test('append soft line break in format', () => { + const editor = createEditor('

0123

'); + editor.insertText(4, SOFT_BREAK_CHARACTER); + expect(editor.getDelta()).toEqual( + new Delta() + .insert(`0123${SOFT_BREAK_CHARACTER}`, { bold: true }) + .insert('\n'), + ); + expect(editor.scroll.domNode).toEqualHTML(` +

0123

`); + }); + test('multiline text', () => { const editor = createEditor('

0123

'); editor.insertText(2, '\n!!\n!!\n'); @@ -201,6 +241,20 @@ describe('Editor', () => { new Delta().insert('01', { strike: true }).insert('23\n'), ); }); + + test('formatted text at the end of a block that ends with other format', () => { + const editor = createEditor('

01

'); + editor.insertText(2, 'example', { link: 'http://example.com' }); + expect(editor.getDelta()).toEqual( + new Delta() + .insert('01', { bold: true }) + .insert('example', { link: 'http://example.com', bold: true }) + .insert('\n'), + ); + expect(editor.scroll.domNode).toEqualHTML( + '

01example

', + ); + }); }); describe('delete', () => { @@ -240,6 +294,47 @@ describe('Editor', () => { expect(editor.scroll.domNode).toEqualHTML('

01235678

'); }); + test('soft line break at end of bold text', () => { + const editor = createEditor( + '

0123

', + ); + editor.deleteText(4, 1); + expect(editor.getDelta()).toEqual( + new Delta().insert('0123', { bold: true }).insert('\n'), + ); + expect(editor.scroll.domNode).toEqualHTML('

0123

'); + }); + + test('text before soft line break', () => { + const editor = createEditor('

0
1

'); + editor.deleteText(2, 1); + expect(editor.getDelta()).toEqual( + new Delta().insert(`0${SOFT_BREAK_CHARACTER}`).insert('\n'), + ); + // importantly deleting the character after a soft break, such that the soft break becomes + // the last leaf in the block, should add the trailing break + expect(editor.scroll.domNode).toEqualHTML( + '

0

', + ); + }); + + test('text before soft line break within an inline parent', () => { + const editor = createEditor( + '

0
1

', + ); + editor.deleteText(2, 1); + expect(editor.getDelta()).toEqual( + new Delta() + .insert(`0${SOFT_BREAK_CHARACTER}`, { bold: true }) + .insert('\n'), + ); + // importantly deleting the character after a soft break, such that the soft break becomes + // the last leaf in the block, should add the trailing break + expect(editor.scroll.domNode).toEqualHTML( + '

0

', + ); + }); + test('entire document', () => { const editor = createEditor('

0123

'); editor.deleteText(0, 5); @@ -265,6 +360,19 @@ describe('Editor', () => { editor.formatLine(1, 1, { header: 1 }); expect(editor.scroll.domNode).toEqualHTML('

0123

'); }); + + test('soft line break', () => { + const editor = createEditor('

01
23

'); + editor.formatLine(0, 1, { header: 1 }); + expect(editor.getDelta()).toEqual( + new Delta() + .insert(`01${SOFT_BREAK_CHARACTER}23`) + .insert('\n', { header: 1 }), + ); + expect(editor.scroll.domNode).toEqualHTML( + '

01
23

', + ); + }); }); describe('removeFormat', () => { @@ -290,6 +398,22 @@ describe('Editor', () => { expect(editor.scroll.domNode).toEqualHTML('

01

34

'); }); + test('soft line break', () => { + const editor = createEditor( + '

01
23

', + ); + editor.removeFormat(0, 2); + expect(editor.getDelta()).toEqual( + new Delta() + .insert('01') + .insert(`${SOFT_BREAK_CHARACTER}23`, { bold: true }) + .insert('\n'), + ); + expect(editor.scroll.domNode).toEqualHTML( + '

01
23

', + ); + }); + test('remove embed', () => { const editor = createEditor('

02

'); editor.removeFormat(1, 1); @@ -382,6 +506,52 @@ describe('Editor', () => { expect(editor.scroll.domNode).toEqualHTML('

01


'); }); + test('insert soft line break at end of block', () => { + const editor = createEditor( + `
    +
  1. 0
  2. +
  3. 1
  4. +
`, + ); + editor.applyDelta(new Delta().retain(3).insert(SOFT_BREAK_CHARACTER)); + expect(editor.getDelta()).toEqual( + new Delta() + .insert('0') + .insert('\n', { list: 'ordered' }) + .insert(`1${SOFT_BREAK_CHARACTER}`) + .insert('\n', { list: 'ordered' }), + ); + expect(editor.scroll.domNode).toEqualHTML( + `
    +
  1. 0
  2. +
  3. + 1 +
    +
    +
  4. +
`, + ); + }); + + test('insert soft line break in middle of block', () => { + const editor = createEditor( + `
    +
  1. 01
  2. +
`, + ); + editor.applyDelta(new Delta().retain(1).insert(SOFT_BREAK_CHARACTER)); + expect(editor.getDelta()).toEqual( + new Delta() + .insert(`0${SOFT_BREAK_CHARACTER}1`) + .insert('\n', { list: 'ordered' }), + ); + expect(editor.scroll.domNode).toEqualHTML( + `
    +
  1. 0
    1
  2. +
`, + ); + }); + test('formatted embed', () => { const editor = createEditor(''); editor.applyDelta( diff --git a/packages/quill/test/unit/modules/clipboard.spec.ts b/packages/quill/test/unit/modules/clipboard.spec.ts index 0ba7c159ed..f716c0c336 100644 --- a/packages/quill/test/unit/modules/clipboard.spec.ts +++ b/packages/quill/test/unit/modules/clipboard.spec.ts @@ -2,6 +2,7 @@ import Delta from 'quill-delta'; import { describe, expect, test, vitest } from 'vitest'; import Quill from '../../../src/core.js'; import { Range } from '../../../src/core/selection.js'; +import { SOFT_BREAK_CHARACTER } from '../../../src/blots/soft-break.js'; import Bold from '../../../src/formats/bold.js'; import Header from '../../../src/formats/header.js'; import Image from '../../../src/formats/image.js'; @@ -307,13 +308,81 @@ describe('Clipboard', () => { expect(delta).toEqual(new Delta().insert('\n')); }); - test('break', () => { + test('break tag pasted without a parent should be treated as soft break', () => { + const html = '
'; + const delta = createClipboard().convert({ html }); + expect(delta).toEqual(new Delta().insert(SOFT_BREAK_CHARACTER)); + }); + + test('breaks are treated as soft breaks unless they are at the end of a block', () => { const html = '
0
1
2
3

4

5
'; const delta = createClipboard().convert({ html }); - expect(delta).toEqual(new Delta().insert('0\n1\n2\n3\n\n4\n\n5')); + expect(delta).toEqual( + new Delta().insert( + `0${SOFT_BREAK_CHARACTER}1\n2\n3\n${SOFT_BREAK_CHARACTER}4\n\n5`, + ), + ); }); + const softBreaksCases: [string, Delta][] = [ + ['


', new Delta()], + ['



', new Delta().insert(`${SOFT_BREAK_CHARACTER}`)], + [ + '

a

', + new Delta().insert('a', { bold: true }), + ], + [ + '

a

', + new Delta().insert('a', { bold: true }), + ], + [ + '

a

', + new Delta() + .insert('a', { bold: true }) + .insert(`${SOFT_BREAK_CHARACTER}`), + ], + [ + '

a

', + new Delta().insert(`a${SOFT_BREAK_CHARACTER}`, { bold: true }), + ], + [ + '

a

', + new Delta().insert('a', { bold: true, italic: true }), + ], + [ + '

a

', + new Delta().insert(`a${SOFT_BREAK_CHARACTER}`, { + bold: true, + italic: true, + }), + ], + [ + '

a

', + new Delta() + .insert('a', { + bold: true, + italic: true, + }) + .insert(`${SOFT_BREAK_CHARACTER}`, { bold: true }), + ], + [ + '

a

', + new Delta() + .insert('a', { + bold: true, + italic: true, + }) + .insert(`${SOFT_BREAK_CHARACTER}`), + ], + ]; + for (const [html, expectedDelta] of softBreaksCases) { + test(`breaks matching for nested formats ${html}`, () => { + const delta = createClipboard().convert({ html }); + expect(delta).toEqual(expectedDelta); + }); + } + test('empty block', () => { const html = '

Test

Body

'; const delta = createClipboard().convert({ html }); @@ -583,7 +652,7 @@ describe('Clipboard', () => { .insert('i1\ni2\n', { list: 'ordered' }) .insert('i3\n', { list: 'ordered', indent: 1 }) .insert('text', { bold: true }) - .insert('\n'), + .insert(`\n${SOFT_BREAK_CHARACTER}`), ); });