Skip to content

Add getSemanticHTML options & Add preserveWhitespace option to getSem… #4598

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Add getSemanticHTML options & Add preserveWhitespace option to getSem…
…anticHTML
  • Loading branch information
MaciejDabek committed Feb 4, 2025
commit ff18adde649a9a2ebb0c860daa70307ae1de096f
38 changes: 28 additions & 10 deletions packages/quill/src/core/editor.ts
Original file line number Diff line number Diff line change
@@ -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 `</li></${endTag}>`;
}
return `</li></${endTag}>${convertListHTML([], lastIndent - 1, types)}`;
return `</li></${endTag}>${convertListHTML([], lastIndent - 1, types, options)}`;
}
const [{ child, offset, length, indent, type }, ...rest] = items;
const [tag, attribute] = getListType(type);
@@ -344,33 +358,37 @@ function convertListHTML(
child,
offset,
length,
)}${convertListHTML(rest, indent, types)}`;
options,
)}${convertListHTML(rest, indent, types, options)}`;
}
return `<${tag}><li>${convertListHTML(items, lastIndent + 1, types)}`;
return `<${tag}><li>${convertListHTML(items, lastIndent + 1, types, options)}`;
}
const previousType = types[types.length - 1];
if (indent === lastIndent && type === previousType) {
return `</li><li${attribute}>${convertHTML(
child,
offset,
length,
)}${convertListHTML(rest, indent, types)}`;
options,
)}${convertListHTML(rest, indent, types, options)}`;
}
const [endTag] = getListType(types.pop());
return `</li></${endTag}>${convertListHTML(items, lastIndent - 1, types)}`;
return `</li></${endTag}>${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') {
return blot.html(index, length);
}
if (blot instanceof TextBlot) {
const escapedText = escapeText(blot.value().slice(index, index + length));
if (options.preserveWhitespace) return escapedText;
return escapedText.replaceAll(' ', '&nbsp;');
}
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('');
50 changes: 41 additions & 9 deletions packages/quill/src/core/quill.ts
Original file line number Diff line number Diff line change
@@ -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;
6 changes: 6 additions & 0 deletions packages/quill/src/core/selection.ts
Original file line number Diff line number Diff line change
@@ -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;
13 changes: 13 additions & 0 deletions packages/quill/test/unit/core/editor.spec.ts
Original file line number Diff line number Diff line change
@@ -1281,6 +1281,7 @@ describe('Editor', () => {
</ol>
`,
);

expect(editor.getHTML(2, 12)).toEqualHTML(`
<ol>
<li>e</li>
@@ -1409,5 +1410,17 @@ describe('Editor', () => {
expect(editor.getHTML(2, 7)).toEqual('<pre>\n123\n\n\n4\n</pre>');
expect(editor.getHTML(5, 7)).toEqual('<pre>\n\n\n\n4567\n</pre>');
});

test('option preserveWhitespace is disabled (DEFAULT)', () => {
const editor = createEditor('<p>This is Quill</p>');
expect(editor.getHTML(0, 14)).toEqual('<p>This&nbsp;is&nbsp;Quill</p>');
});

test('option preserveWhitespace is enabled', () => {
const editor = createEditor('<p>This is Quill</p>');
expect(editor.getHTML(0, 14, { preserveWhitespace: true })).toEqual(
'<p>This is Quill</p>',
);
});
});
});
30 changes: 27 additions & 3 deletions packages/quill/test/unit/core/quill.spec.ts
Original file line number Diff line number Diff line change
@@ -604,9 +604,33 @@ describe('Quill', () => {

test('works with range', () => {
const quill = new Quill(createContainer('<h1>Welcome</h1>'));
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('<h1>Welcome to quill</h1>'));
expect(quill.getSemanticHTML({ preserveWhitespace: true }))
.toMatchInlineSnapshot(`
"<h1>Welcome to quill</h1>"
`);
});

test('works with index and options', () => {
const quill = new Quill(createContainer('<h1>Welcome to quill</h1>'));
expect(quill.getSemanticHTML(0, { preserveWhitespace: true }))
.toMatchInlineSnapshot(`
"<h1>Welcome to quill</h1>"
`);
});

test('works with index, length and options', () => {
const quill = new Quill(createContainer('<h1>Welcome to quill</h1>'));
expect(quill.getSemanticHTML(0, 10, { preserveWhitespace: true }))
.toMatchInlineSnapshot(`
"Welcome to"
`);
});
});

6 changes: 5 additions & 1 deletion packages/website/content/docs/api.mdx
Original file line number Diff line number Diff line change
@@ -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 `"&nbsp;"` (default: false)

**Methods**

```typescript
getSemanticHTML(index: number = 0, length: number = remaining): string
getSemanticHTML(index: number = 0, length: number = remaining, options?: SemanticHTMLOptions): string
```

**Examples**