diff --git a/packages/quill/src/blots/block.ts b/packages/quill/src/blots/block.ts index e4c4f98747..fe2093b429 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,11 @@ class Block extends BlockBlot { deleteAt(index: number, length: number) { super.deleteAt(index, length); + this.children.forEach((child) => { + if (child instanceof Break) { + child.optimize(); + } + }); this.cache = {}; } @@ -42,6 +49,11 @@ class Block extends BlockBlot { value, ); } + this.children.forEach((child) => { + if (child instanceof Break) { + child.optimize(); + } + }); this.cache = {}; } @@ -55,11 +67,17 @@ 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 (str === SOFT_BREAK_CHARACTER) { + super.insertAt(i, SoftBreak.blotName, SOFT_BREAK_CHARACTER); + } else { + super.insertAt(Math.min(i, this.length() - 1), str); + } + i += str.length; + }); + this.cache = {}; } // TODO: Fix this next time the file is edited. @@ -74,11 +92,12 @@ 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.children.forEach((child) => { + if (child instanceof Break) { + child.optimize(); + } + }); this.cache = {}; } @@ -96,6 +115,15 @@ class Block extends BlockBlot { optimize(context: { [key: string]: any }) { super.optimize(context); + + // in order for an end-of-block soft break to be rendered properly by the browser, we need a trailing break + if ( + this.children.length > 0 && + this.children.tail?.statics.blotName === SoftBreak.blotName + ) { + const breakBlot = this.scroll.create(Break.blotName); + super.insertBefore(breakBlot, null); + } this.cache = {}; } diff --git a/packages/quill/src/blots/break.ts b/packages/quill/src/blots/break.ts index db4c2a7faf..dca023b867 100644 --- a/packages/quill/src/blots/break.ts +++ b/packages/quill/src/blots/break.ts @@ -1,12 +1,19 @@ import { EmbedBlot } 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 noPrevBlots = this.prev == null; + const prevBlotIsSoftBreak = + this.prev != null && this.prev.statics.blotName == SoftBreak.blotName; + const shouldRender = + thisIsLastBlotInParent && (noPrevBlots || prevBlotIsSoftBreak); + 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..87b05e1d5e 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'); @@ -33,6 +34,7 @@ const CLIPBOARD_CONFIG: [Selector, Matcher][] = [ [Node.TEXT_NODE, matchText], [Node.TEXT_NODE, matchNewline], ['br', matchBreak], + ['br.soft-break', matchSoftBreak], [Node.ELEMENT_NODE, matchNewline], [Node.ELEMENT_NODE, matchBlot], [Node.ELEMENT_NODE, matchAttributor], @@ -496,6 +498,10 @@ function matchBreak(node: Node, delta: Delta) { return delta; } +function matchSoftBreak() { + return new Delta().insert(SOFT_BREAK_CHARACTER); +} + function matchCodeBlock(node: Node, delta: Delta, scroll: ScrollBlot) { const match = scroll.query('code-block'); const language = diff --git a/packages/quill/src/modules/keyboard.ts b/packages/quill/src/modules/keyboard.ts index 7941b370f5..7197c3e616 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 SoftBreak, { 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,11 @@ 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); + this.quill.setSelection(range.index + 1); + } } 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/core/editor.spec.ts b/packages/quill/test/unit/core/editor.spec.ts index 0c595332bb..9b808ce3a9 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,30 @@ describe('Editor', () => {


`); }); + test('insert soft line', () => { + 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', () => { + 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('multiline text', () => { const editor = createEditor('

0123

'); editor.insertText(2, '\n!!\n!!\n'); @@ -240,6 +268,17 @@ describe('Editor', () => { expect(editor.scroll.domNode).toEqualHTML('

01235678

'); }); + test('soft line', () => { + 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('entire document', () => { const editor = createEditor('

0123

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

0123

'); }); + + test('soft line', () => { + 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 +342,22 @@ describe('Editor', () => { expect(editor.scroll.domNode).toEqualHTML('

01

34

'); }); + test('soft line', () => { + 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 +450,52 @@ describe('Editor', () => { expect(editor.scroll.domNode).toEqualHTML('

01


'); }); + test('insert soft line 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 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(