diff --git a/modules/tinymce/CHANGELOG.md b/modules/tinymce/CHANGELOG.md index 26b0f3f9e0a..de1020d2304 100644 --- a/modules/tinymce/CHANGELOG.md +++ b/modules/tinymce/CHANGELOG.md @@ -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 diff --git a/modules/tinymce/src/core/main/ts/api/SettingsTypes.ts b/modules/tinymce/src/core/main/ts/api/SettingsTypes.ts index db9d5b5eace..06dff4d587a 100644 --- a/modules/tinymce/src/core/main/ts/api/SettingsTypes.ts +++ b/modules/tinymce/src/core/main/ts/api/SettingsTypes.ts @@ -110,6 +110,7 @@ interface BaseEditorSettings { forced_root_block?: boolean | string; forced_root_block_attrs?: Record; formats?: Formats; + format_empty_lines?: boolean; gecko_spellcheck?: boolean; height?: number | string; hidden_input?: boolean; diff --git a/modules/tinymce/src/core/main/ts/api/html/DomParser.ts b/modules/tinymce/src/core/main/ts/api/html/DomParser.ts index 0e36be81f15..03f9b2527a5 100644 --- a/modules/tinymce/src/core/main/ts/api/html/DomParser.ts +++ b/modules/tinymce/src/core/main/ts/api/html/DomParser.ts @@ -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'; @@ -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 @@ -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 = 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; @@ -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, @@ -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 = validate ? schema.getElementRule(name) : {}; if (elementRule) { @@ -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); diff --git a/modules/tinymce/src/core/main/ts/api/html/Schema.ts b/modules/tinymce/src/core/main/ts/api/html/Schema.ts index 6a234beaecb..3912eee17bd 100644 --- a/modules/tinymce/src/core/main/ts/api/html/Schema.ts +++ b/modules/tinymce/src/core/main/ts/api/html/Schema.ts @@ -32,6 +32,7 @@ export interface SchemaSettings { valid_styles?: string | Record; verify_html?: boolean; whitespace_elements?: string; + padd_empty_block_inline_children?: boolean; } export interface Attribute { @@ -64,6 +65,7 @@ export interface ElementRule { paddEmpty?: boolean; removeEmpty?: boolean; removeEmptyAttrs?: boolean; + paddInEmptyBlock?: boolean; } export interface SchemaElement extends ElementRule { @@ -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 * @@ -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; } @@ -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) => { @@ -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; } diff --git a/modules/tinymce/src/core/main/ts/fmt/ApplyFormat.ts b/modules/tinymce/src/core/main/ts/fmt/ApplyFormat.ts index cdf4d2ac4f2..f5d2659982a 100644 --- a/modules/tinymce/src/core/main/ts/fmt/ApplyFormat.ts +++ b/modules/tinymce/src/core/main/ts/fmt/ApplyFormat.ts @@ -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'; @@ -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 = { - ...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; diff --git a/modules/tinymce/src/core/main/ts/init/InitContentBody.ts b/modules/tinymce/src/core/main/ts/init/InitContentBody.ts index 61dd9e487a5..4e329b1c680 100644 --- a/modules/tinymce/src/core/main/ts/init/InitContentBody.ts +++ b/modules/tinymce/src/core/main/ts/init/InitContentBody.ts @@ -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'; @@ -65,6 +65,34 @@ const getRootName = (editor: Editor): string => editor.inline ? editor.getElemen const removeUndefined = (obj: T): T => Obj.filter(obj as Record, (v) => Type.isUndefined(v) === false) as T; +const mkSchemaSettings = (editor: Editor): SchemaSettings => { + const settings = editor.settings; + + return removeUndefined({ + 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; @@ -100,6 +128,7 @@ const mkSerializerSettings = (editor: Editor): DomSerializerSettings => { return { ...mkParserSettings(editor), + ...mkSchemaSettings(editor), ...removeUndefined({ // SerializerSettings url_converter: settings.url_converter, @@ -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 }) }; }; @@ -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` diff --git a/modules/tinymce/src/core/test/ts/browser/fmt/FormatEmptyLineTest.ts b/modules/tinymce/src/core/test/ts/browser/fmt/FormatEmptyLineTest.ts index d1fdcf3f138..f5691f78510 100644 --- a/modules/tinymce/src/core/test/ts/browser/fmt/FormatEmptyLineTest.ts +++ b/modules/tinymce/src/core/test/ts/browser/fmt/FormatEmptyLineTest.ts @@ -1,5 +1,7 @@ +import { Keys } from '@ephox/agar'; import { context, describe, it } from '@ephox/bedrock-client'; -import { TinyAssertions, TinyHooks, TinySelections, TinyUiActions } from '@ephox/wrap-mcagar'; +import { Type, Unicode } from '@ephox/katamari'; +import { TinyAssertions, TinyContentActions, TinyHooks, TinySelections, TinyUiActions } from '@ephox/wrap-mcagar'; import Editor from 'tinymce/core/api/Editor'; import Theme from 'tinymce/themes/silver/Theme'; @@ -8,6 +10,9 @@ interface TestConfig { readonly selector: string; readonly selectorCount: number; readonly html: string; + readonly rawHtml?: boolean; + readonly expectedHtml?: string; + readonly expectedRawHtml?: string; readonly select: (editor: Editor) => void; readonly apply: (editor: Editor) => void; readonly remove: (editor: Editor) => void; @@ -22,7 +27,7 @@ describe('browser.tinymce.core.fmt.FormatEmptyLineTest', () => { base_url: '/project/tinymce/js/tinymce' }, [ Theme ], true); - const tagHTML = (tag: string) => `<${tag}>a<${tag}> <${tag}>b`; + const tagHTML = (tag: string) => `<${tag}>a\n<${tag}> \n<${tag}>b`; const toggleInlineStyle = (style: string) => (editor: Editor) => { TinyUiActions.clickOnToolbar(editor, `[aria-label="${style}"]`); @@ -32,6 +37,9 @@ describe('browser.tinymce.core.fmt.FormatEmptyLineTest', () => { const selectAll = (editor: Editor) => editor.execCommand('SelectAll'); + const pAssertToolbarButtonState = (editor: Editor, selector: string, active: boolean) => + TinyUiActions.pWaitForUi(editor, `button[aria-label="${selector}"][aria-pressed="${active}"]`); + const tableHTML = ` @@ -46,163 +54,376 @@ describe('browser.tinymce.core.fmt.FormatEmptyLineTest', () => {
`; - const listHTML = `
    -
  • b
  • -
  •  
  • -
`; + const listHTML = `
    \n
  • b
  • \n
  •  
  • \n
`; - const testFormat = (testId: string, label: string, config: TestConfig) => { - const { selector, selectorCount, html } = config; + const testFormat = (editor: Editor, config: TestConfig) => { + const { selector, selectorCount, html, rawHtml, expectedHtml, expectedRawHtml } = config; const expectedPresence = { [selector]: selectorCount }; const expectedPresenceOnRemove = { [selector]: 0 }; - it(`${testId}: Test format: ${label}`, () => { - const editor = hook.editor(); - editor.setContent(html); - editor.focus(); - config.select(editor); - config.apply(editor); - TinyAssertions.assertContentPresence(editor, expectedPresence); - config.remove(editor); - TinyAssertions.assertContentPresence(editor, expectedPresenceOnRemove); + editor.setContent(html, { format: rawHtml === true ? 'raw' : 'html' }); + editor.focus(); + config.select(editor); + config.apply(editor); + TinyAssertions.assertContentPresence(editor, expectedPresence); + if (Type.isNonNullable(expectedHtml)) { + TinyAssertions.assertContent(editor, expectedHtml); + } + if (Type.isNonNullable(expectedRawHtml)) { + TinyAssertions.assertRawContent(editor, expectedRawHtml); + } + config.remove(editor); + TinyAssertions.assertContentPresence(editor, expectedPresenceOnRemove); + }; + + const testInlineFormats = (editor: Editor, config: PartialTestConfig) => { + testFormat(editor, { + ...config, + selector: 'strong', + apply: toggleInlineStyle('Bold'), + remove: toggleInlineStyle('Bold') + }); + + testFormat(editor, { + ...config, + selector: 'em', + apply: toggleInlineStyle('Italic'), + remove: toggleInlineStyle('Italic') + }); + + testFormat(editor, { + ...config, + selector: 'span[style*="text-decoration: underline;"]', + apply: toggleInlineStyle('Underline'), + remove: toggleInlineStyle('Underline') + }); + + testFormat(editor, { + ...config, + selector: 'span[style*="text-decoration: line-through;"]', + apply: toggleInlineStyle('Strikethrough'), + remove: toggleInlineStyle('Strikethrough') }); }; - const sTestInlineFormats = (testId: string, label: string, config: PartialTestConfig) => - context(`${testId}: Test inline formats: ${label}`, () => { - testFormat(testId, 'Bold', { - ...config, - selector: 'strong', - apply: toggleInlineStyle('Bold'), - remove: toggleInlineStyle('Bold') + const testBlockFormats = (editor: Editor, config: PartialTestConfig) => { + testFormat(editor, { + ...config, + selector: 'h1', + apply: applyCustomFormat('h1'), + remove: removeCustomFormat('h1') + }); + + testFormat(editor, { + ...config, + selector: 'div', + apply: applyCustomFormat('div'), + remove: removeCustomFormat('div') + }); + }; + + context('Test inline formats on valid blocks', () => { + it('TINY-6483: Check inline formats apply to valid empty block (paragraph)', () => { + testInlineFormats(hook.editor(), { + selectorCount: 3, + html: tagHTML('p'), + select: selectAll }); + }); - testFormat(testId, 'Italic', { - ...config, - selector: 'em', - apply: toggleInlineStyle('Italic'), - remove: toggleInlineStyle('Italic') + it('TINY-6483: Check inline formats apply to valid empty block (heading)', () => { + testInlineFormats(hook.editor(), { + selectorCount: 3, + html: tagHTML('p'), + select: selectAll }); + }); - testFormat(testId, 'Underline', { - ...config, - selector: 'span[style*="text-decoration: underline;"]', - apply: toggleInlineStyle('Underline'), - remove: toggleInlineStyle('Underline') + it('TINY-6483: Check inline formats apply to valid empty block (preformat)', () => { + testInlineFormats(hook.editor(), { + selectorCount: 3, + html: tagHTML('pre'), + select: selectAll }); + }); - testFormat(testId, 'Strikethrough', { - ...config, - selector: 'span[style*="text-decoration: line-through;"]', - apply: toggleInlineStyle('Strikethrough'), - remove: toggleInlineStyle('Strikethrough') + it('TINY-6483: Check inline formats apply to valid empty block (div)', () => { + testInlineFormats(hook.editor(), { + selectorCount: 3, + html: tagHTML('div'), + select: selectAll }); }); - const testBlockFormats = (testId: string, label: string, config: PartialTestConfig) => - context(`${testId}: Test block formats: ${label}`, () => { - testFormat(testId, 'h1', { - ...config, - selector: 'h1', - apply: applyCustomFormat('h1'), - remove: removeCustomFormat('h1') + it('TINY-6483: Check inline formats apply to valid empty block (blockquote)', () => { + testInlineFormats(hook.editor(), { + selectorCount: 3, + html: tagHTML('blockquote'), + select: selectAll }); + }); - testFormat(testId, 'div', { - ...config, - selector: 'div', - apply: applyCustomFormat('div'), - remove: removeCustomFormat('div') + it('TINY-6483: Check inline formats apply to valid empty block (list - li)', () => { + testInlineFormats(hook.editor(), { + selectorCount: 4, + html: `

a

\n${listHTML}\n

c

`, + select: selectAll }); }); - // Test inline formats on valid blocks - sTestInlineFormats('TINY-6483', 'Check inline formats apply to valid empty block (paragraph)', { - selectorCount: 3, - html: tagHTML('p'), - select: selectAll - }); - sTestInlineFormats('TINY-6483', 'Check inline formats apply to valid empty block (heading)', { - selectorCount: 3, - html: tagHTML('p'), - select: selectAll - }); - sTestInlineFormats('TINY-6483', 'Check inline formats apply to valid empty block (preformat)', { - selectorCount: 3, - html: tagHTML('pre'), - select: selectAll - }); - sTestInlineFormats('TINY-6483', 'Check inline formats apply to valid empty block (div)', { - selectorCount: 3, - html: tagHTML('div'), - select: selectAll - }); - sTestInlineFormats('TINY-6483', 'Check inline formats apply to valid empty block (blockquote)', { - selectorCount: 3, - html: tagHTML('blockquote'), - select: selectAll - }); - sTestInlineFormats('TINY-6483', 'Check inline formats apply to valid empty block (list - li)', { - selectorCount: 4, - html: `

a

${listHTML}

c

`, - select: selectAll - }); - sTestInlineFormats('TINY-6483', 'Check inline formats apply to valid empty block (table - td,th)', { - selectorCount: 5, - html: `

a

${tableHTML}

c

`, - select: selectAll + it('TINY-6483: Check inline formats apply to valid empty block (table - td,th)', () => { + testInlineFormats(hook.editor(), { + selectorCount: 5, + html: `

a

${tableHTML}

c

`, + select: selectAll + }); + }); }); // Test that for a collapsed selection only the caret span is formatted and not the br - sTestInlineFormats('TINY-6483', 'Check collapsed selection (paragraph)', { - selectorCount: 1, - html: tagHTML('p'), - select: (editor) => TinySelections.setCursor(editor, [ 1, 0 ], 0) + it('TINY-6483: Check collapsed selection (paragraph)', () => { + testInlineFormats(hook.editor(), { + selectorCount: 1, + html: tagHTML('p'), + select: (editor) => TinySelections.setCursor(editor, [ 1, 0 ], 0) + }); }); // Test inline format on br surrounded by inline block - testFormat('TINY-6483', 'Check inline format does not apply to empty inline block', { - selector: 'strong', - selectorCount: 3, - html: '

a

 

b

', - select: selectAll, - apply: toggleInlineStyle('Bold'), - remove: toggleInlineStyle('Bold') + it('TINY-6483: Check inline format does not apply to empty inline block', () => { + testFormat(hook.editor(), { + selector: 'strong', + selectorCount: 3, + html: '

a

\n

 

\n

b

', + select: selectAll, + apply: toggleInlineStyle('Bold'), + remove: toggleInlineStyle('Bold') + }); }); // Test cells can be formatted with internal table selections - sTestInlineFormats('TINY-6483', 'Check inline formats apply to table cells with explicit cell selections', { - selectorCount: 3, - html: ` - - - - - - - - - - - -
 
b
 
`, - select: (editor) => TinySelections.setCursor(editor, [ 0, 0, 0, 0, 0 ], 0) + it('TINY-6483: Check inline formats apply to table cells with explicit cell selections', () => { + testInlineFormats(hook.editor(), { + selectorCount: 3, + html: ` + + + + + + + + + + + +
 
b
 
`, + select: (editor) => TinySelections.setCursor(editor, [ 0, 0, 0, 0, 0 ], 0) + }); }); - // Test block formats - testBlockFormats('TINY-6483', 'Check block formats converts valid blocks (paragraphs)', { - selectorCount: 3, - html: tagHTML('p'), - select: selectAll + context('Test block formats', () => { + it('TINY-6483: Check block formats converts valid blocks (paragraphs)', () => { + testBlockFormats(hook.editor(), { + selectorCount: 3, + html: tagHTML('p'), + select: selectAll + }); + }); + + it('TINY-6483: Check block formats do not apply to invalid empty block (list - li)', () => { + testBlockFormats(hook.editor(), { + selectorCount: 3, + html: `

a

\n${listHTML}\n

c

`, + select: selectAll + }); + }); + + it('TINY-6483: Check block formats do not apply to invalid empty block (table - td,th)', () => { + testBlockFormats(hook.editor(), { + selectorCount: 3, + html: `

a

\n${tableHTML}

c

`, + select: selectAll + }); + }); }); - testBlockFormats('TINY-6483', 'Check block formats do not apply to invalid empty block (list - li)', { - selectorCount: 3, - html: `

a

${listHTML}

c

`, - select: selectAll + + context('Serializing empty inline format elements', () => { + it('TINY-8639: Check inline format is correctly serialized (bold)', () => { + testFormat(hook.editor(), { + selector: 'strong', + selectorCount: 3, + html: '

a


b

', + rawHtml: true, + expectedHtml: + '

a

\n' + + '

 

\n' + + '

b

', + expectedRawHtml: + '

a

' + + '


' + + '

b

', + select: selectAll, + apply: toggleInlineStyle('Bold'), + remove: toggleInlineStyle('Bold') + }); + }); + + it('TINY-8639: Check inline format is correctly serialized (strikethrough)', () => { + testFormat(hook.editor(), { + selector: 'span[style*="text-decoration: line-through;"]', + selectorCount: 3, + html: '

a


b

', + rawHtml: true, + expectedHtml: + '

a

\n' + + '

 

\n' + + '

b

', + expectedRawHtml: + '

a

' + + '


' + + '

b

', + select: selectAll, + apply: toggleInlineStyle('Strikethrough'), + remove: toggleInlineStyle('Strikethrough') + }); + }); + + it('TINY-8639: Check inline format is correctly serialized (underline)', () => { + testFormat(hook.editor(), { + selector: 'span[style*="text-decoration: underline;"]', + selectorCount: 3, + html: '

a


b

', + rawHtml: true, + expectedHtml: + '

a

\n' + + '

 

\n' + + '

b

', + expectedRawHtml: + '

a

' + + '


' + + '

b

', + select: selectAll, + apply: toggleInlineStyle('Underline'), + remove: toggleInlineStyle('Underline') + }); + }); + + it('TINY-8639: Check inline format is correctly serialized (bold and strikethrough)', () => { + testFormat(hook.editor(), { + selector: 'strong', + selectorCount: 3, + html: '

a


b

', + rawHtml: true, + expectedHtml: + '

a

\n' + + '

 

\n' + + '

b

', + expectedRawHtml: + '

a

' + + '


' + + '

b

', + select: selectAll, + apply: (editor) => { + toggleInlineStyle('Bold')(editor); + toggleInlineStyle('Strikethrough')(editor); + }, + remove: (editor) => { + toggleInlineStyle('Bold')(editor); + toggleInlineStyle('Strikethrough')(editor); + }, + }); + }); + + it('TINY-8639: Check inline format is correctly serialized in list item', () => { + testFormat(hook.editor(), { + selector: 'strong', + selectorCount: 2, + html: '

a


', + rawHtml: true, + expectedHtml: + '

a

\n' + + '
    \n
  •  
  • \n
', + expectedRawHtml: + '

a

' + + '

', + select: selectAll, + apply: toggleInlineStyle('Bold'), + remove: toggleInlineStyle('Bold') + }); + }); + + it('TINY-8639: should be able to insert and type in serialized empty inline format element', async () => { + const editor = hook.editor(); + editor.setContent('

a


', { format: 'raw' }); + selectAll(editor); + toggleInlineStyle('Bold')(editor); + TinyAssertions.assertRawContent(editor, '

a


'); + TinyAssertions.assertContent(editor, '

a

\n

 

'); + + TinySelections.setCursor(editor, [ 1, 0, 0 ], 0); + await pAssertToolbarButtonState(editor, 'Bold', true); + + const content = editor.getContent(); + editor.setContent(content); + TinyAssertions.assertRawContent(editor, '

a

 

'); + TinySelections.setCursor(editor, [ 1, 0, 0 ], 0); + await pAssertToolbarButtonState(editor, 'Bold', true); + TinyContentActions.type(editor, 'abc'); + TinyAssertions.assertRawContent(editor, '

a

abc 

'); + }); + + it('TINY-8639: should be able to insert and remove serialized empty inline format element', async () => { + const editor = hook.editor(); + editor.setContent('

a

 

'); + TinySelections.setCursor(editor, [ 1, 0, 0 ], 0); + await pAssertToolbarButtonState(editor, 'Bold', true); + toggleInlineStyle('Bold')(editor); + TinyAssertions.assertRawContent(editor, '

a

 

'); + }); + + it('TINY-8639: should serialize caret formatted empty line if the cursor has not moved', () => { + const editor = hook.editor(); + editor.setContent('

a

'); + TinySelections.setCursor(editor, [ 0, 0 ], 1); + TinyContentActions.keystroke(editor, Keys.enter()); + toggleInlineStyle('Bold')(editor); + TinyAssertions.assertRawContent( + editor, + '

a

' + + `

${Unicode.zeroWidth}

` + ); + TinyAssertions.assertContent(editor, '

a

\n

 

'); + }); }); - testBlockFormats('TINY-6483', 'Check block formats do not apply to invalid empty block (table - td,th)', { - selectorCount: 3, - html: `

a

${tableHTML}

c

`, - select: selectAll + + context('Parsing empty inline formatting elements', () => { + it('TINY-8639: should be padded on an empty line', () => { + const editor = hook.editor(); + editor.setContent('

'); + TinyAssertions.assertRawContent(editor, '

 

'); + TinyAssertions.assertContent(editor, '

 

'); + }); + + it('TINY-8639: should be padded when in an empty block', () => { + const editor = hook.editor(); + editor.setContent('
test
'); + TinyAssertions.assertRawContent(editor, '
test
 
'); + TinyAssertions.assertContent(editor, '
test\n
 
\n
'); + }); + + it('TINY-8639: should not be padded when in an non-empty line', () => { + const editor = hook.editor(); + editor.setContent('

testing

'); + TinyAssertions.assertRawContent(editor, '

testing

'); + TinyAssertions.assertContent(editor, '

testing

'); + }); + + it('TINY-8639: should not be padded when in an non-empty block', () => { + const editor = hook.editor(); + editor.setContent('
test
'); + TinyAssertions.assertRawContent(editor, '
test
'); + TinyAssertions.assertContent(editor, '
\n
test
\n
'); + }); }); }); diff --git a/modules/tinymce/src/core/test/ts/browser/html/DomParserTest.ts b/modules/tinymce/src/core/test/ts/browser/html/DomParserTest.ts index 57364235257..37248c57133 100644 --- a/modules/tinymce/src/core/test/ts/browser/html/DomParserTest.ts +++ b/modules/tinymce/src/core/test/ts/browser/html/DomParserTest.ts @@ -816,4 +816,174 @@ describe('browser.tinymce.core.html.DomParserTest', () => { '' ); }); + + it('TINY-8639: handling empty text inline elements when root block is empty', () => { + const html = '

' + + '

' + + '

' + + '

' + + '

'; + + // Assert default behaviour when padd_empty_block_inline_children is not specified (should be equivalent to false) + let parser = DomParser({}, Schema({})); + let serializedHtml = serializer.serialize(parser.parse(html)); + + assert.equal(serializedHtml, + '

\u00a0

' + + '

\u00a0

' + + '

\u00a0

' + + '

\u00a0

' + + '

\u00a0

' + ); + + parser = DomParser({}, Schema({ padd_empty_block_inline_children: false })); + serializedHtml = serializer.serialize(parser.parse(html)); + + assert.equal(serializedHtml, + '

\u00a0

' + + '

\u00a0

' + + '

\u00a0

' + + '

\u00a0

' + + '

\u00a0

' + ); + + parser = DomParser({}, Schema({ padd_empty_block_inline_children: true })); + serializedHtml = serializer.serialize(parser.parse(html)); + + assert.equal(serializedHtml, + '

\u00a0

' + + '

\u00a0

' + + '

\u00a0

' + + '

\u00a0

' + + '

\u00a0

' + ); + }); + + it('TINY-8639: handling single space text inline elements when root block is otherwise empty', () => { + const html = '

' + + '

' + + '

' + + '

' + + '

'; + + // Assert default behaviour when padd_empty_block_inline_children is not specified (should be equivalent to false) + let parser = DomParser({}, Schema({})); + let serializedHtml = serializer.serialize(parser.parse(html)); + + assert.equal(serializedHtml, + '

' + + '

' + + // isEmpty node logic considers a span with no style attribute and a single space to be empty (Node.ts -> isEmpty -> isEmptyTextNode) + '

\u00a0

' + + '

' + + '

\u00a0

' + ); + + parser = DomParser({}, Schema({ padd_empty_block_inline_children: false })); + serializedHtml = serializer.serialize(parser.parse(html)); + + assert.equal(serializedHtml, + '

' + + '

' + + // isEmpty node logic considers a span with no style attribute and a single space to be empty (Node.ts -> isEmpty -> isEmptyTextNode) + '

\u00a0

' + + '

' + + '

\u00a0

' + ); + + parser = DomParser({}, Schema({ padd_empty_block_inline_children: true })); + serializedHtml = serializer.serialize(parser.parse(html)); + + assert.equal(serializedHtml, + '

' + + '

' + + '

\u00a0

' + + '

' + + '

\u00a0

' + ); + }); + + it('TINY-8639: handling single nbsp text inline elements when root block is otherwise empty', () => { + const html = '

 

' + + '

 

' + + '

 

' + + '

 

' + + '

 

'; + + // Assert default behaviour when padd_empty_block_inline_children is not specified (should be equivalent to false) + let parser = DomParser({}, Schema({})); + let serializedHtml = serializer.serialize(parser.parse(html)); + + assert.equal(serializedHtml, + '

\u00a0

' + + '

\u00a0

' + + '

\u00a0

' + + '

\u00a0

' + + '

\u00a0

' + ); + + parser = DomParser({}, Schema({ padd_empty_block_inline_children: false })); + serializedHtml = serializer.serialize(parser.parse(html)); + + assert.equal(serializedHtml, + '

\u00a0

' + + '

\u00a0

' + + '

\u00a0

' + + '

\u00a0

' + + '

\u00a0

' + ); + + parser = DomParser({}, Schema({ padd_empty_block_inline_children: true })); + serializedHtml = serializer.serialize(parser.parse(html)); + + assert.equal(serializedHtml, + '

\u00a0

' + + '

\u00a0

' + + '

\u00a0

' + + '

\u00a0

' + + '

\u00a0

' + ); + }); + + it('TINY-8639: should always remove empty inline element if it is not in an empty block', () => { + const html = '

abcd

' + + '

abcd

' + + '

abcd

' + + '

abcd

' + + '

abcd

'; + + // Assert default behaviour when padd_empty_block_inline_children is not specified (should be equivalent to false) + let parser = DomParser({}, Schema({})); + let serializedHtml = serializer.serialize(parser.parse(html)); + + assert.equal(serializedHtml, + '

abcd

' + + '

abcd

' + + '

abcd

' + + '

abcd

' + + '

abcd

' + ); + + parser = DomParser({}, Schema({ padd_empty_block_inline_children: false })); + serializedHtml = serializer.serialize(parser.parse(html)); + + assert.equal(serializedHtml, + '

abcd

' + + '

abcd

' + + '

abcd

' + + '

abcd

' + + '

abcd

' + ); + + parser = DomParser({}, Schema({ padd_empty_block_inline_children: true })); + serializedHtml = serializer.serialize(parser.parse(html)); + + assert.equal(serializedHtml, + '

abcd

' + + '

abcd

' + + '

abcd

' + + '

abcd

' + + '

abcd

' + ); + }); }); diff --git a/modules/tinymce/src/core/test/ts/browser/html/SchemaTest.ts b/modules/tinymce/src/core/test/ts/browser/html/SchemaTest.ts index a9fd34bb226..71de005cb28 100644 --- a/modules/tinymce/src/core/test/ts/browser/html/SchemaTest.ts +++ b/modules/tinymce/src/core/test/ts/browser/html/SchemaTest.ts @@ -1,5 +1,5 @@ -import { describe, it } from '@ephox/bedrock-client'; -import { Arr, Obj } from '@ephox/katamari'; +import { context, describe, it } from '@ephox/bedrock-client'; +import { Arr, Obj, Type } from '@ephox/katamari'; import { assert } from 'chai'; import Schema from 'tinymce/core/api/html/Schema'; @@ -272,9 +272,9 @@ describe('browser.tinymce.core.html.SchemaTest', () => { const schema = Schema(); assert.deepEqual(schema.getTextInlineElements(), { B: {}, CITE: {}, CODE: {}, DFN: {}, EM: {}, FONT: {}, I: {}, MARK: {}, Q: {}, - SAMP: {}, SPAN: {}, STRIKE: {}, STRONG: {}, SUB: {}, SUP: {}, U: {}, VAR: {}, + SAMP: {}, SPAN: {}, S: {}, STRIKE: {}, STRONG: {}, SUB: {}, SUP: {}, U: {}, VAR: {}, b: {}, cite: {}, code: {}, dfn: {}, em: {}, font: {}, i: {}, mark: {}, q: {}, - samp: {}, span: {}, strike: {}, strong: {}, sub: {}, sup: {}, u: {}, var: {} + samp: {}, span: {}, s: {}, strike: {}, strong: {}, sub: {}, sup: {}, u: {}, var: {} }); }); @@ -523,4 +523,24 @@ describe('browser.tinymce.core.html.SchemaTest', () => { } }); }); + + context('paddInEmptyBlock', () => { + it('TINY-8639: default behaviour', () => { + const schema = Schema({}); + const rules = Obj.mapToArray(schema.getTextInlineElements(), (_value, name) => schema.getElementRule(name.toLowerCase())); + assert.isTrue(rules.length > 0 && Arr.forall(rules, (rule) => Type.isUndefined(rule.paddInEmptyBlock))); + }); + + it('TINY-8639: padd_empty_block_inline_children: false', () => { + const schema = Schema({ padd_empty_block_inline_children: false }); + const rules = Obj.mapToArray(schema.getTextInlineElements(), (_value, name) => schema.getElementRule(name.toLowerCase())); + assert.isTrue(rules.length > 0 && Arr.forall(rules, (rule) => Type.isUndefined(rule.paddInEmptyBlock))); + }); + + it('TINY-8639: padd_empty_block_inline_children: true', () => { + const schema = Schema({ padd_empty_block_inline_children: true }); + const rules = Obj.mapToArray(schema.getTextInlineElements(), (_value, name) => schema.getElementRule(name.toLowerCase())); + assert.isTrue(rules.length > 0 && Arr.forall(rules, (rule) => rule.paddInEmptyBlock === true)); + }); + }); });