From be5e70c138ffd772e5c41c471785a74b59bd97be Mon Sep 17 00:00:00 2001 From: Dean Way Date: Mon, 13 Jan 2025 14:20:53 -0500 Subject: [PATCH 01/17] add soft line breaks --- packages/quill/src/blots/block.ts | 43 +++++++-- packages/quill/src/blots/break.ts | 11 ++- packages/quill/src/blots/soft-break.ts | 21 +++++ packages/quill/src/core.ts | 2 + packages/quill/src/core/editor.ts | 1 + packages/quill/src/modules/clipboard.ts | 6 ++ packages/quill/src/modules/keyboard.ts | 13 +++ packages/quill/test/unit/core/editor.spec.ts | 91 ++++++++++++++++++++ 8 files changed, 176 insertions(+), 12 deletions(-) create mode 100644 packages/quill/src/blots/soft-break.ts diff --git a/packages/quill/src/blots/block.ts b/packages/quill/src/blots/block.ts index e4c4f98747..f952cd8f2a 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,12 @@ 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..8b28d87dce 100644 --- a/packages/quill/src/blots/break.ts +++ b/packages/quill/src/blots/break.ts @@ -1,13 +1,18 @@ import { EmbedBlot } from 'parchment'; +import SoftBreak from './soft-break'; class Break extends EmbedBlot { static value() { return undefined; } - optimize() { - if (this.prev || this.next) { - this.remove(); + 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..0e976da194 --- /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 + } +} \ No newline at end of file diff --git a/packages/quill/src/core.ts b/packages/quill/src/core.ts index 5b8946ad5f..85af72f701 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..27a366f654 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(node: Node, delta: Delta) { + 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..49488af6ff 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.insertEmbed(range.index, SoftBreak.blotName, SOFT_BREAK_CHARACTER) + this.quill.setSelection(range.index + 1); + } } const defaultOptions: KeyboardOptions = { diff --git a/packages/quill/test/unit/core/editor.spec.ts b/packages/quill/test/unit/core/editor.spec.ts index 0c595332bb..ffd900955c 100644 --- a/packages/quill/test/unit/core/editor.spec.ts +++ b/packages/quill/test/unit/core/editor.spec.ts @@ -27,6 +27,7 @@ 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 +53,7 @@ const createEditor = (html: string) => { CodeBlockContainer, Blockquote, SizeClass, + SoftBreak, ]), }); return quill.editor; @@ -157,6 +159,26 @@ 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 +262,13 @@ 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 +294,15 @@ 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 +328,15 @@ 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 +429,50 @@ 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( From 5e00b6d88cabcd27f50ba1119330e20acb6cb8c0 Mon Sep 17 00:00:00 2001 From: Dean Way Date: Mon, 13 Jan 2025 15:17:38 -0500 Subject: [PATCH 02/17] fix lint --- packages/quill/src/blots/block.ts | 25 ++-- packages/quill/src/blots/break.ts | 10 +- packages/quill/src/blots/soft-break.ts | 16 +- packages/quill/src/core.ts | 2 +- packages/quill/src/modules/clipboard.ts | 4 +- packages/quill/src/modules/keyboard.ts | 12 +- .../quill/test/e2e/__dev_server__/index.html | 141 +++++++++--------- packages/quill/test/unit/core/editor.spec.ts | 61 +++++--- 8 files changed, 152 insertions(+), 119 deletions(-) diff --git a/packages/quill/src/blots/block.ts b/packages/quill/src/blots/block.ts index f952cd8f2a..fe2093b429 100644 --- a/packages/quill/src/blots/block.ts +++ b/packages/quill/src/blots/block.ts @@ -13,7 +13,7 @@ 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"); +const softBreakRegex = new RegExp(`(${SOFT_BREAK_CHARACTER})`, 'g'); class Block extends BlockBlot { cache: { delta?: Delta | null; length?: number } = {}; @@ -27,9 +27,9 @@ class Block extends BlockBlot { deleteAt(index: number, length: number) { super.deleteAt(index, length); - this.children.forEach(child => { + this.children.forEach((child) => { if (child instanceof Break) { - child.optimize() + child.optimize(); } }); this.cache = {}; @@ -49,9 +49,9 @@ class Block extends BlockBlot { value, ); } - this.children.forEach(child => { + this.children.forEach((child) => { if (child instanceof Break) { - child.optimize() + child.optimize(); } }); this.cache = {}; @@ -69,13 +69,13 @@ class Block extends BlockBlot { if (text.length > 0) { const softLines = text.split(softBreakRegex); let i = index; - softLines.forEach(str => { + 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 + i += str.length; }); this.cache = {}; @@ -93,9 +93,9 @@ class Block extends BlockBlot { insertBefore(blot: Blot, ref?: Blot | null) { super.insertBefore(blot, ref); - this.children.forEach(child => { + this.children.forEach((child) => { if (child instanceof Break) { - child.optimize() + child.optimize(); } }); this.cache = {}; @@ -116,8 +116,11 @@ 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) { + // 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); } diff --git a/packages/quill/src/blots/break.ts b/packages/quill/src/blots/break.ts index 8b28d87dce..dca023b867 100644 --- a/packages/quill/src/blots/break.ts +++ b/packages/quill/src/blots/break.ts @@ -1,5 +1,5 @@ import { EmbedBlot } from 'parchment'; -import SoftBreak from './soft-break'; +import SoftBreak from './soft-break.js'; class Break extends EmbedBlot { static value() { @@ -9,10 +9,12 @@ class Break extends EmbedBlot { 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) + const prevBlotIsSoftBreak = + this.prev != null && this.prev.statics.blotName == SoftBreak.blotName; + const shouldRender = + thisIsLastBlotInParent && (noPrevBlots || prevBlotIsSoftBreak); if (!shouldRender) { - this.remove() + this.remove(); } } diff --git a/packages/quill/src/blots/soft-break.ts b/packages/quill/src/blots/soft-break.ts index 0e976da194..b3cc3dd2b2 100644 --- a/packages/quill/src/blots/soft-break.ts +++ b/packages/quill/src/blots/soft-break.ts @@ -1,21 +1,21 @@ -import { EmbedBlot } from "parchment"; +import { EmbedBlot } from 'parchment'; -export const SOFT_BREAK_CHARACTER = "\u2028"; +export const SOFT_BREAK_CHARACTER = '\u2028'; export default class SoftBreak extends EmbedBlot { - static tagName = "BR"; + static tagName = 'BR'; static blotName: string = 'soft-break'; static className: string = 'soft-break'; - + length(): number { - return 1 + return 1; } value(): string { - return SOFT_BREAK_CHARACTER + return SOFT_BREAK_CHARACTER; } optimize(): void { - return + return; } -} \ No newline at end of file +} diff --git a/packages/quill/src/core.ts b/packages/quill/src/core.ts index 85af72f701..65d0df47d9 100644 --- a/packages/quill/src/core.ts +++ b/packages/quill/src/core.ts @@ -39,7 +39,7 @@ Quill.register({ 'blots/block': Block, 'blots/block/embed': BlockEmbed, 'blots/break': Break, - "blots/soft-break": SoftBreak, + 'blots/soft-break': SoftBreak, 'blots/container': Container, 'blots/cursor': Cursor, 'blots/embed': Embed, diff --git a/packages/quill/src/modules/clipboard.ts b/packages/quill/src/modules/clipboard.ts index 27a366f654..87b05e1d5e 100644 --- a/packages/quill/src/modules/clipboard.ts +++ b/packages/quill/src/modules/clipboard.ts @@ -498,8 +498,8 @@ function matchBreak(node: Node, delta: Delta) { return delta; } -function matchSoftBreak(node: Node, delta: Delta) { - return new Delta().insert(SOFT_BREAK_CHARACTER) +function matchSoftBreak() { + return new Delta().insert(SOFT_BREAK_CHARACTER); } 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 49488af6ff..8b5f3274e8 100644 --- a/packages/quill/src/modules/keyboard.ts +++ b/packages/quill/src/modules/keyboard.ts @@ -87,11 +87,11 @@ class Keyboard extends Module { }); this.addBinding( { - key: "Enter", + key: 'Enter', shiftKey: true, }, - this.handleShiftEnter - ) + this.handleShiftEnter, + ); this.addBinding({ key: 'Enter', shiftKey: null }, this.handleEnter); this.addBinding( { key: 'Enter', metaKey: null, ctrlKey: null, altKey: null }, @@ -362,7 +362,11 @@ class Keyboard extends Module { } handleShiftEnter(range: Range) { - this.quill.insertEmbed(range.index, SoftBreak.blotName, SOFT_BREAK_CHARACTER) + this.quill.insertEmbed( + range.index, + SoftBreak.blotName, + SOFT_BREAK_CHARACTER, + ); this.quill.setSelection(range.index + 1); } } 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 ffd900955c..9b808ce3a9 100644 --- a/packages/quill/test/unit/core/editor.spec.ts +++ b/packages/quill/test/unit/core/editor.spec.ts @@ -27,7 +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'; +import SoftBreak, { + SOFT_BREAK_CHARACTER, +} from '../../../src/blots/soft-break.js'; const createEditor = (html: string) => { const container = document.createElement('div'); @@ -163,7 +165,9 @@ describe('Editor', () => { 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'), + new Delta() + .insert(`012${SOFT_BREAK_CHARACTER}3`, { bold: true }) + .insert('\n'), ); expect(editor.scroll.domNode).toEqualHTML(`

012
3

`); @@ -173,7 +177,9 @@ describe('Editor', () => { 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"}), + new Delta() + .insert(`0123${SOFT_BREAK_CHARACTER}`) + .insert('\n', { list: 'bullet' }), ); expect(editor.scroll.domNode).toEqualHTML(`
  1. 0123

`); @@ -263,9 +269,13 @@ describe('Editor', () => { }); test('soft line', () => { - const editor = createEditor('

0123

'); + const editor = createEditor( + '

0123

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

0123

'); }); @@ -297,11 +307,15 @@ describe('Editor', () => { test('soft line', () => { const editor = createEditor('

01
23

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

01
23

', ); - expect(editor.scroll.domNode).toEqualHTML('

01
23

') }); }); @@ -329,12 +343,19 @@ describe('Editor', () => { }); test('soft line', () => { - const editor = createEditor('

01
23

'); - editor.removeFormat(0, 2) + 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'), + new Delta() + .insert('01') + .insert(`${SOFT_BREAK_CHARACTER}23`, { bold: true }) + .insert('\n'), + ); + expect(editor.scroll.domNode).toEqualHTML( + '

01
23

', ); - expect(editor.scroll.domNode).toEqualHTML('

01
23

') }); test('remove embed', () => { @@ -434,15 +455,15 @@ describe('Editor', () => { `
  1. 0
  2. 1
  3. -
` + `, ); editor.applyDelta(new Delta().retain(3).insert(SOFT_BREAK_CHARACTER)); expect(editor.getDelta()).toEqual( new Delta() .insert('0') - .insert('\n', {list: 'ordered'}) + .insert('\n', { list: 'ordered' }) .insert(`1${SOFT_BREAK_CHARACTER}`) - .insert('\n', {list: 'ordered'}) + .insert('\n', { list: 'ordered' }), ); expect(editor.scroll.domNode).toEqualHTML( `
    @@ -452,7 +473,7 @@ describe('Editor', () => {

    -
` + `, ); }); @@ -460,16 +481,18 @@ describe('Editor', () => { 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'}) + new Delta() + .insert(`0${SOFT_BREAK_CHARACTER}1`) + .insert('\n', { list: 'ordered' }), ); expect(editor.scroll.domNode).toEqualHTML( `
  1. 0
    1
  2. -
` + `, ); }); From 9a9251ae27a056e5a6abc8aca9da17ec947027c6 Mon Sep 17 00:00:00 2001 From: Dean Way Date: Mon, 13 Jan 2025 16:50:53 -0500 Subject: [PATCH 03/17] use insertText over insertEmbed in shiftEnter keyboard binding --- packages/quill/src/modules/keyboard.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/quill/src/modules/keyboard.ts b/packages/quill/src/modules/keyboard.ts index 8b5f3274e8..7197c3e616 100644 --- a/packages/quill/src/modules/keyboard.ts +++ b/packages/quill/src/modules/keyboard.ts @@ -362,11 +362,7 @@ class Keyboard extends Module { } handleShiftEnter(range: Range) { - this.quill.insertEmbed( - range.index, - SoftBreak.blotName, - SOFT_BREAK_CHARACTER, - ); + this.quill.insertText(range.index, SOFT_BREAK_CHARACTER); this.quill.setSelection(range.index + 1); } } From 53bf338f092722f88a0f26c70fe645a1f3f73d84 Mon Sep 17 00:00:00 2001 From: DeanWay Date: Sun, 16 Feb 2025 15:14:18 +0100 Subject: [PATCH 04/17] remove unused import --- packages/quill/src/modules/keyboard.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/quill/src/modules/keyboard.ts b/packages/quill/src/modules/keyboard.ts index 7197c3e616..d373f06e3b 100644 --- a/packages/quill/src/modules/keyboard.ts +++ b/packages/quill/src/modules/keyboard.ts @@ -7,7 +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'; +import { SOFT_BREAK_CHARACTER } from '../blots/soft-break.js'; const debug = logger('quill:keyboard'); From c22140592b7b43fade51435d16edd9e67b11fe20 Mon Sep 17 00:00:00 2001 From: DeanWay Date: Mon, 17 Feb 2025 11:03:11 +0100 Subject: [PATCH 05/17] move repeated child optimization to private method --- packages/quill/src/blots/block.ts | 26 +++++++++++--------------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/packages/quill/src/blots/block.ts b/packages/quill/src/blots/block.ts index fe2093b429..497f4ee86c 100644 --- a/packages/quill/src/blots/block.ts +++ b/packages/quill/src/blots/block.ts @@ -27,11 +27,7 @@ class Block extends BlockBlot { deleteAt(index: number, length: number) { super.deleteAt(index, length); - this.children.forEach((child) => { - if (child instanceof Break) { - child.optimize(); - } - }); + this.optimizeChildren(); this.cache = {}; } @@ -49,11 +45,7 @@ class Block extends BlockBlot { value, ); } - this.children.forEach((child) => { - if (child instanceof Break) { - child.optimize(); - } - }); + this.optimizeChildren(); this.cache = {}; } @@ -93,11 +85,7 @@ class Block extends BlockBlot { insertBefore(blot: Blot, ref?: Blot | null) { super.insertBefore(blot, ref); - this.children.forEach((child) => { - if (child instanceof Break) { - child.optimize(); - } - }); + this.optimizeChildren(); this.cache = {}; } @@ -150,6 +138,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'; From cf23b98ce002e7dec1414f3a742e673b806fc986 Mon Sep 17 00:00:00 2001 From: DeanWay Date: Mon, 17 Feb 2025 11:12:00 +0100 Subject: [PATCH 06/17] add test cases for soft line breaks inside of inline parent blots --- packages/quill/test/unit/core/editor.spec.ts | 52 +++++++++++++++++--- 1 file changed, 45 insertions(+), 7 deletions(-) diff --git a/packages/quill/test/unit/core/editor.spec.ts b/packages/quill/test/unit/core/editor.spec.ts index 9b808ce3a9..08f596fb74 100644 --- a/packages/quill/test/unit/core/editor.spec.ts +++ b/packages/quill/test/unit/core/editor.spec.ts @@ -161,7 +161,7 @@ describe('Editor', () => {


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

0123

'); editor.insertText(3, SOFT_BREAK_CHARACTER); expect(editor.getDelta()).toEqual( @@ -173,7 +173,7 @@ describe('Editor', () => {

012
3

`); }); - test('append soft line', () => { + test('append soft line break', () => { const editor = createEditor('
  1. 0123
'); editor.insertText(4, SOFT_BREAK_CHARACTER); expect(editor.getDelta()).toEqual( @@ -185,6 +185,18 @@ describe('Editor', () => {
  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', { bold: true }) + .insert(`${SOFT_BREAK_CHARACTER}\n`), + ); + expect(editor.scroll.domNode).toEqualHTML(` +

0123

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

0123

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

01235678

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

0123

', ); @@ -279,6 +291,32 @@ describe('Editor', () => { 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); @@ -305,7 +343,7 @@ describe('Editor', () => { expect(editor.scroll.domNode).toEqualHTML('

0123

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

01
23

'); editor.formatLine(0, 1, { header: 1 }); expect(editor.getDelta()).toEqual( @@ -342,7 +380,7 @@ describe('Editor', () => { expect(editor.scroll.domNode).toEqualHTML('

01

34

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

01
23

', ); @@ -450,7 +488,7 @@ describe('Editor', () => { expect(editor.scroll.domNode).toEqualHTML('

01


'); }); - test('insert soft line at end of block', () => { + test('insert soft line break at end of block', () => { const editor = createEditor( `
  1. 0
  2. @@ -477,7 +515,7 @@ describe('Editor', () => { ); }); - test('insert soft line in middle of block', () => { + test('insert soft line break in middle of block', () => { const editor = createEditor( `
    1. 01
    2. From 059d6817c9e1448a87853a29349cdc817309aae2 Mon Sep 17 00:00:00 2001 From: DeanWay Date: Mon, 17 Feb 2025 11:12:56 +0100 Subject: [PATCH 07/17] fix soft breaks implementation to properly insert the trailing break element when a soft break is the last leaf in a block nested in some inline parent --- packages/quill/src/blots/block.ts | 20 ++++++++++++++++++-- packages/quill/src/blots/break.ts | 14 ++++++++------ 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/packages/quill/src/blots/block.ts b/packages/quill/src/blots/block.ts index 497f4ee86c..843382a360 100644 --- a/packages/quill/src/blots/block.ts +++ b/packages/quill/src/blots/block.ts @@ -3,6 +3,7 @@ import { BlockBlot, EmbedBlot, LeafBlot, + ParentBlot, Scope, } from 'parchment'; import type { Blot, Parent } from 'parchment'; @@ -103,11 +104,13 @@ class Block extends BlockBlot { optimize(context: { [key: string]: any }) { super.optimize(context); + const lastLeafInBlock = getLastLeafInParent(this); // 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 + 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); @@ -204,6 +207,19 @@ class BlockEmbed extends EmbedBlot { BlockEmbed.scope = Scope.BLOCK_BLOT; // It is important for cursor behavior BlockEmbeds use tags that are block level elements +export function getLastLeafInParent(blot: ParentBlot): Blot | null { + let current = blot.children.tail; + const MAX_ITERATIONS = 1000; + for (let i = 0; current != null && i < MAX_ITERATIONS; i++) { + if (current instanceof ParentBlot) { + current = current.children.tail; + } else { + return current + } + } + return null +} + function blockDelta(blot: BlockBlot, filter = true) { return blot .descendants(LeafBlot) diff --git a/packages/quill/src/blots/break.ts b/packages/quill/src/blots/break.ts index dca023b867..8b7d577431 100644 --- a/packages/quill/src/blots/break.ts +++ b/packages/quill/src/blots/break.ts @@ -1,5 +1,6 @@ -import { EmbedBlot } from 'parchment'; +import { Blot, EmbedBlot, ParentBlot } from 'parchment'; import SoftBreak from './soft-break.js'; +import { getLastLeafInParent } from './block.js'; class Break extends EmbedBlot { static value() { @@ -8,11 +9,11 @@ class Break extends EmbedBlot { 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); + const thisIsFirstBlotInParent = this.prev == null; + const thisIsOnlyBlotInParent = thisIsLastBlotInParent && thisIsFirstBlotInParent + const prevLeaf = this.prev instanceof ParentBlot ? getLastLeafInParent(this.prev) : this.prev + const prevLeafIsSoftBreak = prevLeaf != null && prevLeaf.statics.blotName == SoftBreak.blotName; + const shouldRender = thisIsOnlyBlotInParent || prevLeafIsSoftBreak; if (!shouldRender) { this.remove(); } @@ -29,4 +30,5 @@ class Break extends EmbedBlot { Break.blotName = 'break'; Break.tagName = 'BR'; + export default Break; From 18c0bd41431b6c2712b928252a46edc87ac5bfc8 Mon Sep 17 00:00:00 2001 From: DeanWay Date: Mon, 17 Feb 2025 13:17:26 +0100 Subject: [PATCH 08/17] fix lint --- packages/quill/src/blots/block.ts | 10 +++++----- packages/quill/src/blots/break.ts | 14 +++++++++----- packages/quill/test/unit/core/editor.spec.ts | 16 ++++++++++------ 3 files changed, 24 insertions(+), 16 deletions(-) diff --git a/packages/quill/src/blots/block.ts b/packages/quill/src/blots/block.ts index 843382a360..8fcbd828ba 100644 --- a/packages/quill/src/blots/block.ts +++ b/packages/quill/src/blots/block.ts @@ -108,9 +108,9 @@ class Block extends BlockBlot { // 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 + 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); @@ -214,10 +214,10 @@ export function getLastLeafInParent(blot: ParentBlot): Blot | null { if (current instanceof ParentBlot) { current = current.children.tail; } else { - return current + return current; } } - return null + return null; } function blockDelta(blot: BlockBlot, filter = true) { diff --git a/packages/quill/src/blots/break.ts b/packages/quill/src/blots/break.ts index 8b7d577431..4371048fa1 100644 --- a/packages/quill/src/blots/break.ts +++ b/packages/quill/src/blots/break.ts @@ -1,4 +1,4 @@ -import { Blot, EmbedBlot, ParentBlot } from 'parchment'; +import { EmbedBlot, ParentBlot } from 'parchment'; import SoftBreak from './soft-break.js'; import { getLastLeafInParent } from './block.js'; @@ -10,9 +10,14 @@ class Break extends EmbedBlot { optimize(): void { const thisIsLastBlotInParent = this.next == null; const thisIsFirstBlotInParent = this.prev == null; - const thisIsOnlyBlotInParent = thisIsLastBlotInParent && thisIsFirstBlotInParent - const prevLeaf = this.prev instanceof ParentBlot ? getLastLeafInParent(this.prev) : this.prev - const prevLeafIsSoftBreak = prevLeaf != null && prevLeaf.statics.blotName == SoftBreak.blotName; + const thisIsOnlyBlotInParent = + thisIsLastBlotInParent && thisIsFirstBlotInParent; + const prevLeaf = + this.prev instanceof ParentBlot + ? getLastLeafInParent(this.prev) + : this.prev; + const prevLeafIsSoftBreak = + prevLeaf != null && prevLeaf.statics.blotName == SoftBreak.blotName; const shouldRender = thisIsOnlyBlotInParent || prevLeafIsSoftBreak; if (!shouldRender) { this.remove(); @@ -30,5 +35,4 @@ class Break extends EmbedBlot { Break.blotName = 'break'; Break.tagName = 'BR'; - export default Break; diff --git a/packages/quill/test/unit/core/editor.spec.ts b/packages/quill/test/unit/core/editor.spec.ts index 08f596fb74..fa3a247929 100644 --- a/packages/quill/test/unit/core/editor.spec.ts +++ b/packages/quill/test/unit/core/editor.spec.ts @@ -292,16 +292,16 @@ describe('Editor', () => { }); test('text before soft line break', () => { - const editor = createEditor( - '

      0
      1

      ', - ); + 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

      '); + expect(editor.scroll.domNode).toEqualHTML( + '

      0

      ', + ); }); test('text before soft line break within an inline parent', () => { @@ -310,11 +310,15 @@ describe('Editor', () => { ); editor.deleteText(2, 1); expect(editor.getDelta()).toEqual( - new Delta().insert(`0${SOFT_BREAK_CHARACTER}`, {bold: true}).insert('\n'), + 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

      ',); + expect(editor.scroll.domNode).toEqualHTML( + '

      0

      ', + ); }); test('entire document', () => { From eb9fd0c517881098bb0df2461546a15859b5c78e Mon Sep 17 00:00:00 2001 From: DeanWay Date: Mon, 17 Feb 2025 13:32:53 +0100 Subject: [PATCH 09/17] use ParentBlot.descendants to find last leaf over custom function --- packages/quill/src/blots/block.ts | 16 +--------------- packages/quill/src/blots/break.ts | 5 ++--- 2 files changed, 3 insertions(+), 18 deletions(-) diff --git a/packages/quill/src/blots/block.ts b/packages/quill/src/blots/block.ts index 8fcbd828ba..6a8feeaf78 100644 --- a/packages/quill/src/blots/block.ts +++ b/packages/quill/src/blots/block.ts @@ -3,7 +3,6 @@ import { BlockBlot, EmbedBlot, LeafBlot, - ParentBlot, Scope, } from 'parchment'; import type { Blot, Parent } from 'parchment'; @@ -104,7 +103,7 @@ class Block extends BlockBlot { optimize(context: { [key: string]: any }) { super.optimize(context); - const lastLeafInBlock = getLastLeafInParent(this); + 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 ( @@ -207,19 +206,6 @@ class BlockEmbed extends EmbedBlot { BlockEmbed.scope = Scope.BLOCK_BLOT; // It is important for cursor behavior BlockEmbeds use tags that are block level elements -export function getLastLeafInParent(blot: ParentBlot): Blot | null { - let current = blot.children.tail; - const MAX_ITERATIONS = 1000; - for (let i = 0; current != null && i < MAX_ITERATIONS; i++) { - if (current instanceof ParentBlot) { - current = current.children.tail; - } else { - return current; - } - } - return null; -} - function blockDelta(blot: BlockBlot, filter = true) { return blot .descendants(LeafBlot) diff --git a/packages/quill/src/blots/break.ts b/packages/quill/src/blots/break.ts index 4371048fa1..75f77a5333 100644 --- a/packages/quill/src/blots/break.ts +++ b/packages/quill/src/blots/break.ts @@ -1,6 +1,5 @@ -import { EmbedBlot, ParentBlot } from 'parchment'; +import { EmbedBlot, LeafBlot, ParentBlot } from 'parchment'; import SoftBreak from './soft-break.js'; -import { getLastLeafInParent } from './block.js'; class Break extends EmbedBlot { static value() { @@ -14,7 +13,7 @@ class Break extends EmbedBlot { thisIsLastBlotInParent && thisIsFirstBlotInParent; const prevLeaf = this.prev instanceof ParentBlot - ? getLastLeafInParent(this.prev) + ? this.prev.descendants(LeafBlot).at(-1) : this.prev; const prevLeafIsSoftBreak = prevLeaf != null && prevLeaf.statics.blotName == SoftBreak.blotName; From 3c571defe0da9b402529442ebe76f558b1d845cb Mon Sep 17 00:00:00 2001 From: DeanWay Date: Mon, 17 Feb 2025 13:48:46 +0100 Subject: [PATCH 10/17] restore condition that break must be last blot in parent --- packages/quill/src/blots/break.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/quill/src/blots/break.ts b/packages/quill/src/blots/break.ts index 75f77a5333..5f2a6e96ad 100644 --- a/packages/quill/src/blots/break.ts +++ b/packages/quill/src/blots/break.ts @@ -17,7 +17,7 @@ class Break extends EmbedBlot { : this.prev; const prevLeafIsSoftBreak = prevLeaf != null && prevLeaf.statics.blotName == SoftBreak.blotName; - const shouldRender = thisIsOnlyBlotInParent || prevLeafIsSoftBreak; + const shouldRender = thisIsOnlyBlotInParent || (thisIsLastBlotInParent && prevLeafIsSoftBreak); if (!shouldRender) { this.remove(); } From 0292e1b9c98a651a1dc0dcb403a22451cc4f3627 Mon Sep 17 00:00:00 2001 From: DeanWay Date: Tue, 18 Feb 2025 15:56:47 +0100 Subject: [PATCH 11/17] fix lint --- packages/quill/src/blots/break.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/quill/src/blots/break.ts b/packages/quill/src/blots/break.ts index 5f2a6e96ad..b65588da7c 100644 --- a/packages/quill/src/blots/break.ts +++ b/packages/quill/src/blots/break.ts @@ -17,7 +17,8 @@ class Break extends EmbedBlot { : this.prev; const prevLeafIsSoftBreak = prevLeaf != null && prevLeaf.statics.blotName == SoftBreak.blotName; - const shouldRender = thisIsOnlyBlotInParent || (thisIsLastBlotInParent && prevLeafIsSoftBreak); + const shouldRender = + thisIsOnlyBlotInParent || (thisIsLastBlotInParent && prevLeafIsSoftBreak); if (!shouldRender) { this.remove(); } From 6690ac03793725f1694b248dfcfbbc2291ee5612 Mon Sep 17 00:00:00 2001 From: DeanWay Date: Thu, 6 Mar 2025 19:14:03 -0500 Subject: [PATCH 12/17] add source for shift-enter keyboard binding --- packages/quill/src/modules/keyboard.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/quill/src/modules/keyboard.ts b/packages/quill/src/modules/keyboard.ts index d373f06e3b..df23e42acc 100644 --- a/packages/quill/src/modules/keyboard.ts +++ b/packages/quill/src/modules/keyboard.ts @@ -362,8 +362,8 @@ class Keyboard extends Module { } handleShiftEnter(range: Range) { - this.quill.insertText(range.index, SOFT_BREAK_CHARACTER); - this.quill.setSelection(range.index + 1); + this.quill.insertText(range.index, SOFT_BREAK_CHARACTER, Quill.sources.USER); + this.quill.setSelection(range.index + 1, Quill.sources.SILENT); } } From c5adcb7039c7bc12ac53f1f7c97892d38f54c286 Mon Sep 17 00:00:00 2001 From: DeanWay Date: Thu, 6 Mar 2025 20:32:46 -0500 Subject: [PATCH 13/17] fix lint --- packages/quill/src/modules/keyboard.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/quill/src/modules/keyboard.ts b/packages/quill/src/modules/keyboard.ts index df23e42acc..5375f263f6 100644 --- a/packages/quill/src/modules/keyboard.ts +++ b/packages/quill/src/modules/keyboard.ts @@ -362,7 +362,11 @@ class Keyboard extends Module { } handleShiftEnter(range: Range) { - this.quill.insertText(range.index, SOFT_BREAK_CHARACTER, Quill.sources.USER); + this.quill.insertText( + range.index, + SOFT_BREAK_CHARACTER, + Quill.sources.USER, + ); this.quill.setSelection(range.index + 1, Quill.sources.SILENT); } } From 61c6233b2de1be019314f98a905caf1d98d9f265 Mon Sep 17 00:00:00 2001 From: DeanWay Date: Fri, 7 Mar 2025 17:10:39 -0500 Subject: [PATCH 14/17] clipboard matcher for break should handle the potential for a pasted break to be semantically a soft break given its position in a line --- packages/quill/src/modules/clipboard.ts | 44 +++++++++-- .../quill/test/unit/__helpers__/factory.ts | 2 + .../quill/test/unit/modules/clipboard.spec.ts | 73 ++++++++++++++++++- 3 files changed, 109 insertions(+), 10 deletions(-) diff --git a/packages/quill/src/modules/clipboard.ts b/packages/quill/src/modules/clipboard.ts index 87b05e1d5e..3a48d43d55 100644 --- a/packages/quill/src/modules/clipboard.ts +++ b/packages/quill/src/modules/clipboard.ts @@ -34,7 +34,6 @@ 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], @@ -491,15 +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) { + let 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 matchSoftBreak() { - 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/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/modules/clipboard.spec.ts b/packages/quill/test/unit/modules/clipboard.spec.ts index 0ba7c159ed..50079462a4 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,79 @@ 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 (let [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 +650,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}`), ); }); From de7ada80a466f2f1b790ab736c34a4f395d00ef8 Mon Sep 17 00:00:00 2001 From: DeanWay Date: Fri, 7 Mar 2025 17:15:29 -0500 Subject: [PATCH 15/17] fix lint --- .../quill/test/unit/modules/clipboard.spec.ts | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/packages/quill/test/unit/modules/clipboard.spec.ts b/packages/quill/test/unit/modules/clipboard.spec.ts index 50079462a4..082f68a9f9 100644 --- a/packages/quill/test/unit/modules/clipboard.spec.ts +++ b/packages/quill/test/unit/modules/clipboard.spec.ts @@ -359,19 +359,21 @@ describe('Clipboard', () => { ], [ '

      a

      ', - new Delta().insert('a', { - bold: true, - italic: true, - }) - .insert(`${SOFT_BREAK_CHARACTER}`, {bold: true}), + 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}`), + new Delta() + .insert('a', { + bold: true, + italic: true, + }) + .insert(`${SOFT_BREAK_CHARACTER}`), ], ]; for (let [html, expectedDelta] of softBreaksCases) { From 3aba2f80fc477fee7de4f23b791801d2498abbc0 Mon Sep 17 00:00:00 2001 From: DeanWay Date: Fri, 7 Mar 2025 19:09:36 -0500 Subject: [PATCH 16/17] fix const/let usage --- packages/quill/src/modules/clipboard.ts | 2 +- packages/quill/test/unit/modules/clipboard.spec.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/quill/src/modules/clipboard.ts b/packages/quill/src/modules/clipboard.ts index 3a48d43d55..62f26351d3 100644 --- a/packages/quill/src/modules/clipboard.ts +++ b/packages/quill/src/modules/clipboard.ts @@ -491,7 +491,7 @@ function matchBlot(node: Node, delta: Delta, scroll: ScrollBlot) { } function matchBreak(node: Node, delta: Delta, scroll: ScrollBlot) { - let parentLineElement = getParentLine(node, scroll); + 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); diff --git a/packages/quill/test/unit/modules/clipboard.spec.ts b/packages/quill/test/unit/modules/clipboard.spec.ts index 082f68a9f9..f716c0c336 100644 --- a/packages/quill/test/unit/modules/clipboard.spec.ts +++ b/packages/quill/test/unit/modules/clipboard.spec.ts @@ -376,7 +376,7 @@ describe('Clipboard', () => { .insert(`${SOFT_BREAK_CHARACTER}`), ], ]; - for (let [html, expectedDelta] of softBreaksCases) { + for (const [html, expectedDelta] of softBreaksCases) { test(`breaks matching for nested formats ${html}`, () => { const delta = createClipboard().convert({ html }); expect(delta).toEqual(expectedDelta); From 814041ce0afb6db750e9b1660d9875c9efd5cf7b Mon Sep 17 00:00:00 2001 From: DeanWay Date: Fri, 21 Mar 2025 13:53:00 -0400 Subject: [PATCH 17/17] inserts at the end of a block should still try to insert within the last child --- packages/quill/src/blots/block.ts | 24 +++++++++++++++++--- packages/quill/test/unit/core/editor.spec.ts | 20 +++++++++++++--- 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/packages/quill/src/blots/block.ts b/packages/quill/src/blots/block.ts index 6a8feeaf78..7e8828a71d 100644 --- a/packages/quill/src/blots/block.ts +++ b/packages/quill/src/blots/block.ts @@ -62,10 +62,28 @@ class Block extends BlockBlot { const softLines = text.split(softBreakRegex); let i = index; softLines.forEach((str) => { - if (str === SOFT_BREAK_CHARACTER) { - super.insertAt(i, SoftBreak.blotName, SOFT_BREAK_CHARACTER); + 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 { - super.insertAt(Math.min(i, this.length() - 1), str); + 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; }); diff --git a/packages/quill/test/unit/core/editor.spec.ts b/packages/quill/test/unit/core/editor.spec.ts index fa3a247929..585fff5add 100644 --- a/packages/quill/test/unit/core/editor.spec.ts +++ b/packages/quill/test/unit/core/editor.spec.ts @@ -190,11 +190,11 @@ describe('Editor', () => { editor.insertText(4, SOFT_BREAK_CHARACTER); expect(editor.getDelta()).toEqual( new Delta() - .insert('0123', { bold: true }) - .insert(`${SOFT_BREAK_CHARACTER}\n`), + .insert(`0123${SOFT_BREAK_CHARACTER}`, { bold: true }) + .insert('\n'), ); expect(editor.scroll.domNode).toEqualHTML(` -

      0123

      `); +

      0123

      `); }); test('multiline text', () => { @@ -241,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', () => {