diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 34967d99707..f78446075cf 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -1,11 +1,5 @@ name: Build and Test -on: - push: - branches-ignore: - - master - pull_request: - branches-ignore: - - master +on: [push, pull_request] jobs: build: diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000000..cfd2fcf50ce --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,41 @@ + + +## Security + +Microsoft takes the security of our software products and services seriously, which includes all source code repositories managed through our GitHub organizations, which include [Microsoft](https://github.com/Microsoft), [Azure](https://github.com/Azure), [DotNet](https://github.com/dotnet), [AspNet](https://github.com/aspnet), [Xamarin](https://github.com/xamarin), and [our GitHub organizations](https://opensource.microsoft.com/). + +If you believe you have found a security vulnerability in any Microsoft-owned repository that meets [Microsoft's definition of a security vulnerability](), please report it to us as described below. + +## Reporting Security Issues + +**Please do not report security vulnerabilities through public GitHub issues.** + +Instead, please report them to the Microsoft Security Response Center (MSRC) at [https://msrc.microsoft.com/create-report](https://msrc.microsoft.com/create-report). + +If you prefer to submit without logging in, send email to [secure@microsoft.com](mailto:secure@microsoft.com). If possible, encrypt your message with our PGP key; please download it from the [Microsoft Security Response Center PGP Key page](https://www.microsoft.com/msrc/pgp-key-msrc). + +You should receive a response within 24 hours. If for some reason you do not, please follow up via email to ensure we received your original message. Additional information can be found at [microsoft.com/msrc](https://www.microsoft.com/msrc). + +Please include the requested information listed below (as much as you can provide) to help us better understand the nature and scope of the possible issue: + +- Type of issue (e.g. buffer overflow, SQL injection, cross-site scripting, etc.) +- Full paths of source file(s) related to the manifestation of the issue +- The location of the affected source code (tag/branch/commit or direct URL) +- Any special configuration required to reproduce the issue +- Step-by-step instructions to reproduce the issue +- Proof-of-concept or exploit code (if possible) +- Impact of the issue, including how an attacker might exploit the issue + +This information will help us triage your report more quickly. + +If you are reporting for a bug bounty, more complete reports can contribute to a higher bounty award. Please visit our [Microsoft Bug Bounty Program](https://microsoft.com/msrc/bounty) page for more details about our active programs. + +## Preferred Languages + +We prefer all communications to be in English. + +## Policy + +Microsoft follows the principle of [Coordinated Vulnerability Disclosure](https://www.microsoft.com/msrc/cvd). + + diff --git a/package.json b/package.json index d13055df295..5846d7ef470 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "roosterjs", - "version": "8.22.0", + "version": "8.22.1", "description": "Framework-independent javascript editor", "repository": { "type": "git", diff --git a/packages/roosterjs-editor-api/lib/format/createLink.ts b/packages/roosterjs-editor-api/lib/format/createLink.ts index 062c603a1f9..c165976a0c9 100644 --- a/packages/roosterjs-editor-api/lib/format/createLink.ts +++ b/packages/roosterjs-editor-api/lib/format/createLink.ts @@ -42,6 +42,7 @@ function applyLinkPrefix(url: string): string { * When protocol is not specified, a best matched protocol will be predicted. * @param altText Optional alt text of the link, will be shown when hover on the link * @param displayText Optional display text for the link. + * @param target Optional display target for the link ("_blank"|"_self"|"_parent"|"_top"|"{framename}") * If specified, the display text of link will be replaced with this text. * If not specified and there wasn't a link, the link url will be used as display text. */ @@ -49,7 +50,8 @@ export default function createLink( editor: IEditor, link: string, altText?: string, - displayText?: string + displayText?: string, + target?: string ) { editor.focus(); let url = (checkXss(link) || '').trim(); @@ -96,6 +98,9 @@ export default function createLink( if (altText && anchor) { anchor.title = altText; } + if (anchor) { + updateAnchorTarget(anchor, target); + } return anchor; }, ChangeSource.CreateLink); } @@ -111,6 +116,14 @@ function updateAnchorDisplayText(anchor: HTMLAnchorElement, displayText: string) } } +function updateAnchorTarget(anchor: HTMLAnchorElement, target?: string) { + if (target) { + anchor.target = target; + } else if (!target && anchor.getAttribute('target')) { + anchor.removeAttribute('target'); + } +} + function checkXss(link: string): string { const sanitizer = new HtmlSanitizer(); const a = document.createElement('a'); diff --git a/packages/roosterjs-editor-api/lib/format/insertImage.ts b/packages/roosterjs-editor-api/lib/format/insertImage.ts index 688638b9d3f..0e87aa65501 100644 --- a/packages/roosterjs-editor-api/lib/format/insertImage.ts +++ b/packages/roosterjs-editor-api/lib/format/insertImage.ts @@ -6,32 +6,53 @@ import { readFile } from 'roosterjs-editor-dom'; * @param editor The editor instance * @param imageFile The image file. There are at least 3 ways to obtain the file object: * From local file, from clipboard data, from drag-and-drop + * @param attributes Optional image element attributes */ -export default function insertImage(editor: IEditor, imageFile: File): void; +export default function insertImage( + editor: IEditor, + imageFile: File, + attributes?: Record +): void; /** * Insert an image to editor at current selection * @param editor The editor instance - * @param imageFile The image link. + * @param url The image link + * @param attributes Optional image element attributes */ -export default function insertImage(editor: IEditor, url: string): void; +export default function insertImage( + editor: IEditor, + url: string, + attributes?: Record +): void; -export default function insertImage(editor: IEditor, imageFile: File | string): void { +export default function insertImage( + editor: IEditor, + imageFile: File | string, + attributes?: Record +): void { if (typeof imageFile == 'string') { - insertImageWithSrc(editor, imageFile); + insertImageWithSrc(editor, imageFile, attributes); } else { readFile(imageFile, dataUrl => { if (dataUrl && !editor.isDisposed()) { - insertImageWithSrc(editor, dataUrl); + insertImageWithSrc(editor, dataUrl, attributes); } }); } } -function insertImageWithSrc(editor: IEditor, src: string) { +function insertImageWithSrc(editor: IEditor, src: string, attributes?: Record) { editor.addUndoSnapshot(() => { const image = editor.getDocument().createElement('img'); image.src = src; + + if (attributes) { + Object.keys(attributes).forEach(attribute => + image.setAttribute(attribute, attributes[attribute]) + ); + } + image.style.maxWidth = '100%'; editor.insertNode(image); }, ChangeSource.Format); diff --git a/packages/roosterjs-editor-api/test/format/createLinkTest.ts b/packages/roosterjs-editor-api/test/format/createLinkTest.ts index 2c0300907b1..78de23298ea 100644 --- a/packages/roosterjs-editor-api/test/format/createLinkTest.ts +++ b/packages/roosterjs-editor-api/test/format/createLinkTest.ts @@ -84,6 +84,15 @@ describe('createLink()', () => { expect(link.outerHTML).toBe('this is my link'); }); + it('sets target attribute in the link', () => { + // Act + createLink(editor, 'www.example.com', undefined, undefined, '_self'); + + // Assert + let link = document.getElementsByTagName('a')[0]; + expect(link.target).toBe('_self'); + }); + it('Issue when selection is under another tag', () => { editor.setContent( '
Hello world ðŸ™‚ this is a test
' diff --git a/packages/roosterjs-editor-dom/lib/contentTraverser/SelectionBlockScoper.ts b/packages/roosterjs-editor-dom/lib/contentTraverser/SelectionBlockScoper.ts index 357dc0bbaca..1ccff11c4b5 100644 --- a/packages/roosterjs-editor-dom/lib/contentTraverser/SelectionBlockScoper.ts +++ b/packages/roosterjs-editor-dom/lib/contentTraverser/SelectionBlockScoper.ts @@ -35,9 +35,27 @@ export default class SelectionBlockScoper implements TraversingScoper { position: NodePosition | Range, private startFrom: ContentPosition | CompatibleContentPosition ) { - position = safeInstanceOf(position, 'Range') ? Position.getStart(position) : position; - this.position = position.normalize(); - this.block = getBlockElementAtNode(this.rootNode, this.position.node); + // Debugging info, will be removed later + let isPosition = false; + + if (safeInstanceOf(position, 'Range')) { + position = Position.getStart(position); + } else { + isPosition = true; + } + + try { + this.position = position.normalize(); + this.block = getBlockElementAtNode(this.rootNode, this.position.node); + } catch (e) { + throw new Error( + `${ + (e as any)?.message + }; isPosition: ${isPosition}; actual type: ${typeof position}; String name: ${ + typeof position?.toString === 'function' ? position.toString() : 'No toString()' + }` + ); + } } /** diff --git a/packages/roosterjs-editor-dom/lib/index.ts b/packages/roosterjs-editor-dom/lib/index.ts index 4b69ab20eba..8ad6466b313 100644 --- a/packages/roosterjs-editor-dom/lib/index.ts +++ b/packages/roosterjs-editor-dom/lib/index.ts @@ -112,3 +112,13 @@ export { default as setStyles } from './style/setStyles'; export { default as adjustInsertPosition } from './edit/adjustInsertPosition'; export { default as deleteSelectedContent } from './edit/deleteSelectedContent'; export { default as getTextContent } from './edit/getTextContent'; + +export { default as validate } from './metadata/validate'; +export { + createNumberDefinition, + createBooleanDefinition, + createStringDefinition, + createArrayDefinition, + createObjectDefinition, +} from './metadata/definitionCreators'; +export { getMetadata, setMetadata, removeMetadata } from './metadata/metadata'; diff --git a/packages/roosterjs-editor-dom/lib/inlineElements/applyTextStyle.ts b/packages/roosterjs-editor-dom/lib/inlineElements/applyTextStyle.ts index 632b5e209cb..6e4dcb67249 100644 --- a/packages/roosterjs-editor-dom/lib/inlineElements/applyTextStyle.ts +++ b/packages/roosterjs-editor-dom/lib/inlineElements/applyTextStyle.ts @@ -6,7 +6,7 @@ import { getNextLeafSibling } from '../utils/getLeafSibling'; import { NodePosition, NodeType, PositionType } from 'roosterjs-editor-types'; import { splitBalancedNodeRange } from '../utils/splitParentNode'; -const STYLET_AGS = 'SPAN,B,I,U,EM,STRONG,STRIKE,S,SMALL'.split(','); +const STYLET_AGS = 'SPAN,B,I,U,EM,STRONG,STRIKE,S,SMALL,SUP,SUB'.split(','); /** * Apply style using a styler function to the given container node in the given range diff --git a/packages/roosterjs-editor-dom/lib/metadata/definitionCreators.ts b/packages/roosterjs-editor-dom/lib/metadata/definitionCreators.ts new file mode 100644 index 00000000000..670ddc8586f --- /dev/null +++ b/packages/roosterjs-editor-dom/lib/metadata/definitionCreators.ts @@ -0,0 +1,120 @@ +import { + Definition, + DefinitionType, + NumberDefinition, + ArrayDefinition, + BooleanDefinition, + StringDefinition, + ObjectDefinition, + ObjectPropertyDefinition, +} from 'roosterjs-editor-types'; + +/** + * Create a number definition + * @param isOptional Whether this property is optional + * @param value Optional value of the number + * @param minValue Optional minimum value + * @param maxValue Optional maximum value + * @param allowNull Allow the property to be null + * @returns The number definition object + */ +export function createNumberDefinition( + isOptional?: boolean, + value?: number, + minValue?: number, + maxValue?: number, + allowNull?: boolean +): NumberDefinition { + return { + type: DefinitionType.Number, + isOptional, + value, + maxValue, + minValue, + allowNull, + }; +} + +/** + * Create a boolean definition + * @param isOptional Whether this property is optional + * @param value Optional expected boolean value + * @param allowNull Allow the property to be null + * @returns The boolean definition object + */ +export function createBooleanDefinition( + isOptional?: boolean, + value?: boolean, + allowNull?: boolean +): BooleanDefinition { + return { + type: DefinitionType.Boolean, + isOptional, + value, + allowNull, + }; +} + +/** + * Create a string definition + * @param isOptional Whether this property is optional + * @param value Optional expected string value + * @param allowNull Allow the property to be null + * @returns The string definition object + */ +export function createStringDefinition( + isOptional?: boolean, + value?: string, + allowNull?: boolean +): StringDefinition { + return { + type: DefinitionType.String, + isOptional, + value, + allowNull, + }; +} + +/** + * Create an array definition + * @param itemDef Definition of each item of the related array + * @param isOptional Whether this property is optional + * @param allowNull Allow the property to be null + * @returns The array definition object + */ +export function createArrayDefinition( + itemDef: Definition, + isOptional?: boolean, + minLength?: number, + maxLength?: number, + allowNull?: boolean +): ArrayDefinition { + return { + type: DefinitionType.Array, + isOptional, + itemDef, + minLength, + maxLength, + allowNull, + }; +} + +/** + * Create an object definition + * @param propertyDef Definition of each property of the related object + * @param isOptional Whether this property is optional + * @param allowNull Allow the property to be null + * @returns The object definition object + */ +export function createObjectDefinition( + propertyDef: ObjectPropertyDefinition, + isOptional?: boolean, + allowNull?: boolean +): ObjectDefinition { + return { + type: DefinitionType.Object, + isOptional, + propertyDef, + allowNull, + }; +} diff --git a/packages/roosterjs-editor-dom/lib/metadata/metadata.ts b/packages/roosterjs-editor-dom/lib/metadata/metadata.ts new file mode 100644 index 00000000000..fcb097fba15 --- /dev/null +++ b/packages/roosterjs-editor-dom/lib/metadata/metadata.ts @@ -0,0 +1,65 @@ +import validate from './validate'; +import { Definition } from 'roosterjs-editor-types'; + +const MetadataDataSetName = 'editingInfo'; + +/** + * Get metadata object from an HTML element + * @param element The HTML element to get metadata object from + * @param definition The type definition of this metadata used for validate this metadata object. + * If not specified, no validation will be performed and always return whatever we get from the element + * @param defaultValue The default value to return if the retrieved object cannot pass the validation, + * or there is no metadata object at all + * @returns The strong-type metadata object if it can be validated, or null + */ +export function getMetadata( + element: HTMLElement, + definition?: Definition, + defaultValue?: T +): T | null { + const str = element.dataset[MetadataDataSetName]; + let obj: any; + + try { + obj = str ? JSON.parse(str) : null; + } catch {} + + if (typeof obj !== 'undefined') { + if (!definition) { + return obj as T; + } else if (validate(obj, definition)) { + return obj; + } + } + + if (defaultValue) { + return defaultValue; + } else { + return null; + } +} + +/** + * Set metadata object into an HTML element + * @param element The HTML element to set metadata object to + * @param metadata The metadata object to set + * @param def An optional type definition object used for validate this metadata object. + * If not specified, metadata will be set without validation + * @returns True if metadata is set, otherwise false + */ +export function setMetadata(element: HTMLElement, metadata: T, def?: Definition): boolean { + if (!def || validate(metadata, def)) { + element.dataset[MetadataDataSetName] = JSON.stringify(metadata); + return true; + } else { + return false; + } +} + +/** + * Remove metadata from the given element if any + * @param element The element to remove metadata from + */ +export function removeMetadata(element: HTMLElement) { + delete element.dataset[MetadataDataSetName]; +} diff --git a/packages/roosterjs-editor-dom/lib/metadata/validate.ts b/packages/roosterjs-editor-dom/lib/metadata/validate.ts new file mode 100644 index 00000000000..4d816b19b63 --- /dev/null +++ b/packages/roosterjs-editor-dom/lib/metadata/validate.ts @@ -0,0 +1,60 @@ +import { Definition, DefinitionType } from 'roosterjs-editor-types'; + +/** + * Validate the given object with a type definition object + * @param input The object to validate + * @param def The type definition object used for validation + * @returns True if the object passed the validation, otherwise false + */ +export default function validate(input: any, def: Definition): input is T { + let result = false; + if ((def.isOptional && typeof input === 'undefined') || (def.allowNull && input === null)) { + result = true; + } else { + switch (def.type) { + case DefinitionType.String: + result = + typeof input === 'string' && + (typeof def.value === 'undefined' || input === def.value); + break; + + case DefinitionType.Number: + result = + typeof input === 'number' && + (typeof def.value === 'undefined' || areSameNumbers(def.value, input)) && + (typeof def.minValue === 'undefined' || input >= def.minValue) && + (typeof def.maxValue === 'undefined' || input <= def.maxValue); + break; + + case DefinitionType.Boolean: + result = + typeof input === 'boolean' && + (typeof def.value === 'undefined' || input === def.value); + break; + + case DefinitionType.Array: + result = + Array.isArray(input) && + (typeof def.minLength === 'undefined' || input.length >= def.minLength) && + (typeof def.maxLength === 'undefined' || input.length <= def.maxLength) && + input.every(x => validate(x, def.itemDef)); + break; + + case DefinitionType.Object: + result = + typeof input === 'object' && + Object.keys(def.propertyDef).every(x => validate(input[x], def.propertyDef[x])); + break; + + case DefinitionType.Customize: + result = def.validator(input); + break; + } + } + + return result; +} + +function areSameNumbers(n1: number, n2: number) { + return Math.abs(n1 - n2) < 1e-3; +} diff --git a/packages/roosterjs-editor-dom/lib/selection/setHtmlWithSelectionPath.ts b/packages/roosterjs-editor-dom/lib/selection/setHtmlWithSelectionPath.ts index 42ee1239057..3049fa5ccc9 100644 --- a/packages/roosterjs-editor-dom/lib/selection/setHtmlWithSelectionPath.ts +++ b/packages/roosterjs-editor-dom/lib/selection/setHtmlWithSelectionPath.ts @@ -1,5 +1,13 @@ import createRange from './createRange'; import safeInstanceOf from '../utils/safeInstanceOf'; +import validate from '../metadata/validate'; +import { + createArrayDefinition, + createBooleanDefinition, + createNumberDefinition, + createObjectDefinition, + createStringDefinition, +} from '../metadata/definitionCreators'; import { ContentMetadata, SelectionRangeTypes, @@ -9,6 +17,30 @@ import { Coordinates, } from 'roosterjs-editor-types'; +const NumberArrayDefinition = createArrayDefinition(createNumberDefinition()); + +const CoordinatesDefinition = createObjectDefinition({ + x: createNumberDefinition(), + y: createNumberDefinition(), +}); + +const IsDarkModeDefinition = createBooleanDefinition(true /*isOptional*/); + +const NormalContentMetadataDefinition = createObjectDefinition({ + type: createNumberDefinition(true /*isOptional*/, SelectionRangeTypes.Normal), + isDarkMode: IsDarkModeDefinition, + start: NumberArrayDefinition, + end: NumberArrayDefinition, +}); + +const TableContentMetadataDefinition = createObjectDefinition({ + type: createNumberDefinition(false /*isOptional*/, SelectionRangeTypes.TableSelection), + isDarkMode: IsDarkModeDefinition, + tableId: createStringDefinition(), + firstCell: CoordinatesDefinition, + lastCell: CoordinatesDefinition, +}); + /** * @deprecated Use setHtmlWithMetadata instead * Restore inner HTML of a root element from given html string. If the string contains selection path, @@ -55,8 +87,14 @@ export function setHtmlWithMetadata( try { const obj = JSON.parse(potentialMetadataComment.nodeValue || ''); - if (isContentMetadata(obj)) { + if ( + validate(obj, NormalContentMetadataDefinition) || + validate(obj, TableContentMetadataDefinition) + ) { rootNode.removeChild(potentialMetadataComment); + obj.type = typeof obj.type === 'undefined' ? SelectionRangeTypes.Normal : obj.type; + obj.isDarkMode = obj.isDarkMode || false; + return obj; } } catch {} @@ -64,49 +102,3 @@ export function setHtmlWithMetadata( return undefined; } - -function isContentMetadata(obj: any): obj is ContentMetadata { - if (!obj || typeof obj != 'object') { - return false; - } - - switch (obj.type || SelectionRangeTypes.Normal) { - case SelectionRangeTypes.Normal: - const regularMetadata = obj as NormalContentMetadata; - if (isNumberArray(regularMetadata.start) && isNumberArray(regularMetadata.end)) { - obj.type = SelectionRangeTypes.Normal; - obj.isDarkMode = !!obj.isDarkMode; - return true; - } - break; - - case SelectionRangeTypes.TableSelection: - const tableMetadata = obj as TableContentMetadata; - if ( - typeof tableMetadata.tableId == 'string' && - !!tableMetadata.tableId && - isCoordinates(tableMetadata.firstCell) && - isCoordinates(tableMetadata.lastCell) - ) { - obj.isDarkMode = !!obj.isDarkMode; - return true; - } - break; - } - - return false; -} - -function isNumberArray(obj: any): obj is number[] { - return obj && Array.isArray(obj) && obj.every(o => typeof o == 'number'); -} - -function isCoordinates(obj: any): obj is Coordinates { - const coordinates = obj as Coordinates; - return ( - coordinates && - typeof coordinates == 'object' && - typeof coordinates.x == 'number' && - typeof coordinates.y == 'number' - ); -} diff --git a/packages/roosterjs-editor-dom/lib/table/tableFormatInfo.ts b/packages/roosterjs-editor-dom/lib/table/tableFormatInfo.ts index 2fcf8482a4a..44aef0d545d 100644 --- a/packages/roosterjs-editor-dom/lib/table/tableFormatInfo.ts +++ b/packages/roosterjs-editor-dom/lib/table/tableFormatInfo.ts @@ -1,6 +1,43 @@ +import { getMetadata, setMetadata } from '../metadata/metadata'; import { TableFormat } from 'roosterjs-editor-types'; - -const TABLE_STYLE_INFO = 'roosterTableInfo'; +import { + createBooleanDefinition, + createNumberDefinition, + createObjectDefinition, + createStringDefinition, +} from '../metadata/definitionCreators'; + +const NullStringDefinition = createStringDefinition( + false /** isOptional */, + undefined /** value */, + true /** allowNull */ +); + +const BooleanDefinition = createBooleanDefinition(false /** isOptional */); + +const TableFormatMetadata = createObjectDefinition>( + { + topBorderColor: NullStringDefinition, + bottomBorderColor: NullStringDefinition, + verticalBorderColor: NullStringDefinition, + hasHeaderRow: BooleanDefinition, + headerRowColor: NullStringDefinition, + hasFirstColumn: BooleanDefinition, + hasBandedColumns: BooleanDefinition, + hasBandedRows: BooleanDefinition, + bgColorEven: NullStringDefinition, + bgColorOdd: NullStringDefinition, + tableBorderFormat: createNumberDefinition( + false /** isOptional */, + undefined /* value */, + 0 /* first table border format */, + 7 /* last table border format */ + ), + keepCellShade: BooleanDefinition, + }, + false /* isOptional */, + true /** allowNull */ +); /** * @internal @@ -9,8 +46,7 @@ const TABLE_STYLE_INFO = 'roosterTableInfo'; * @param table The table that has the info */ export function getTableFormatInfo(table: HTMLTableElement) { - const obj = safeParseJSON(table?.dataset[TABLE_STYLE_INFO]); - return checkIfTableFormatIsValid(obj) ? obj : null; + return getMetadata(table, TableFormatMetadata); } /** @@ -21,74 +57,6 @@ export function getTableFormatInfo(table: HTMLTableElement) { */ export function saveTableInfo(table: HTMLTableElement, format: TableFormat) { if (table && format) { - table.dataset[TABLE_STYLE_INFO] = JSON.stringify(format); - } -} - -function checkIfTableFormatIsValid(format: any): format is Required { - if (!format) { - return false; - } - const { - topBorderColor, - verticalBorderColor, - bottomBorderColor, - bgColorOdd, - bgColorEven, - hasBandedColumns, - hasBandedRows, - hasFirstColumn, - hasHeaderRow, - tableBorderFormat, - } = format; - const colorsValues = [ - topBorderColor, - verticalBorderColor, - bottomBorderColor, - bgColorOdd, - bgColorEven, - ]; - const stateValues = [hasBandedColumns, hasBandedRows, hasFirstColumn, hasHeaderRow]; - - if ( - colorsValues.some(key => !isAValidColor(key)) || - stateValues.some(key => !isBoolean(key)) || - !isAValidTableBorderType(tableBorderFormat) - ) { - return false; - } - - return true; -} - -function isAValidColor(color: any) { - if (color === null || color === undefined || typeof color === 'string') { - return true; - } - return false; -} - -function isBoolean(a: any) { - if (typeof a === 'boolean') { - return true; - } - return false; -} - -function isAValidTableBorderType(border: any) { - if (-1 < border && border < 8) { - return true; - } - return false; -} - -function safeParseJSON(json: string | undefined): any { - if (!json) { - return null; - } - try { - return JSON.parse(json); - } catch { - return null; + setMetadata(table, format, TableFormatMetadata); } } diff --git a/packages/roosterjs-editor-dom/lib/utils/shouldSkipNode.ts b/packages/roosterjs-editor-dom/lib/utils/shouldSkipNode.ts index d4421048a24..4e3dfd4ecd4 100644 --- a/packages/roosterjs-editor-dom/lib/utils/shouldSkipNode.ts +++ b/packages/roosterjs-editor-dom/lib/utils/shouldSkipNode.ts @@ -2,7 +2,7 @@ import getTagOfNode from './getTagOfNode'; import { getComputedStyle } from './getComputedStyles'; import { NodeType } from 'roosterjs-editor-types'; -const CRLF = /^[\r\n]+$/gm; +const CRLF = /^[\r\n]+$/g; const CRLF_SPACE = /[\t\r\n\u0020\u200B]/gm; // We should only find new line, real space or ZeroWidthSpace (TAB, %20, but not  ) /** diff --git a/packages/roosterjs-editor-dom/test/inlineElements/applyTextStyleTest.ts b/packages/roosterjs-editor-dom/test/inlineElements/applyTextStyleTest.ts index b39e0efec96..4aea114d618 100644 --- a/packages/roosterjs-editor-dom/test/inlineElements/applyTextStyleTest.ts +++ b/packages/roosterjs-editor-dom/test/inlineElements/applyTextStyleTest.ts @@ -186,6 +186,22 @@ describe('applyTextStyle()', () => { ); }); + it('applyTextStyle() text node with SUP/SUB', () => { + let div = document.createElement('DIV'); + div.innerHTML = 'test1test2test3test4test5'; + let start = new Position(div, PositionType.Begin).normalize().move(2); + let end = new Position(div, PositionType.End).normalize().move(-2); + applyTextStyle( + div, + (node, isInnerNode) => (node.style.color = isInnerNode ? '' : 'red'), + start, + end + ); + expect(div.innerHTML).toBe( + 'test1test2test3test4test5' + ); + }); + it('applyTextStyle() text node with double span', () => { let div = document.createElement('DIV'); div.innerHTML = 'text'; diff --git a/packages/roosterjs-editor-dom/test/metadata/definitionCreatorsTest.ts b/packages/roosterjs-editor-dom/test/metadata/definitionCreatorsTest.ts new file mode 100644 index 00000000000..3a3b6fb451f --- /dev/null +++ b/packages/roosterjs-editor-dom/test/metadata/definitionCreatorsTest.ts @@ -0,0 +1,160 @@ +import { Definition, DefinitionType, ObjectPropertyDefinition } from 'roosterjs-editor-types'; +import { + createNumberDefinition, + createBooleanDefinition, + createStringDefinition, + createArrayDefinition, + createObjectDefinition, +} from '../../lib/metadata/definitionCreators'; + +describe('createNumberDefinition', () => { + it('normal case', () => { + const def = createNumberDefinition(); + expect(def).toEqual({ + type: DefinitionType.Number, + isOptional: undefined, + value: undefined, + maxValue: undefined, + minValue: undefined, + allowNull: undefined, + }); + }); + + it('full case', () => { + const def = createNumberDefinition(true, 2, 1, 3); + expect(def).toEqual({ + type: DefinitionType.Number, + isOptional: true, + value: 2, + minValue: 1, + maxValue: 3, + allowNull: undefined, + }); + }); +}); + +describe('createBooleanDefinition', () => { + it('normal case', () => { + const def = createBooleanDefinition(); + expect(def).toEqual({ + type: DefinitionType.Boolean, + isOptional: undefined, + value: undefined, + allowNull: undefined, + }); + }); + + it('full case', () => { + const def = createBooleanDefinition(true, false); + expect(def).toEqual({ + type: DefinitionType.Boolean, + isOptional: true, + value: false, + allowNull: undefined, + }); + }); +}); + +describe('createStringDefinition', () => { + it('normal case', () => { + const def = createStringDefinition(); + expect(def).toEqual({ + type: DefinitionType.String, + isOptional: undefined, + value: undefined, + allowNull: undefined, + }); + }); + + it('optional case', () => { + const def = createStringDefinition(true, 'test'); + expect(def).toEqual({ + type: DefinitionType.String, + isOptional: true, + value: 'test', + allowNull: undefined, + }); + }); + + it('full case', () => { + const def = createStringDefinition(true, 'test', true); + expect(def).toEqual({ + type: DefinitionType.String, + isOptional: true, + value: 'test', + allowNull: true, + }); + }); +}); + +describe('createArrayDefinition', () => { + const itemDef: Definition = { + type: DefinitionType.Number, + }; + + it('normal case', () => { + const def = createArrayDefinition(itemDef); + expect(def).toEqual({ + type: DefinitionType.Array, + itemDef, + isOptional: undefined, + minLength: undefined, + maxLength: undefined, + allowNull: undefined, + }); + }); + + it('full case', () => { + const def = createArrayDefinition(itemDef, true, 1, 3); + expect(def).toEqual({ + type: DefinitionType.Array, + isOptional: true, + itemDef, + minLength: 1, + maxLength: 3, + allowNull: undefined, + }); + }); +}); + +interface TestType { + x: number; + y: string; +} + +describe('createObjectDefinition', () => { + const propertyDef: ObjectPropertyDefinition = { + x: { type: DefinitionType.Number }, + y: { type: DefinitionType.String }, + }; + + it('normal case', () => { + const def = createObjectDefinition(propertyDef); + expect(def).toEqual({ + type: DefinitionType.Object, + propertyDef, + isOptional: undefined, + allowNull: undefined, + }); + }); + + it('isOptional case', () => { + const def = createObjectDefinition(propertyDef, true); + expect(def).toEqual({ + type: DefinitionType.Object, + isOptional: true, + propertyDef, + allowNull: undefined, + }); + }); + + it('full case', () => { + const def = createObjectDefinition(propertyDef, true, true); + expect(def).toEqual({ + type: DefinitionType.Object, + isOptional: true, + propertyDef, + allowNull: true, + }); + }); +}); diff --git a/packages/roosterjs-editor-dom/test/metadata/metadataTest.ts b/packages/roosterjs-editor-dom/test/metadata/metadataTest.ts new file mode 100644 index 00000000000..3dc99b78b8a --- /dev/null +++ b/packages/roosterjs-editor-dom/test/metadata/metadataTest.ts @@ -0,0 +1,124 @@ +import { CustomizeDefinition, DefinitionType } from 'roosterjs-editor-types'; +import { getMetadata, removeMetadata, setMetadata } from '../../lib/metadata/metadata'; + +describe('metadata', () => { + it('getMetadata gets a valid metadata', () => { + const validators = { + trueValidator: (input: any) => true, + falseValidator: (input: any) => false, + }; + const obj = { x: 1, y: 'test' }; + const validatorSpy = spyOn(validators, 'trueValidator').and.callThrough(); + const div = document.createElement('div'); + div.innerHTML = 'test'; + const node = div.firstChild as HTMLElement; + node.setAttribute('data-editing-info', JSON.stringify(obj)); + const def: CustomizeDefinition = { + type: DefinitionType.Customize, + validator: validators.trueValidator, + }; + + const metadata = getMetadata(node, def); + + expect(validatorSpy).toHaveBeenCalled(); + expect(metadata).toEqual(obj); + }); + + it('getMetadata gets an invalid metadata', () => { + const validators = { + trueValidator: (input: any) => true, + falseValidator: (input: any) => false, + }; + const obj = { x: 1, y: 'test' }; + const validatorSpy = spyOn(validators, 'falseValidator').and.callThrough(); + const div = document.createElement('div'); + div.innerHTML = 'test'; + const node = div.firstChild as HTMLElement; + + node.setAttribute('data-editing-info', JSON.stringify(obj)); + + const def: CustomizeDefinition = { + type: DefinitionType.Customize, + validator: validators.falseValidator, + }; + + const metadata = getMetadata(node, def); + + expect(validatorSpy).toHaveBeenCalled(); + expect(metadata).toBeNull(); + }); + + it('getMetadata gets an invalid metadata and return default value', () => { + const validators = { + trueValidator: (input: any) => true, + falseValidator: (input: any) => false, + }; + const obj = { x: 1, y: 'test' }; + const validatorSpy = spyOn(validators, 'falseValidator').and.callThrough(); + const div = document.createElement('div'); + div.innerHTML = 'test'; + const node = div.firstChild as HTMLElement; + + node.setAttribute('data-editing-info', JSON.stringify(obj)); + + const def: CustomizeDefinition = { + type: DefinitionType.Customize, + validator: validators.falseValidator, + }; + + const metadata = getMetadata(node, def, obj); + + expect(validatorSpy).toHaveBeenCalled(); + expect(metadata).toBe(obj); + }); + + it('setMetadata sets a valid metadata', () => { + const validators = { + trueValidator: (input: any) => true, + falseValidator: (input: any) => false, + }; + const obj = { x: 1, y: 'test' }; + const validatorSpy = spyOn(validators, 'trueValidator').and.callThrough(); + const node = document.createElement('div'); + const def: CustomizeDefinition = { + type: DefinitionType.Customize, + validator: validators.trueValidator, + }; + const result = setMetadata(node, obj, def); + + expect(validatorSpy).toHaveBeenCalled(); + expect(result).toBeTrue(); + expect(node.outerHTML).toBe( + '
' + ); + }); + + it('setMetadata sets an invalid metadata', () => { + const validators = { + trueValidator: (input: any) => true, + falseValidator: (input: any) => false, + }; + const validatorSpy = spyOn(validators, 'falseValidator').and.callThrough(); + const node = document.createElement('div'); + const def: CustomizeDefinition = { + type: DefinitionType.Customize, + validator: validators.falseValidator, + }; + const obj = { x: 1, y: 'test' }; + const result = setMetadata(node, obj, def); + + expect(validatorSpy).toHaveBeenCalled(); + expect(result).toBeFalse(); + expect(node.outerHTML).toBe('
'); + }); +}); + +describe('removeMetadata', () => { + it('removeElement', () => { + const obj = { x: 1, y: 'test' }; + const div = document.createElement('div'); + div.setAttribute('data-editing-info', JSON.stringify(obj)); + removeMetadata(div); + expect(div.outerHTML).toBe('
'); + }); +}); diff --git a/packages/roosterjs-editor-dom/test/metadata/validateTest.ts b/packages/roosterjs-editor-dom/test/metadata/validateTest.ts new file mode 100644 index 00000000000..32689ec700b --- /dev/null +++ b/packages/roosterjs-editor-dom/test/metadata/validateTest.ts @@ -0,0 +1,295 @@ +import validate from '../../lib/metadata/validate'; +import { + Definition, + ObjectPropertyDefinition, + PluginEventType, + DefinitionType, +} from 'roosterjs-editor-types'; +import { + createArrayDefinition, + createBooleanDefinition, + createNumberDefinition, + createObjectDefinition, + createStringDefinition, +} from '../../lib/metadata/definitionCreators'; + +describe('validate', () => { + function runTestInternal(input: any, def: Definition, result: boolean) { + expect(validate(input, def)).toBe(result); + } + + function runNumberTest( + input: any, + resultForRequired: boolean, + resultForOptional: boolean, + value?: number + ) { + const requiredDef = createNumberDefinition(false, value); + const optionalDef = createNumberDefinition(true, value); + runTestInternal(input, requiredDef, resultForRequired); + runTestInternal(input, optionalDef, resultForOptional); + } + + function runStringTest( + input: any, + resultForRequired: boolean, + resultForOptional: boolean, + value?: string + ) { + const requiredDef = createStringDefinition(false, value); + const optionalDef = createStringDefinition(true, value); + runTestInternal(input, requiredDef, resultForRequired); + runTestInternal(input, optionalDef, resultForOptional); + } + + function runBooleanTest( + input: any, + resultForRequired: boolean, + resultForOptional: boolean, + value?: boolean + ) { + const requiredDef = createBooleanDefinition(false, value); + const optionalDef = createBooleanDefinition(true, value); + runTestInternal(input, requiredDef, resultForRequired); + runTestInternal(input, optionalDef, resultForOptional); + } + + function runArrayTest( + input: any, + resultForRequired: boolean, + resultForOptional: boolean, + minLength?: number, + maxLength?: number + ) { + const itemDef = createNumberDefinition(); + const requiredDef = createArrayDefinition(itemDef, false, minLength, maxLength); + const optionalDef = createArrayDefinition(itemDef, true, minLength, maxLength); + runTestInternal(input, requiredDef, resultForRequired); + runTestInternal(input, optionalDef, resultForOptional); + } + + interface TestObj { + x: number; + y: string; + } + + function runObjectTest(input: any, resultForRequired: boolean, resultForOptional: boolean) { + const propertyDef: ObjectPropertyDefinition = { + x: createNumberDefinition(), + y: createStringDefinition(), + }; + const requiredDef = createObjectDefinition(propertyDef, false); + const optionalDef = createObjectDefinition(propertyDef, true); + runTestInternal(input, requiredDef, resultForRequired); + runTestInternal(input, optionalDef, resultForOptional); + } + + it('Validate number', () => { + runNumberTest(0, true, true); + runNumberTest(0, true, true, 0); + runNumberTest(0, true, true, 0.000001); + runNumberTest(0, false, false, 1); + runNumberTest(undefined, false, true); + runNumberTest(null, false, false); + runNumberTest('test', false, false); + runNumberTest(PluginEventType.EditorReady, true, true); + runNumberTest(true, false, false); + runNumberTest({}, false, false); + runNumberTest([], false, false); + runNumberTest({ x: 1 }, false, false); + runNumberTest([1], false, false); + }); + + it('Validate string', () => { + runStringTest('test', true, true); + runStringTest('test', true, true, 'test'); + runStringTest('test', false, false, 'test1'); + runStringTest(undefined, false, true); + runStringTest(null, false, false); + runStringTest(1, false, false); + runStringTest(PluginEventType.EditorReady, false, false); + runStringTest(true, false, false); + runStringTest({}, false, false); + runStringTest([], false, false); + runStringTest({ x: 1 }, false, false); + runStringTest([1], false, false); + }); + + it('Validate boolean', () => { + runBooleanTest(true, true, true); + runBooleanTest(true, true, true, true); + runBooleanTest(true, false, false, false); + runBooleanTest(undefined, false, true); + runBooleanTest(null, false, false); + runBooleanTest(1, false, false); + runBooleanTest(PluginEventType.EditorReady, false, false); + runBooleanTest('test', false, false); + runBooleanTest({}, false, false); + runBooleanTest([], false, false); + runBooleanTest({ x: 1 }, false, false); + runBooleanTest([1], false, false); + }); + + it('Validate array', () => { + runArrayTest([], true, true); + runArrayTest(undefined, false, true); + runArrayTest([1, 2, 3], true, true); + runArrayTest([1, 2, 'test'], false, false); + runArrayTest([null], false, false); + runArrayTest([1, 2], true, true, 0, 3); + runArrayTest([1, 2], false, false, 3); + runArrayTest([1, 2], false, false, undefined, 1); + runArrayTest(true, false, false); + runArrayTest(null, false, false); + runArrayTest(1, false, false); + runArrayTest(PluginEventType.EditorReady, false, false); + runArrayTest('test', false, false); + runArrayTest({}, false, false); + runArrayTest({ x: 1 }, false, false); + }); + + it('Validate object', () => { + runObjectTest({ x: 1, y: 'test' }, true, true); + runObjectTest(undefined, false, true); + runObjectTest({ x: 1, y: 2 }, false, false); + runObjectTest({ x: 1 }, false, false); + runObjectTest([], false, false); + runObjectTest(true, false, false); + runObjectTest(1, false, false); + runObjectTest('test', false, false); + }); + + interface TestObj2 { + a: number[]; + b?: TestObj; + } + + it('Validate object 2', () => { + const def: Definition = createObjectDefinition({ + a: createArrayDefinition(createNumberDefinition()), + b: createObjectDefinition( + { + x: createNumberDefinition(), + y: createStringDefinition(), + }, + true + ), + }); + + expect( + validate( + { + a: [1, 2, 3], + b: { + x: 1, + y: 'test', + }, + }, + def + ) + ).toBeTrue(); + expect( + validate( + { + a: [1, 2, 3], + }, + def + ) + ).toBeTrue(); + expect( + validate( + { + a: [1, 2, 3, 'test'], + b: { + x: 1, + y: 'test', + }, + }, + def + ) + ).toBeFalse(); + expect( + validate( + { + a: null, + b: { + x: 1, + y: 'test', + }, + }, + def + ) + ).toBeFalse(); + expect( + validate( + { + a: [1, 2, 3], + b: { + x: 1, + y: 'test', + }, + c: 0, + }, + def + ) + ).toBeTrue(); + }); +}); + +describe('Validate customize', () => { + it('Validate true', () => { + const validators = { + trueValidator: (input: any) => true, + falseValidator: (input: any) => false, + }; + const trueSpy = spyOn(validators, 'trueValidator').and.callThrough(); + const input = {}; + const result = validate( + {}, + { type: DefinitionType.Customize, validator: validators.trueValidator } + ); + + expect(result).toBe(true); + expect(trueSpy).toHaveBeenCalledWith(input); + }); + + it('Validate false', () => { + const validators = { + trueValidator: (input: any) => true, + falseValidator: (input: any) => false, + }; + const falseSpy = spyOn(validators, 'falseValidator').and.callThrough(); + const input = {}; + const result = validate( + {}, + { type: DefinitionType.Customize, validator: validators.falseValidator } + ); + + expect(result).toBe(false); + expect(falseSpy).toHaveBeenCalledWith(input); + }); + + it('Validate object', () => { + interface TestObj { + name: string; + value: number; + } + const validators = { + trueValidator: (input: any) => true, + falseValidator: (input: any) => false, + }; + + const trueSpy = spyOn(validators, 'trueValidator').and.callThrough(); + const def = createObjectDefinition({ + name: createStringDefinition(), + value: { + type: DefinitionType.Customize, + validator: validators.trueValidator, + }, + }); + const result = validate({ name: 'test', value: 1 }, def); + + expect(result).toBeTrue(); + expect(trueSpy).toHaveBeenCalledWith(1); + }); +}); diff --git a/packages/roosterjs-editor-dom/test/selections/deleteSelectedContentTest.ts b/packages/roosterjs-editor-dom/test/selections/deleteSelectedContentTest.ts index 6a394b48c23..56061c287c1 100644 --- a/packages/roosterjs-editor-dom/test/selections/deleteSelectedContentTest.ts +++ b/packages/roosterjs-editor-dom/test/selections/deleteSelectedContentTest.ts @@ -81,14 +81,14 @@ describe('deleteSelectedContent', () => { ); }); - it('Whole talbe 1', () => { + it('Whole table 1', () => { runTest( 'aa
line1line2
line3line4
bb', 'aabb' ); }); - it('Whole talbe 2', () => { + it('Whole table 2', () => { // TODO: the result contains separated continuous text object at root // Selection path gives wrong result. Need to revisit here runTest( diff --git a/packages/roosterjs-editor-dom/test/table/applyTableFormatTest.ts b/packages/roosterjs-editor-dom/test/table/applyTableFormatTest.ts index 0083b34454e..b47e227c3f5 100644 --- a/packages/roosterjs-editor-dom/test/table/applyTableFormatTest.ts +++ b/packages/roosterjs-editor-dom/test/table/applyTableFormatTest.ts @@ -20,9 +20,10 @@ const format: Required = { describe('applyTableFormat', () => { let table = - '









'; + '









'; let expectedTableChrome = - '









'; + '









'; + let div = document.createElement('div'); document.body.appendChild(div); const id = 'id1'; diff --git a/packages/roosterjs-editor-dom/test/table/tableFormatInfoTest.ts b/packages/roosterjs-editor-dom/test/table/tableFormatInfoTest.ts index 4c8de881ceb..a17314cb93b 100644 --- a/packages/roosterjs-editor-dom/test/table/tableFormatInfoTest.ts +++ b/packages/roosterjs-editor-dom/test/table/tableFormatInfoTest.ts @@ -2,7 +2,7 @@ import VTable from '../../lib/table/VTable'; import { getTableFormatInfo, saveTableInfo } from '../../lib/table/tableFormatInfo'; import { TableFormat } from 'roosterjs-editor-types'; -const TABLE_STYLE_INFO = 'roosterTableInfo'; +const TABLE_STYLE_INFO = 'editingInfo'; const format: TableFormat = { topBorderColor: '#0C64C0', bottomBorderColor: '#0C64C0', diff --git a/packages/roosterjs-editor-dom/test/utils/shouldSkipNodeTest.ts b/packages/roosterjs-editor-dom/test/utils/shouldSkipNodeTest.ts index 1f0f534f8f3..63d7169a661 100644 --- a/packages/roosterjs-editor-dom/test/utils/shouldSkipNodeTest.ts +++ b/packages/roosterjs-editor-dom/test/utils/shouldSkipNodeTest.ts @@ -30,6 +30,17 @@ describe('shouldSkipNode, shouldSkipNode()', () => { expect(shouldSkip).toBe(true); }); + it('CRLF+text textNode', () => { + // Arrange + let node = document.createTextNode('\r\ntest'); + + // Act + let shouldSkip = shouldSkipNode(node); + + // Assert + expect(shouldSkip).toBe(false); + }); + it('DisplayNone', () => { // Arrange let node = DomTestHelper.createElementFromContent( diff --git a/packages/roosterjs-editor-plugins/lib/plugins/ContentEdit/features/textFeatures.ts b/packages/roosterjs-editor-plugins/lib/plugins/ContentEdit/features/textFeatures.ts index 444dc2dcb40..b1a86f667d1 100644 --- a/packages/roosterjs-editor-plugins/lib/plugins/ContentEdit/features/textFeatures.ts +++ b/packages/roosterjs-editor-plugins/lib/plugins/ContentEdit/features/textFeatures.ts @@ -129,6 +129,10 @@ function shouldSetIndentation(editor: IEditor, range: Range): boolean { const firstBlock = editor.getBlockElementAtNode(startPosition.node); const lastBlock = editor.getBlockElementAtNode(endPosition.node); + if (!firstBlock || !lastBlock) { + return false; + } + if (!firstBlock.equals(lastBlock)) { //If the selections has more than one block, we indent all the blocks in the selection return true; diff --git a/packages/roosterjs-editor-plugins/lib/plugins/Paste/Paste.ts b/packages/roosterjs-editor-plugins/lib/plugins/Paste/Paste.ts index ae3b4c4d2d6..1201f7c1f03 100644 --- a/packages/roosterjs-editor-plugins/lib/plugins/Paste/Paste.ts +++ b/packages/roosterjs-editor-plugins/lib/plugins/Paste/Paste.ts @@ -4,6 +4,7 @@ import convertPastedContentFromExcel from './excelConverter/convertPastedContent import convertPastedContentFromPowerPoint from './pptConverter/convertPastedContentFromPowerPoint'; import convertPastedContentFromWord from './wordConverter/convertPastedContentFromWord'; import handleLineMerge from './lineMerge/handleLineMerge'; +import sanitizeHtmlColorsFromPastedContent from './sanitizeHtmlColorsFromPastedContent/sanitizeHtmlColorsFromPastedContent'; import { toArray } from 'roosterjs-editor-dom'; import { EditorPlugin, @@ -114,6 +115,8 @@ export default class Paste implements EditorPlugin { handleLineMerge(fragment); } + sanitizeHtmlColorsFromPastedContent(sanitizingOption); + // Replace unknown tags with SPAN sanitizingOption.unknownTagReplacement = this.unknownTagReplacement; } diff --git a/packages/roosterjs-editor-plugins/lib/plugins/Paste/sanitizeHtmlColorsFromPastedContent/deprecatedColorList.ts b/packages/roosterjs-editor-plugins/lib/plugins/Paste/sanitizeHtmlColorsFromPastedContent/deprecatedColorList.ts new file mode 100644 index 00000000000..05588a27f19 --- /dev/null +++ b/packages/roosterjs-editor-plugins/lib/plugins/Paste/sanitizeHtmlColorsFromPastedContent/deprecatedColorList.ts @@ -0,0 +1,30 @@ +/** + * @internal + * List of deprecated colors that should be removed + */ + +export const DeprecatedColorList: string[] = [ + 'activeborder', + 'activecaption', + 'appworkspace', + 'background', + 'buttonhighlight', + 'buttonshadow', + 'captiontext', + 'inactiveborder', + 'inactivecaption', + 'inactivecaptiontext', + 'infobackground', + 'infotext', + 'menu', + 'menutext', + 'scrollbar', + 'threeddarkshadow', + 'threedface', + 'threedhighlight', + 'threedlightshadow', + 'threedfhadow', + 'window', + 'windowframe', + 'windowtext', +]; diff --git a/packages/roosterjs-editor-plugins/lib/plugins/Paste/sanitizeHtmlColorsFromPastedContent/sanitizeHtmlColorsFromPastedContent.ts b/packages/roosterjs-editor-plugins/lib/plugins/Paste/sanitizeHtmlColorsFromPastedContent/sanitizeHtmlColorsFromPastedContent.ts new file mode 100644 index 00000000000..e3da37455a6 --- /dev/null +++ b/packages/roosterjs-editor-plugins/lib/plugins/Paste/sanitizeHtmlColorsFromPastedContent/sanitizeHtmlColorsFromPastedContent.ts @@ -0,0 +1,20 @@ +import { chainSanitizerCallback } from 'roosterjs-editor-dom'; +import { DeprecatedColorList } from './deprecatedColorList'; +import { HtmlSanitizerOptions } from 'roosterjs-editor-types'; + +/** + * @internal + * Remove the deprecated colors from pasted content + * @param sanitizingOption the sanitizingOption of BeforePasteEvent + * */ +export default function sanitizeHtmlColorsFromPastedContent( + sanitizingOption: Required +) { + ['color', 'background-color'].forEach(property => { + chainSanitizerCallback( + sanitizingOption.cssStyleCallbacks, + property, + (value: string) => DeprecatedColorList.indexOf(value) < 0 + ); + }); +} diff --git a/packages/roosterjs-editor-plugins/lib/plugins/TableCellSelection/TableCellSelection.ts b/packages/roosterjs-editor-plugins/lib/plugins/TableCellSelection/TableCellSelection.ts index 56cfc5b58be..2068cd8325e 100644 --- a/packages/roosterjs-editor-plugins/lib/plugins/TableCellSelection/TableCellSelection.ts +++ b/packages/roosterjs-editor-plugins/lib/plugins/TableCellSelection/TableCellSelection.ts @@ -31,7 +31,13 @@ import { const TABLE_CELL_SELECTOR = 'td,th'; const LEFT_CLICK = 1; const RIGHT_CLICK = 3; - +const IGNORE_KEY_UP_KEYS = [ + Keys.SHIFT, + Keys.ALT, + Keys.META_LEFT, + Keys.CTRL_LEFT, + Keys.PRINT_SCREEN, +]; /** * TableCellSelectionPlugin help highlight table cells */ @@ -211,8 +217,7 @@ export default class TableCellSelection implements EditorPlugin { if (shiftKey) { if (!this.firstTarget) { const pos = this.editor.getFocusedPosition(); - - const cell = getCellAtCursor(this.editor, pos.node); + const cell = pos && getCellAtCursor(this.editor, pos.node); this.firstTarget = this.firstTarget || cell; } @@ -223,7 +228,10 @@ export default class TableCellSelection implements EditorPlugin { } this.editor.runAsync(editor => { const pos = editor.getFocusedPosition(); - this.setData(this.tableSelection ? this.lastTarget : pos.node); + const newTarget = this.tableSelection ? this.lastTarget : pos?.node; + if (newTarget) { + this.setData(newTarget); + } if (this.firstTable! == this.targetTable!) { if (!this.shouldConvertToTableSelection() && !this.tableSelection) { @@ -242,8 +250,14 @@ export default class TableCellSelection implements EditorPlugin { } private handleKeyUpEvent(event: PluginKeyUpEvent) { - const { shiftKey, which } = event.rawEvent; - if (!shiftKey && which != Keys.SHIFT && this.firstTarget && !this.preventKeyUp) { + const { shiftKey, which, ctrlKey } = event.rawEvent; + if ( + !shiftKey && + !ctrlKey && + this.firstTarget && + !this.preventKeyUp && + IGNORE_KEY_UP_KEYS.indexOf(which) == -1 + ) { this.clearState(); } this.preventKeyUp = false; diff --git a/packages/roosterjs-editor-plugins/test/paste/sanitizeHtmlColorsFromPastedContentTest.ts b/packages/roosterjs-editor-plugins/test/paste/sanitizeHtmlColorsFromPastedContentTest.ts new file mode 100644 index 00000000000..b123c36566b --- /dev/null +++ b/packages/roosterjs-editor-plugins/test/paste/sanitizeHtmlColorsFromPastedContentTest.ts @@ -0,0 +1,90 @@ +import sanitizeHtmlColorsFromPastedContent from '../../lib/plugins/Paste/sanitizeHtmlColorsFromPastedContent/sanitizeHtmlColorsFromPastedContent'; +import { HtmlSanitizer } from 'roosterjs-editor-dom'; +import { + BeforePasteEvent, + SanitizeHtmlOptions, + PluginEventType, + ClipboardData, +} from 'roosterjs-editor-types'; + +describe('sanitizeHtmlColorsFromPastedContent', () => { + function callSanitizer(fragment: DocumentFragment, sanitizingOption: SanitizeHtmlOptions) { + const sanitizer = new HtmlSanitizer(sanitizingOption); + sanitizer.convertGlobalCssToInlineCss(fragment); + sanitizer.sanitize(fragment); + } + + function runTest(source: string, expected: string) { + const doc = new DOMParser().parseFromString(source, 'text/html'); + const fragment = doc.createDocumentFragment(); + while (doc.body.firstChild) { + fragment.appendChild(doc.body.firstChild); + } + + const event = createBeforePasteEventMock(fragment); + sanitizeHtmlColorsFromPastedContent(event.sanitizingOption); + callSanitizer(fragment, event.sanitizingOption); + + while (fragment.firstChild) { + doc.body.appendChild(fragment.firstChild); + } + + expect(doc.body.innerHTML).toBe(expected); + } + + it('sanitize on a div', () => { + runTest('
', '
'); + }); + + it('sanitize on a div', () => { + runTest( + '
', + '
' + ); + }); + + it('sanitize on a p', () => { + runTest( + '

', + '

' + ); + }); + + it('sanitize on nested elements', () => { + runTest( + '

', + '

' + ); + }); + + it('sanitize on nested elements with background color', () => { + runTest( + '

', + '

' + ); + }); +}); + +function createBeforePasteEventMock(fragment: DocumentFragment) { + return ({ + eventType: PluginEventType.BeforePaste, + clipboardData: {}, + fragment: fragment, + sanitizingOption: { + elementCallbacks: {}, + attributeCallbacks: {}, + cssStyleCallbacks: {}, + additionalTagReplacements: {}, + additionalAllowedAttributes: [], + additionalAllowedCssClasses: [], + additionalDefaultStyleValues: {}, + additionalGlobalStyleNodes: [], + additionalPredefinedCssForElement: {}, + preserveHtmlComments: false, + unknownTagReplacement: null, + }, + htmlBefore: '', + htmlAfter: '', + htmlAttributes: {}, + } as unknown) as BeforePasteEvent; +} diff --git a/packages/roosterjs-editor-plugins/test/paste/word/convertPastedContentFromWordTest.ts b/packages/roosterjs-editor-plugins/test/paste/word/convertPastedContentFromWordTest.ts index 4dba0a56f33..9581e982c01 100644 --- a/packages/roosterjs-editor-plugins/test/paste/word/convertPastedContentFromWordTest.ts +++ b/packages/roosterjs-editor-plugins/test/paste/word/convertPastedContentFromWordTest.ts @@ -1,7 +1,11 @@ import convertPastedContentFromWord from '../../../lib/plugins/Paste/wordConverter/convertPastedContentFromWord'; -import { BeforePasteEvent, SanitizeHtmlOptions } from 'roosterjs-editor-types'; -import { ClipboardData, PluginEventType } from '../../../../roosterjs/lib'; import { HtmlSanitizer, moveChildNodes } from 'roosterjs-editor-dom'; +import { + BeforePasteEvent, + SanitizeHtmlOptions, + PluginEventType, + ClipboardData, +} from 'roosterjs-editor-types'; describe('convertPastedContentFromWord', () => { function callSanitizer(fragment: DocumentFragment, sanitizingOption: SanitizeHtmlOptions) { diff --git a/packages/roosterjs-editor-types/lib/enum/DefinitionType.ts b/packages/roosterjs-editor-types/lib/enum/DefinitionType.ts new file mode 100644 index 00000000000..0a808a52732 --- /dev/null +++ b/packages/roosterjs-editor-types/lib/enum/DefinitionType.ts @@ -0,0 +1,34 @@ +/** + * Types of definitions, used by Definition type + */ +export const enum DefinitionType { + /** + * Boolean type definition, represents a boolean type value + */ + Boolean, + + /** + * Number type definition, represents a number type value + */ + Number, + + /** + * String type definition, represents a string type value + */ + String, + + /** + * Array type definition, represents an array with a given item type + */ + Array, + + /** + * Object type definition, represents an object with the given property types + */ + Object, + + /** + * Customize type definition, represents a customized type with a validator function + */ + Customize, +} diff --git a/packages/roosterjs-editor-types/lib/enum/Keys.ts b/packages/roosterjs-editor-types/lib/enum/Keys.ts index 2d1a6fa138c..17a0d4268d0 100644 --- a/packages/roosterjs-editor-types/lib/enum/Keys.ts +++ b/packages/roosterjs-editor-types/lib/enum/Keys.ts @@ -7,6 +7,8 @@ export const enum Keys { TAB = 9, ENTER = 13, SHIFT = 16, + CTRL_LEFT = 17, + ALT = 18, ESCAPE = 27, SPACE = 32, PAGEUP = 33, @@ -14,6 +16,7 @@ export const enum Keys { UP = 38, RIGHT = 39, DOWN = 40, + PRINT_SCREEN = 44, DELETE = 46, /** * @deprecated Just for backward compatibility @@ -25,6 +28,7 @@ export const enum Keys { U = 85, Y = 89, Z = 90, + META_LEFT = 91, COMMA = 188, DASH_UNDERSCORE = 189, PERIOD = 190, diff --git a/packages/roosterjs-editor-types/lib/enum/index.ts b/packages/roosterjs-editor-types/lib/enum/index.ts index 306240589bf..969eee5741b 100644 --- a/packages/roosterjs-editor-types/lib/enum/index.ts +++ b/packages/roosterjs-editor-types/lib/enum/index.ts @@ -29,3 +29,4 @@ export { PluginEventType } from './PluginEventType'; export { SelectionRangeTypes } from './SelectionRangeTypes'; export { NumberingListType } from './NumberingListType'; export { BulletListType } from './BulletListType'; +export { DefinitionType } from './DefinitionType'; diff --git a/packages/roosterjs-editor-types/lib/type/Definition.ts b/packages/roosterjs-editor-types/lib/type/Definition.ts new file mode 100644 index 00000000000..20756f7e6ad --- /dev/null +++ b/packages/roosterjs-editor-types/lib/type/Definition.ts @@ -0,0 +1,140 @@ +import { DefinitionType } from '../enum/DefinitionType'; +import type { CompatibleDefinitionType } from '../compatibleEnum/DefinitionType'; + +/** + * A type template to get item type of an array + */ +export type ArrayItemType = T extends (infer U)[] ? U : never; + +/** + * Base interface of property definition + */ +export interface DefinitionBase { + /** + * Type of this property + */ + type: T; + + /** + * Whether this property is optional + */ + isOptional?: boolean; + + /** + * Whether this property is allowed to be null + */ + allowNull?: boolean; +} + +/** + * String property definition. This definition can also be used for string based enum property + */ +export interface StringDefinition + extends DefinitionBase { + /** + * An optional value of this property. When specified, the given property must have exactly same value of this value + */ + value?: string; +} + +/** + * Number property definition. This definition can also be used for number based enum property + */ +export interface NumberDefinition + extends DefinitionBase { + /** + * An optional value of this property. When specified, the given property must have same value of this value + */ + value?: number; + + /** + * An optional minimum value of this property. When specified, the given property must be greater or equal to this value + */ + minValue?: number; + + /** + * An optional maximum value of this property. When specified, the given property must be less or equal to this value + */ + maxValue?: number; +} + +/** + * Boolean property definition + */ +export interface BooleanDefinition + extends DefinitionBase { + /** + * An optional value of this property. When specified, the given property must have same value of this value + */ + value?: boolean; +} + +/** + * Array property definition. + */ +export interface ArrayDefinition + extends DefinitionBase { + /** + * Definition of each item of this array. All items of the given array must have the same type. Otherwise, use CustomizeDefinition instead. + */ + itemDef: Definition>; + + /** + * An optional minimum length of this array. When specified, the given array must have at least this value of items + */ + minLength?: number; + + /** + * An optional maximum length of this array. When specified, the given array must have at most this value of items + */ + maxLength?: number; +} + +/** + * Object property definition type used by Object Definition + */ +export type ObjectPropertyDefinition = { + [Key in keyof T]: Definition; +}; + +/** + * Object property definition. + */ +export interface ObjectDefinition + extends DefinitionBase { + /** + * A key-value map to specify the definition of each possible property of this object + */ + propertyDef: ObjectPropertyDefinition; +} + +/** + * Customize property definition. When all other property definition type cannot satisfy your requirement, + * use this definition with a customized validator function to do property validation. + */ +export interface CustomizeDefinition + extends DefinitionBase { + /** + * The customized validator function to do customized validation + * @param input The value to validate + * @returns True means the given value is of the specified type, otherwise false + */ + validator: (input: any) => boolean; +} + +/** + * A combination of all definition types + */ +export type Definition = + | CustomizeDefinition + | (T extends any[] + ? ArrayDefinition + : T extends Record + ? ObjectDefinition + : T extends String + ? StringDefinition + : T extends Number + ? NumberDefinition + : T extends Boolean + ? BooleanDefinition + : never); diff --git a/packages/roosterjs-editor-types/lib/type/index.ts b/packages/roosterjs-editor-types/lib/type/index.ts index b501c9c8fed..9a6b8815f5f 100644 --- a/packages/roosterjs-editor-types/lib/type/index.ts +++ b/packages/roosterjs-editor-types/lib/type/index.ts @@ -11,3 +11,15 @@ export { export { DOMEventHandlerFunction, DOMEventHandlerObject, DOMEventHandler } from './domEventHandler'; export { TrustedHTMLHandler } from './TrustedHTMLHandler'; export { SizeTransformer } from './SizeTransformer'; +export { + ArrayItemType, + DefinitionBase, + StringDefinition, + NumberDefinition, + BooleanDefinition, + ArrayDefinition, + ObjectDefinition, + ObjectPropertyDefinition, + CustomizeDefinition, + Definition, +} from './Definition'; diff --git a/tools/buildTools/dts.js b/tools/buildTools/dts.js index 4495b9098d6..94d85c751c5 100644 --- a/tools/buildTools/dts.js +++ b/tools/buildTools/dts.js @@ -273,6 +273,10 @@ function parseImportFrom(content, currentFileName, queue, baseDir, projDir, exte return newContent.replace(regImportFrom, ''); } +function parseEmptyExport(content) { + return content.replace(/export \{\};/g, ''); +} + function process(baseDir, queue, index, projDir, externalHandler) { var item = queue[index]; var currentFileName = item.filename; @@ -286,10 +290,15 @@ function process(baseDir, queue, index, projDir, externalHandler) { content = parseImportFrom(content, currentFileName, queue, baseDir, projDir, externalHandler); // 3. Parse all the public elements - content = [parseClasses, parseFunctions, parseEnum, parseType, parseConst, parseExport].reduce( - (c, func) => func(c, item.elements), - content - ); + content = [ + parseClasses, + parseFunctions, + parseEnum, + parseType, + parseConst, + parseExport, + parseEmptyExport, + ].reduce((c, func) => func(c, item.elements), content); // 4. Remove single line comments content = content.replace(singleLineComment, '');