diff --git a/packages/quill/src/core/editor.ts b/packages/quill/src/core/editor.ts index b6391a7cd4..7e05f355d8 100644 --- a/packages/quill/src/core/editor.ts +++ b/packages/quill/src/core/editor.ts @@ -16,6 +16,14 @@ type SelectionInfo = { oldRange: Range; }; +export type SemanticHTMLOptions = { + preserveWhitespace?: boolean; +}; + +const defaultSemanticHTMLOptions: SemanticHTMLOptions = { + preserveWhitespace: false, +} as const; + class Editor { scroll: Scroll; delta: Delta; @@ -195,15 +203,20 @@ class Editor { return { ...lineFormats, ...leafFormats }; } - getHTML(index: number, length: number): string { + getHTML( + index: number, + length: number, + options?: SemanticHTMLOptions, + ): string { + const finalOptions = { ...defaultSemanticHTMLOptions, ...options }; const [line, lineOffset] = this.scroll.line(index); if (line) { const lineLength = line.length(); const isWithinLine = line.length() >= lineOffset + length; if (isWithinLine && !(lineOffset === 0 && length === lineLength)) { - return convertHTML(line, lineOffset, length, true); + return convertHTML(line, lineOffset, length, finalOptions, true); } - return convertHTML(this.scroll, index, length, true); + return convertHTML(this.scroll, index, length, finalOptions, true); } return ''; } @@ -327,13 +340,14 @@ function convertListHTML( items: ListItem[], lastIndent: number, types: string[], + options: SemanticHTMLOptions, ): string { if (items.length === 0) { const [endTag] = getListType(types.pop()); if (lastIndent <= 0) { return ``; } - return `${convertListHTML([], lastIndent - 1, types)}`; + return `${convertListHTML([], lastIndent - 1, types, options)}`; } const [{ child, offset, length, indent, type }, ...rest] = items; const [tag, attribute] = getListType(type); @@ -344,9 +358,10 @@ function convertListHTML( child, offset, length, - )}${convertListHTML(rest, indent, types)}`; + options, + )}${convertListHTML(rest, indent, types, options)}`; } - return `<${tag}>
  • ${convertListHTML(items, lastIndent + 1, types)}`; + return `<${tag}>
  • ${convertListHTML(items, lastIndent + 1, types, options)}`; } const previousType = types[types.length - 1]; if (indent === lastIndent && type === previousType) { @@ -354,16 +369,18 @@ function convertListHTML( child, offset, length, - )}${convertListHTML(rest, indent, types)}`; + options, + )}${convertListHTML(rest, indent, types, options)}`; } const [endTag] = getListType(types.pop()); - return `
  • ${convertListHTML(items, lastIndent - 1, types)}`; + return `${convertListHTML(items, lastIndent - 1, types, options)}`; } function convertHTML( blot: Blot, index: number, length: number, + options: SemanticHTMLOptions, isRoot = false, ): string { if ('html' in blot && typeof blot.html === 'function') { @@ -371,6 +388,7 @@ function convertHTML( } if (blot instanceof TextBlot) { const escapedText = escapeText(blot.value().slice(index, index + length)); + if (options.preserveWhitespace) return escapedText; return escapedText.replaceAll(' ', ' '); } if (blot instanceof ParentBlot) { @@ -390,11 +408,11 @@ function convertHTML( type: formats.list, }); }); - return convertListHTML(items, -1, []); + return convertListHTML(items, -1, [], options); } const parts: string[] = []; blot.children.forEachAt(index, length, (child, offset, childLength) => { - parts.push(convertHTML(child, offset, childLength)); + parts.push(convertHTML(child, offset, childLength, options)); }); if (isRoot || blot.statics.blotName === 'list') { return parts.join(''); diff --git a/packages/quill/src/core/quill.ts b/packages/quill/src/core/quill.ts index dae5267bcb..6eab5ec54e 100644 --- a/packages/quill/src/core/quill.ts +++ b/packages/quill/src/core/quill.ts @@ -9,14 +9,14 @@ import type Clipboard from '../modules/clipboard.js'; import type History from '../modules/history.js'; import type Keyboard from '../modules/keyboard.js'; import type Uploader from '../modules/uploader.js'; -import Editor from './editor.js'; +import Editor, { SemanticHTMLOptions } from './editor.js'; import Emitter from './emitter.js'; import type { EmitterSource } from './emitter.js'; import instances from './instances.js'; import logger from './logger.js'; import type { DebugLevel } from './logger.js'; import Module from './module.js'; -import Selection, { Range } from './selection.js'; +import Selection, { isRange, Range } from './selection.js'; import type { Bounds } from './selection.js'; import Composition from './composition.js'; import Theme from './theme.js'; @@ -537,15 +537,47 @@ class Quill { return this.selection.getRange()[0]; } - getSemanticHTML(range: Range): string; - getSemanticHTML(index?: number, length?: number): string; - getSemanticHTML(index: Range | number = 0, length?: number) { - if (typeof index === 'number') { - length = length ?? this.getLength() - index; + getSemanticHTML(options?: SemanticHTMLOptions): string; + getSemanticHTML(range: Range, options?: SemanticHTMLOptions): string; + getSemanticHTML(index: number, options?: SemanticHTMLOptions): string; + getSemanticHTML( + index: number, + length: number, + options?: SemanticHTMLOptions, + ): string; + getSemanticHTML( + indexOrRangeOrOptions?: Range | number | SemanticHTMLOptions, + lengthOrOptions?: number | SemanticHTMLOptions, + options?: SemanticHTMLOptions, + ) { + let finalIndex: number | Range = 0; + let finalLength: number | undefined = undefined; + let finalOptions: SemanticHTMLOptions = {}; + + if (indexOrRangeOrOptions === undefined) { + finalIndex = 0; + finalLength = this.getLength() - finalIndex; + } else if (isRange(indexOrRangeOrOptions)) { + finalIndex = indexOrRangeOrOptions as Range; + finalOptions = (lengthOrOptions as SemanticHTMLOptions) ?? {}; + } else if (typeof indexOrRangeOrOptions === 'number') { + finalIndex = indexOrRangeOrOptions; + if (typeof lengthOrOptions === 'number') { + finalLength = lengthOrOptions; + finalOptions = options ?? {}; + } else { + finalLength = this.getLength() - finalIndex; + finalOptions = (lengthOrOptions as SemanticHTMLOptions) ?? {}; + } + } else { + finalIndex = 0; + finalLength = this.getLength() - finalIndex; + finalOptions = indexOrRangeOrOptions; } + // @ts-expect-error - [index, length] = overload(index, length); - return this.editor.getHTML(index, length); + [finalIndex, finalLength] = overload(finalIndex, finalLength); + return this.editor.getHTML(finalIndex, finalLength, finalOptions); } getText(range?: Range): string; diff --git a/packages/quill/src/core/selection.ts b/packages/quill/src/core/selection.ts index 33f7f2a576..44510ec46b 100644 --- a/packages/quill/src/core/selection.ts +++ b/packages/quill/src/core/selection.ts @@ -35,6 +35,12 @@ export class Range { ) {} } +export function isRange(value: any): value is Range { + return ( + value && typeof value === 'object' && 'index' in value && 'length' in value + ); +} + class Selection { scroll: Scroll; emitter: Emitter; diff --git a/packages/quill/test/unit/core/editor.spec.ts b/packages/quill/test/unit/core/editor.spec.ts index 2d47a1dec9..1f5f44b9ec 100644 --- a/packages/quill/test/unit/core/editor.spec.ts +++ b/packages/quill/test/unit/core/editor.spec.ts @@ -1281,6 +1281,7 @@ describe('Editor', () => { `, ); + expect(editor.getHTML(2, 12)).toEqualHTML(`
    1. e
    2. @@ -1409,5 +1410,17 @@ describe('Editor', () => { expect(editor.getHTML(2, 7)).toEqual('
      \n123\n\n\n4\n
      '); expect(editor.getHTML(5, 7)).toEqual('
      \n\n\n\n4567\n
      '); }); + + test('option preserveWhitespace is disabled (DEFAULT)', () => { + const editor = createEditor('

      This is Quill

      '); + expect(editor.getHTML(0, 14)).toEqual('

      This is Quill

      '); + }); + + test('option preserveWhitespace is enabled', () => { + const editor = createEditor('

      This is Quill

      '); + expect(editor.getHTML(0, 14, { preserveWhitespace: true })).toEqual( + '

      This is Quill

      ', + ); + }); }); }); diff --git a/packages/quill/test/unit/core/quill.spec.ts b/packages/quill/test/unit/core/quill.spec.ts index c128c0dc12..05cf7bcd51 100644 --- a/packages/quill/test/unit/core/quill.spec.ts +++ b/packages/quill/test/unit/core/quill.spec.ts @@ -604,9 +604,33 @@ describe('Quill', () => { test('works with range', () => { const quill = new Quill(createContainer('

      Welcome

      ')); - expect(quill.getText({ index: 1, length: 2 })).toMatchInlineSnapshot( - '"el"', - ); + expect( + quill.getSemanticHTML({ index: 1, length: 2 }), + ).toMatchInlineSnapshot('"el"'); + }); + + test('works with only options', () => { + const quill = new Quill(createContainer('

      Welcome to quill

      ')); + expect(quill.getSemanticHTML({ preserveWhitespace: true })) + .toMatchInlineSnapshot(` + "

      Welcome to quill

      " + `); + }); + + test('works with index and options', () => { + const quill = new Quill(createContainer('

      Welcome to quill

      ')); + expect(quill.getSemanticHTML(0, { preserveWhitespace: true })) + .toMatchInlineSnapshot(` + "

      Welcome to quill

      " + `); + }); + + test('works with index, length and options', () => { + const quill = new Quill(createContainer('

      Welcome to quill

      ')); + expect(quill.getSemanticHTML(0, 10, { preserveWhitespace: true })) + .toMatchInlineSnapshot(` + "Welcome to" + `); }); }); diff --git a/packages/website/content/docs/api.mdx b/packages/website/content/docs/api.mdx index 66d456eb76..100fa64249 100644 --- a/packages/website/content/docs/api.mdx +++ b/packages/website/content/docs/api.mdx @@ -116,10 +116,14 @@ This method is useful for exporting the contents of the editor in a format that The `length` parameter defaults to the length of the remaining document. +The `options` parameter allows to modify output: + +- `preserveWhitespace` - if true all whitespaces are preserved, otherwise all whitespaces are changed to `" "` (default: false) + **Methods** ```typescript -getSemanticHTML(index: number = 0, length: number = remaining): string +getSemanticHTML(index: number = 0, length: number = remaining, options?: SemanticHTMLOptions): string ``` **Examples**