Skip to content

Commit

Permalink
TINY-8639: Retain formatted blank lines when format_empty_lines is …
Browse files Browse the repository at this point in the history
…true (backport) (#7842)
  • Loading branch information
ltrouton committed May 23, 2022
1 parent d7a29a1 commit f3ab303
Show file tree
Hide file tree
Showing 9 changed files with 632 additions and 184 deletions.
3 changes: 3 additions & 0 deletions modules/tinymce/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed
- Base64 data URIs were not extracted correctly during parsing when proceeded by `data:` text #TINY-8646
- Empty lines that were formatted in a ranged selection using the `format_empty_lines` option were not kept in the serialized content #TINY-8639
- The `s` element was missing from the default schema text inline elements #TINY-8639
- Some text inline elements specified via the schema were not removed when empty by default #TINY-8639

## 5.10.4 - 2022-04-27

Expand Down
1 change: 1 addition & 0 deletions modules/tinymce/src/core/main/ts/api/SettingsTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ interface BaseEditorSettings {
forced_root_block?: boolean | string;
forced_root_block_attrs?: Record<string, string>;
formats?: Formats;
format_empty_lines?: boolean;
gecko_spellcheck?: boolean;
height?: number | string;
hidden_input?: boolean;
Expand Down
37 changes: 24 additions & 13 deletions modules/tinymce/src/core/main/ts/api/html/DomParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* For commercial licenses see https://www.tiny.cloud/
*/

import { Obj } from '@ephox/katamari';
import { Obj, Type } from '@ephox/katamari';

import * as LegacyFilter from '../../html/LegacyFilter';
import * as ParserFilters from '../../html/ParserFilters';
Expand All @@ -14,7 +14,7 @@ import { BlobCache } from '../file/BlobCache';
import Tools from '../util/Tools';
import AstNode from './Node';
import SaxParser, { ParserFormat } from './SaxParser';
import Schema, { SchemaElement, SchemaMap } from './Schema';
import Schema, { getTextRootBlockElements, SchemaElement, SchemaMap } from './Schema';

/**
* This class parses HTML code into a DOM like structure of nodes it will remove redundant whitespace and make
Expand Down Expand Up @@ -365,7 +365,8 @@ const DomParser = (settings?: DomParserSettings, schema = Schema()): DomParser =
args = args || {};
matchedNodes = {};
matchedAttributes = {};
const blockElements = extend(makeMap('script,style,head,html,body,title,meta,param'), schema.getBlockElements());
const blockElements: Record<string, string> = extend(makeMap('script,style,head,html,body,title,meta,param'), schema.getBlockElements());
const textRootBlockElements = getTextRootBlockElements(schema);
const nonEmptyElements = schema.getNonEmptyElements();
const children = schema.children;
const validate = settings.validate;
Expand Down Expand Up @@ -491,6 +492,18 @@ const DomParser = (settings?: DomParserSettings, schema = Schema()): DomParser =
return output;
};

const isTextRootBlockEmpty = (node: AstNode) => {
let tempNode = node;
while (Type.isNonNullable(tempNode)) {
if (tempNode.name in textRootBlockElements) {
return isEmpty(schema, nonEmptyElements, whiteSpaceElements, tempNode);
} else {
tempNode = tempNode.parent;
}
}
return false;
};

const parser = SaxParser({
validate,
document: settings.document,
Expand Down Expand Up @@ -591,7 +604,7 @@ const DomParser = (settings?: DomParserSettings, schema = Schema()): DomParser =
},

end: (name) => {
let textNode, text, sibling, tempNode;
let textNode, text, sibling;

const elementRule: Partial<SchemaElement> = validate ? schema.getElementRule(name) : {};
if (elementRule) {
Expand Down Expand Up @@ -674,24 +687,22 @@ const DomParser = (settings?: DomParserSettings, schema = Schema()): DomParser =
isInWhiteSpacePreservedElement = false;
}

if (elementRule.removeEmpty && isEmpty(schema, nonEmptyElements, whiteSpaceElements, node)) {
tempNode = node.parent;
const isNodeEmpty = isEmpty(schema, nonEmptyElements, whiteSpaceElements, node);
const parentNode = node.parent;

if (elementRule.paddInEmptyBlock && isNodeEmpty && isTextRootBlockEmpty(node)) {
paddEmptyNode(settings, args, blockElements, node);
} else if (elementRule.removeEmpty && isNodeEmpty) {
if (blockElements[node.name]) {
node.empty().remove();
} else {
node.unwrap();
}

node = tempNode;
return;
}

if (elementRule.paddEmpty && (isPaddedWithNbsp(node) || isEmpty(schema, nonEmptyElements, whiteSpaceElements, node))) {
} else if (elementRule.paddEmpty && (isPaddedWithNbsp(node) || isNodeEmpty)) {
paddEmptyNode(settings, args, blockElements, node);
}

node = node.parent;
node = parentNode;
}
}
}, schema);
Expand Down
34 changes: 30 additions & 4 deletions modules/tinymce/src/core/main/ts/api/html/Schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export interface SchemaSettings {
valid_styles?: string | Record<string, string>;
verify_html?: boolean;
whitespace_elements?: string;
padd_empty_block_inline_children?: boolean;
}

export interface Attribute {
Expand Down Expand Up @@ -64,6 +65,7 @@ export interface ElementRule {
paddEmpty?: boolean;
removeEmpty?: boolean;
removeEmptyAttrs?: boolean;
paddInEmptyBlock?: boolean;
}

export interface SchemaElement extends ElementRule {
Expand Down Expand Up @@ -124,6 +126,19 @@ const split = (items: string, delim?: string): string[] => {
return items ? items.split(delim || ' ') : [];
};

const createMap = (defaultValue?: string, extendWith?: SchemaMap): SchemaMap => {
const value = makeMap(defaultValue, ' ', makeMap(defaultValue.toUpperCase(), ' '));
return extend(value, extendWith);
};

// A curated list using the textBlockElements map and parts of the blockElements map from the schema
// TODO: TINY-8728 Investigate if the extras can be added directly to the default text block elements
export const getTextRootBlockElements = (schema: Schema): SchemaMap =>
createMap(
'td th li dt dd figcaption caption details summary',
schema.getTextBlockElements()
);

/**
* Builds a schema lookup table
*
Expand Down Expand Up @@ -443,8 +458,7 @@ const Schema = (settings?: SchemaSettings): Schema => {
value = mapCache[option];

if (!value) {
value = makeMap(defaultValue, ' ', makeMap(defaultValue.toUpperCase(), ' '));
value = extend(value, extendWith);
value = createMap(defaultValue, extendWith);

mapCache[option] = value;
}
Expand Down Expand Up @@ -488,7 +502,7 @@ const Schema = (settings?: SchemaSettings): Schema => {
const blockElementsMap = createLookupTable('block_elements', 'hr table tbody thead tfoot ' +
'th tr td li ol ul caption dl dt dd noscript menu isindex option ' +
'datalist select optgroup figcaption details summary', textBlockElementsMap);
const textInlineElementsMap = createLookupTable('text_inline_elements', 'span strong b em i font strike u var cite ' +
const textInlineElementsMap = createLookupTable('text_inline_elements', 'span strong b em i font s strike u var cite ' +
'dfn code mark q sup sub samp');

each((settings.special || 'script noscript iframe noframes noembed title style textarea xmp').split(' '), (name) => {
Expand Down Expand Up @@ -783,8 +797,20 @@ const Schema = (settings?: SchemaSettings): Schema => {
// Add default alt attribute for images, removed since alt="" is treated as presentational.
// elements.img.attributesDefault = [{name: 'alt', value: ''}];

// By default,
// - padd the text inline element if it is empty and also a child of an empty root block
// - in all other cases, remove the text inline element if it is empty
each(textInlineElementsMap, (_val, name) => {
if (elements[name]) {
if (settings.padd_empty_block_inline_children) {
elements[name].paddInEmptyBlock = true;
}
elements[name].removeEmpty = true;
}
});

// Remove these if they are empty by default
each(split('ol ul sub sup blockquote span font a table tbody strong em b i'), (name) => {
each(split('ol ul blockquote a table tbody'), (name) => {
if (elements[name]) {
elements[name].removeEmpty = true;
}
Expand Down
15 changes: 2 additions & 13 deletions modules/tinymce/src/core/main/ts/fmt/ApplyFormat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { PredicateExists, SugarElement } from '@ephox/sugar';
import DOMUtils from '../api/dom/DOMUtils';
import Editor from '../api/Editor';
import * as Events from '../api/Events';
import { getTextRootBlockElements } from '../api/html/Schema';
import * as Settings from '../api/Settings';
import Tools from '../api/util/Tools';
import * as Bookmarks from '../bookmark/Bookmarks';
Expand Down Expand Up @@ -40,19 +41,7 @@ const isElementNode = (node: Node): node is Element => {
const canFormatBR = (editor: Editor, format: ApplyFormat, node: HTMLBRElement, parentName: string) => {
// TINY-6483: Can format 'br' if it is contained in a valid empty block and an inline format is being applied
if (Settings.canFormatEmptyLines(editor) && FormatUtils.isInlineFormat(format)) {
// A curated list using the textBlockElements map and parts of the blockElements map from the schema
const validBRParentElements: Record<string, {}> = {
...editor.schema.getTextBlockElements(),
td: {},
th: {},
li: {},
dt: {},
dd: {},
figcaption: {},
caption: {},
details: {},
summary: {}
};
const validBRParentElements = getTextRootBlockElements(editor.schema);
// If a caret node is present, the format should apply to that, not the br (applicable to collapsed selections)
const hasCaretNodeSibling = PredicateExists.sibling(SugarElement.fromDom(node), (sibling) => isCaretNode(sibling.dom));
return Obj.hasNonNullableKey(validBRParentElements, parentName) && Empty.isEmpty(SugarElement.fromDom(node.parentNode), false) && !hasCaretNodeSibling;
Expand Down
55 changes: 31 additions & 24 deletions modules/tinymce/src/core/main/ts/init/InitContentBody.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import * as Events from '../api/Events';
import Formatter from '../api/Formatter';
import DomParser, { DomParserSettings } from '../api/html/DomParser';
import AstNode from '../api/html/Node';
import Schema from '../api/html/Schema';
import Schema, { SchemaSettings } from '../api/html/Schema';
import * as Settings from '../api/Settings';
import UndoManager from '../api/UndoManager';
import Delay from '../api/util/Delay';
Expand Down Expand Up @@ -65,6 +65,34 @@ const getRootName = (editor: Editor): string => editor.inline ? editor.getElemen

const removeUndefined = <T>(obj: T): T => Obj.filter(obj as Record<string, unknown>, (v) => Type.isUndefined(v) === false) as T;

const mkSchemaSettings = (editor: Editor): SchemaSettings => {
const settings = editor.settings;

return removeUndefined<SchemaSettings>({
block_elements: settings.block_elements,
boolean_attributes: settings.boolean_attributes,
custom_elements: settings.custom_elements,
extended_valid_elements: settings.extended_valid_elements,
invalid_elements: settings.invalid_elements,
invalid_styles: settings.invalid_styles,
move_caret_before_on_enter_elements: settings.move_caret_before_on_enter_elements,
non_empty_elements: settings.non_empty_elements,
schema: settings.schema,
self_closing_elements: settings.self_closing_elements,
short_ended_elements: settings.short_ended_elements,
special: settings.special,
text_block_elements: settings.text_block_elements,
text_inline_elements: settings.text_inline_elements,
valid_children: settings.valid_children,
valid_classes: settings.valid_classes,
valid_elements: settings.valid_elements,
valid_styles: settings.valid_styles,
verify_html: settings.verify_html,
whitespace_elements: settings.whitespace_elements,
padd_empty_block_inline_children: settings.format_empty_lines,
});
};

const mkParserSettings = (editor: Editor): DomParserSettings => {
const settings = editor.settings;
const blobCache = editor.editorUpload.blobCache;
Expand Down Expand Up @@ -100,6 +128,7 @@ const mkSerializerSettings = (editor: Editor): DomSerializerSettings => {

return {
...mkParserSettings(editor),
...mkSchemaSettings(editor),
...removeUndefined<DomSerializerSettings>({
// SerializerSettings
url_converter: settings.url_converter,
Expand All @@ -112,28 +141,6 @@ const mkSerializerSettings = (editor: Editor): DomSerializerSettings => {
indent: settings.indent,
indent_after: settings.indent_after,
indent_before: settings.indent_before,

// Schema settings
block_elements: settings.block_elements,
boolean_attributes: settings.boolean_attributes,
custom_elements: settings.custom_elements,
extended_valid_elements: settings.extended_valid_elements,
invalid_elements: settings.invalid_elements,
invalid_styles: settings.invalid_styles,
move_caret_before_on_enter_elements: settings.move_caret_before_on_enter_elements,
non_empty_elements: settings.non_empty_elements,
schema: settings.schema,
self_closing_elements: settings.self_closing_elements,
short_ended_elements: settings.short_ended_elements,
special: settings.special,
text_block_elements: settings.text_block_elements,
text_inline_elements: settings.text_inline_elements,
valid_children: settings.valid_children,
valid_classes: settings.valid_classes,
valid_elements: settings.valid_elements,
valid_styles: settings.valid_styles,
verify_html: settings.verify_html,
whitespace_elements: settings.whitespace_elements
})
};
};
Expand Down Expand Up @@ -425,7 +432,7 @@ const initContentBody = (editor: Editor, skipWrite?: boolean) => {
(body as any).disabled = false;

editor.editorUpload = EditorUpload(editor);
editor.schema = Schema(settings);
editor.schema = Schema(mkSchemaSettings(editor));
editor.dom = DOMUtils(doc, {
keep_values: true,
// Note: Don't bind here, as the binding is handled via the `url_converter_scope`
Expand Down

0 comments on commit f3ab303

Please sign in to comment.