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 + +