diff --git a/demo/scripts/controlsV2/demoButtons/setBulletedListStyleButton.ts b/demo/scripts/controlsV2/demoButtons/setBulletedListStyleButton.ts index ab585097120..97617c0c5c4 100644 --- a/demo/scripts/controlsV2/demoButtons/setBulletedListStyleButton.ts +++ b/demo/scripts/controlsV2/demoButtons/setBulletedListStyleButton.ts @@ -10,7 +10,6 @@ const dropDownMenuItems = { [BulletListType.LongArrow]: 'LongArrow', [BulletListType.UnfilledArrow]: 'UnfilledArrow', [BulletListType.Hyphen]: 'Hyphen', - [BulletListType.DoubleLongArrow]: 'DoubleLongArrow', [BulletListType.Circle]: 'Circle', }; diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts b/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts index a6d4c5af32a..1e9085c302b 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts +++ b/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts @@ -45,6 +45,8 @@ const initialState: OptionState = { autoHyphen: true, autoFraction: true, autoOrdinals: true, + autoMailto: true, + autoTel: true, }, markdownOptions: { bold: true, @@ -56,7 +58,7 @@ const initialState: OptionState = { handleTabKey: true, }, customReplacements: emojiReplacements, - experimentalFeatures: new Set(['PersistCache']), + experimentalFeatures: new Set(['PersistCache', 'HandleEnterKey']), }; export class EditorOptionsPlugin extends SidePanePluginImpl { diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/ExperimentalFeatures.tsx b/demo/scripts/controlsV2/sidePane/editorOptions/ExperimentalFeatures.tsx index da543da70c9..2bd26858972 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/ExperimentalFeatures.tsx +++ b/demo/scripts/controlsV2/sidePane/editorOptions/ExperimentalFeatures.tsx @@ -9,7 +9,13 @@ export interface DefaultFormatProps { export class ExperimentalFeatures extends React.Component { render() { - return this.renderFeature('PersistCache'); + return ( + <> + {this.renderFeature('PersistCache')} + {this.renderFeature('HandleEnterKey')} + {this.renderFeature('LegacyImageSelection')} + + ); } private renderFeature(featureName: ExperimentalFeature): JSX.Element { diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/Plugins.tsx b/demo/scripts/controlsV2/sidePane/editorOptions/Plugins.tsx index 86ebfe53fdf..d1ba83fee58 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/Plugins.tsx +++ b/demo/scripts/controlsV2/sidePane/editorOptions/Plugins.tsx @@ -110,6 +110,8 @@ export class Plugins extends PluginsBase { private autoHyphen = React.createRef(); private autoFraction = React.createRef(); private autoOrdinals = React.createRef(); + private autoTel = React.createRef(); + private autoMailto = React.createRef(); private markdownBold = React.createRef(); private markdownItalic = React.createRef(); private markdownStrikethrough = React.createRef(); @@ -166,6 +168,18 @@ export class Plugins extends PluginsBase { this.props.state.autoFormatOptions.autoOrdinals, (state, value) => (state.autoFormatOptions.autoOrdinals = value) )} + {this.renderCheckBox( + 'Telephone', + this.autoTel, + this.props.state.autoFormatOptions.autoTel, + (state, value) => (state.autoFormatOptions.autoTel = value) + )} + {this.renderCheckBox( + 'Email', + this.autoMailto, + this.props.state.autoFormatOptions.autoMailto, + (state, value) => (state.autoFormatOptions.autoMailto = value) + )} )} {this.renderPluginItem( diff --git a/package.json b/package.json index ab3e23aed45..9dab173dd0d 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "coverage-istanbul-loader": "3.0.5", "css-loader": "3.5.3", "detect-port": "^1.3.0", - "dompurify": "2.3.0", + "dompurify": "2.5.4", "eslint": "^8.50.0", "eslint-plugin-etc": "^2.0.3", "eslint-plugin-react": "^7.33.2", diff --git a/packages/roosterjs-content-model-api/lib/modelApi/block/setModelIndentation.ts b/packages/roosterjs-content-model-api/lib/modelApi/block/setModelIndentation.ts index 217a0d5f641..6a1626f3c07 100644 --- a/packages/roosterjs-content-model-api/lib/modelApi/block/setModelIndentation.ts +++ b/packages/roosterjs-content-model-api/lib/modelApi/block/setModelIndentation.ts @@ -66,10 +66,19 @@ export function setModelIndentation( //if block has only one level, there is not need to check if it is multilevel selection } else if (block.levels.length == 1 || !isMultilevelSelection(model, block, parent)) { if (isIndent) { - const lastLevel = block.levels[block.levels.length - 1]; + const threadIdx = thread.indexOf(block); + const previousItem = thread[threadIdx - 1]; + const nextItem = thread[threadIdx + 1]; + const levelLength = block.levels.length; + const lastLevel = block.levels[levelLength - 1]; const newLevel: ContentModelListLevel = createListLevel( lastLevel?.listType || 'UL', - lastLevel?.format + lastLevel?.format, + previousItem && previousItem.levels.length > levelLength + ? previousItem.levels[levelLength].dataset + : nextItem && nextItem.levels.length > levelLength + ? nextItem.levels[levelLength].dataset + : undefined ); updateListMetadata(newLevel, metadata => { diff --git a/packages/roosterjs-content-model-api/lib/modelApi/common/clearModelFormat.ts b/packages/roosterjs-content-model-api/lib/modelApi/common/clearModelFormat.ts index 04d8cff9d3e..5e1a551f6d0 100644 --- a/packages/roosterjs-content-model-api/lib/modelApi/common/clearModelFormat.ts +++ b/packages/roosterjs-content-model-api/lib/modelApi/common/clearModelFormat.ts @@ -33,7 +33,9 @@ export function clearModelFormat( blocksToClear: [ReadonlyContentModelBlockGroup[], ShallowMutableContentModelBlock][], segmentsToClear: ShallowMutableContentModelSegment[], tablesToClear: [ContentModelTable, boolean][] -) { +): boolean { + let pendingStructureChange = false; + iterateSelections( model, (path, tableContext, block, segments) => { @@ -75,14 +77,14 @@ export function clearModelFormat( blocksToClear.length == 1 ) { segmentsToClear.splice(0, segmentsToClear.length, ...adjustWordSelection(model, marker)); - clearListFormat(blocksToClear[0][0]); + pendingStructureChange = clearListFormat(blocksToClear[0][0]) || pendingStructureChange; } else if (blocksToClear.length > 1 || blocksToClear.some(x => isWholeBlockSelected(x[1]))) { // 2. If a full block or multiple blocks are selected, clear block format for (let i = blocksToClear.length - 1; i >= 0; i--) { const [path, block] = blocksToClear[i]; clearBlockFormat(path, block); - clearListFormat(path); + pendingStructureChange = clearListFormat(path) || pendingStructureChange; clearContainerFormat(path, block); } } @@ -92,6 +94,8 @@ export function clearModelFormat( // 4. Clear format for table if any createTablesFormat(tablesToClear); + + return pendingStructureChange; } function createTablesFormat(tablesToClear: [ContentModelTable, boolean][]) { @@ -191,6 +195,10 @@ function clearListFormat(path: ReadonlyContentModelBlockGroup[]) { if (listItem) { mutateBlock(listItem).levels = []; + + return true; + } else { + return false; } } diff --git a/packages/roosterjs-content-model-api/lib/publicApi/format/clearFormat.ts b/packages/roosterjs-content-model-api/lib/publicApi/format/clearFormat.ts index 5e8fa8a0a1a..05998818f3b 100644 --- a/packages/roosterjs-content-model-api/lib/publicApi/format/clearFormat.ts +++ b/packages/roosterjs-content-model-api/lib/publicApi/format/clearFormat.ts @@ -8,6 +8,8 @@ import type { ContentModelTable, } from 'roosterjs-content-model-types'; +const MAX_TRY = 3; + /** * Clear format of selection * @param editor The editor to clear format from @@ -17,17 +19,27 @@ export function clearFormat(editor: IEditor) { editor.formatContentModel( model => { - const blocksToClear: [ContentModelBlockGroup[], ContentModelBlock][] = []; - const segmentsToClear: ContentModelSegment[] = []; - const tablesToClear: [ContentModelTable, boolean][] = []; + let changed = false; + let needtoRun = true; + let triedTimes = 0; + + while (needtoRun && triedTimes++ < MAX_TRY) { + const blocksToClear: [ContentModelBlockGroup[], ContentModelBlock][] = []; + const segmentsToClear: ContentModelSegment[] = []; + const tablesToClear: [ContentModelTable, boolean][] = []; + + needtoRun = clearModelFormat(model, blocksToClear, segmentsToClear, tablesToClear); - clearModelFormat(model, blocksToClear, segmentsToClear, tablesToClear); + normalizeContentModel(model); - normalizeContentModel(model); + changed = + changed || + blocksToClear.length > 0 || + segmentsToClear.length > 0 || + tablesToClear.length > 0; + } - return ( - blocksToClear.length > 0 || segmentsToClear.length > 0 || tablesToClear.length > 0 - ); + return changed; }, { apiName: 'clearFormat', diff --git a/packages/roosterjs-content-model-api/test/modelApi/block/setModelIndentationTest.ts b/packages/roosterjs-content-model-api/test/modelApi/block/setModelIndentationTest.ts index 765ca3526e6..6bf1e233806 100644 --- a/packages/roosterjs-content-model-api/test/modelApi/block/setModelIndentationTest.ts +++ b/packages/roosterjs-content-model-api/test/modelApi/block/setModelIndentationTest.ts @@ -1,5 +1,5 @@ import * as getListAnnounceData from '../../../lib/modelApi/list/getListAnnounceData'; -import { FormatContentModelContext } from 'roosterjs-content-model-types'; +import { ContentModelDocument, FormatContentModelContext } from 'roosterjs-content-model-types'; import { setModelIndentation } from '../../../lib/modelApi/block/setModelIndentation'; import { createContentModelDocument, @@ -899,6 +899,303 @@ describe('indent', () => { }); expect(getListAnnounceDataSpy).not.toHaveBeenCalled(); }); + + it('Indent and follow previous item style', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [], + format: {}, + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: false, + format: {}, + }, + levels: [ + { format: {}, dataset: { a: 'b' }, listType: 'OL' }, + { format: {}, dataset: { a: 'c' }, listType: 'OL' }, + ], + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { segmentType: 'SelectionMarker', format: {}, isSelected: true }, + ], + }, + ], + format: {}, + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: false, + format: {}, + }, + levels: [{ format: {}, dataset: { a: 'b' }, listType: 'OL' }], + }, + ], + }; + + setModelIndentation(model, 'indent'); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [], + format: {}, + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: false, + format: {}, + }, + levels: [ + { format: {}, dataset: { a: 'b' }, listType: 'OL' }, + { format: {}, dataset: { a: 'c' }, listType: 'OL' }, + ], + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { segmentType: 'SelectionMarker', format: {}, isSelected: true }, + ], + }, + ], + format: {}, + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: false, + format: {}, + }, + levels: [ + { format: {}, dataset: { a: 'b' }, listType: 'OL' }, + { + format: {}, + dataset: { a: 'c', editingInfo: '{"applyListStyleFromLevel":true}' }, + listType: 'OL', + }, + ], + }, + ], + }); + }); + + it('Indent and follow next item style', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [], + format: {}, + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: false, + format: {}, + }, + levels: [{ format: {}, dataset: { a: 'b' }, listType: 'OL' }], + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { segmentType: 'SelectionMarker', format: {}, isSelected: true }, + ], + }, + ], + format: {}, + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: false, + format: {}, + }, + levels: [{ format: {}, dataset: { a: 'b' }, listType: 'OL' }], + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [], + format: {}, + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: false, + format: {}, + }, + levels: [ + { format: {}, dataset: { a: 'b' }, listType: 'OL' }, + { format: {}, dataset: { a: 'c' }, listType: 'OL' }, + ], + }, + ], + }; + + setModelIndentation(model, 'indent'); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [], + format: {}, + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: false, + format: {}, + }, + levels: [{ format: {}, dataset: { a: 'b' }, listType: 'OL' }], + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { segmentType: 'SelectionMarker', format: {}, isSelected: true }, + ], + }, + ], + format: {}, + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: false, + format: {}, + }, + levels: [ + { format: {}, dataset: { a: 'b' }, listType: 'OL' }, + { + format: {}, + dataset: { a: 'c', editingInfo: '{"applyListStyleFromLevel":true}' }, + listType: 'OL', + }, + ], + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [], + format: {}, + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: false, + format: {}, + }, + levels: [ + { format: {}, dataset: { a: 'b' }, listType: 'OL' }, + { format: {}, dataset: { a: 'c' }, listType: 'OL' }, + ], + }, + ], + }); + }); + + it('Indent, no style to follow', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [], + format: {}, + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: false, + format: {}, + }, + levels: [{ format: {}, dataset: { a: 'b' }, listType: 'OL' }], + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { segmentType: 'SelectionMarker', format: {}, isSelected: true }, + ], + }, + ], + format: {}, + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: false, + format: {}, + }, + levels: [{ format: {}, dataset: { a: 'b' }, listType: 'OL' }], + }, + ], + }; + + setModelIndentation(model, 'indent'); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [], + format: {}, + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: false, + format: {}, + }, + levels: [{ format: {}, dataset: { a: 'b' }, listType: 'OL' }], + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { segmentType: 'SelectionMarker', format: {}, isSelected: true }, + ], + }, + ], + format: {}, + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: false, + format: {}, + }, + levels: [ + { format: {}, dataset: { a: 'b' }, listType: 'OL' }, + { + format: {}, + dataset: { editingInfo: '{"applyListStyleFromLevel":true}' }, + listType: 'OL', + }, + ], + }, + ], + }); + }); }); describe('outdent', () => { diff --git a/packages/roosterjs-content-model-api/test/modelApi/common/clearModelFormatTest.ts b/packages/roosterjs-content-model-api/test/modelApi/common/clearModelFormatTest.ts index 4eb36438c3e..932cb19fe05 100644 --- a/packages/roosterjs-content-model-api/test/modelApi/common/clearModelFormatTest.ts +++ b/packages/roosterjs-content-model-api/test/modelApi/common/clearModelFormatTest.ts @@ -102,12 +102,13 @@ describe('clearModelFormat', () => { const segments: any[] = []; const tables: any[] = []; - clearModelFormat(model, [], [], []); + const result = clearModelFormat(model, [], [], []); expect(model).toEqual({ blockGroupType: 'Document', blocks: [] }); expect(blocks).toEqual([]); expect(segments).toEqual([]); expect(tables).toEqual([]); + expect(result).toBeFalse(); }); it('Model without selection', () => { @@ -122,7 +123,7 @@ describe('clearModelFormat', () => { para.segments.push(text); model.blocks.push(para); - clearModelFormat(model, blocks, segments, tables); + const result = clearModelFormat(model, blocks, segments, tables); expect(model).toEqual({ blockGroupType: 'Document', @@ -144,6 +145,7 @@ describe('clearModelFormat', () => { expect(blocks).toEqual([]); expect(segments).toEqual([]); expect(tables).toEqual([]); + expect(result).toBeFalse(); }); it('Model with text selection', () => { @@ -161,7 +163,7 @@ describe('clearModelFormat', () => { const segments: any[] = []; const tables: any[] = []; - clearModelFormat(model, blocks, segments, tables); + const result = clearModelFormat(model, blocks, segments, tables); expect(model).toEqual({ blockGroupType: 'Document', @@ -188,6 +190,7 @@ describe('clearModelFormat', () => { expect(blocks).toEqual([[[model], para]]); expect(segments).toEqual([text2]); expect(tables).toEqual([]); + expect(result).toBeFalse(); }); it('Model with link', () => { @@ -211,7 +214,7 @@ describe('clearModelFormat', () => { const segments: any[] = []; const tables: any[] = []; - clearModelFormat(model, blocks, segments, tables); + const result = clearModelFormat(model, blocks, segments, tables); expect(model).toEqual({ blockGroupType: 'Document', @@ -239,6 +242,7 @@ describe('clearModelFormat', () => { expect(blocks).toEqual([[[model], para]]); expect(segments).toEqual([text1]); expect(tables).toEqual([]); + expect(result).toBeFalse(); }); it('Model with code', () => { @@ -260,7 +264,7 @@ describe('clearModelFormat', () => { const segments: any[] = []; const tables: any[] = []; - clearModelFormat(model, blocks, segments, tables); + const result = clearModelFormat(model, blocks, segments, tables); expect(model).toEqual({ blockGroupType: 'Document', @@ -282,6 +286,7 @@ describe('clearModelFormat', () => { expect(blocks).toEqual([[[model], para]]); expect(segments).toEqual([text1]); expect(tables).toEqual([]); + expect(result).toBeFalse(); }); it('Model with text selection in whole paragraph', () => { @@ -300,7 +305,7 @@ describe('clearModelFormat', () => { const segments: any[] = []; const tables: any[] = []; - clearModelFormat(model, blocks, segments, tables); + const result = clearModelFormat(model, blocks, segments, tables); expect(model).toEqual({ blockGroupType: 'Document', @@ -328,6 +333,7 @@ describe('clearModelFormat', () => { expect(blocks).toEqual([[[model], para]]); expect(segments).toEqual([text1, text2]); expect(tables).toEqual([]); + expect(result).toBeFalse(); }); it('Model with collapsed selection', () => { @@ -344,7 +350,7 @@ describe('clearModelFormat', () => { const segments: any[] = []; const tables: any[] = []; - clearModelFormat(model, blocks, segments, tables); + const result = clearModelFormat(model, blocks, segments, tables); expect(model).toEqual({ blockGroupType: 'Document', @@ -375,6 +381,7 @@ describe('clearModelFormat', () => { expect(blocks).toEqual([[[model], para]]); expect(segments).toEqual([marker]); expect(tables).toEqual([]); + expect(result).toBeFalse(); }); it('Model with collapsed selection inside word', () => { @@ -392,7 +399,7 @@ describe('clearModelFormat', () => { const segments: any[] = []; const tables: any[] = []; - clearModelFormat(model, blocks, segments, tables); + const result = clearModelFormat(model, blocks, segments, tables); expect(model).toEqual({ blockGroupType: 'Document', @@ -447,6 +454,7 @@ describe('clearModelFormat', () => { text3, ]); expect(tables).toEqual([]); + expect(result).toBeFalse(); }); it('Model with collapsed selection under list', () => { @@ -463,7 +471,7 @@ describe('clearModelFormat', () => { const segments: any[] = []; const tables: any[] = []; - clearModelFormat(model, blocks, segments, tables); + const result = clearModelFormat(model, blocks, segments, tables); expect(model).toEqual({ blockGroupType: 'Document', @@ -499,6 +507,7 @@ describe('clearModelFormat', () => { expect(blocks).toEqual([[[list, model], para]]); expect(segments).toEqual([marker]); expect(tables).toEqual([]); + expect(result).toBeTrue(); }); it('Model with divider selection', () => { @@ -516,7 +525,7 @@ describe('clearModelFormat', () => { const segments: any[] = []; const tables: any[] = []; - clearModelFormat(model, blocks, segments, tables); + const result = clearModelFormat(model, blocks, segments, tables); expect(model).toEqual({ blockGroupType: 'Document', @@ -540,6 +549,7 @@ describe('clearModelFormat', () => { expect(blocks).toEqual([[[model], divider]]); expect(segments).toEqual([]); expect(tables).toEqual([]); + expect(result).toBeFalse(); }); it('Model with selection under list', () => { @@ -558,7 +568,7 @@ describe('clearModelFormat', () => { const segments: any[] = []; const tables: any[] = []; - clearModelFormat(model, blocks, segments, tables); + const result = clearModelFormat(model, blocks, segments, tables); expect(model).toEqual({ blockGroupType: 'Document', @@ -600,6 +610,7 @@ describe('clearModelFormat', () => { }, ]); expect(tables).toEqual([]); + expect(result).toBeTrue(); }); it('Model with selection under list, has defaultSegmentFormat', () => { @@ -620,7 +631,7 @@ describe('clearModelFormat', () => { const segments: any[] = []; const tables: any[] = []; - clearModelFormat(model, blocks, segments, tables); + const result = clearModelFormat(model, blocks, segments, tables); expect(model).toEqual({ blockGroupType: 'Document', @@ -662,6 +673,7 @@ describe('clearModelFormat', () => { expect(blocks).toEqual([[[list, model], para]]); expect(segments).toEqual([text]); expect(tables).toEqual([]); + expect(result).toBeTrue(); }); it('Model with selection under quote', () => { @@ -688,7 +700,7 @@ describe('clearModelFormat', () => { const segments: any[] = []; const tables: any[] = []; - clearModelFormat(model, blocks, segments, tables); + const result = clearModelFormat(model, blocks, segments, tables); expect(model).toEqual({ blockGroupType: 'Document', @@ -774,6 +786,7 @@ describe('clearModelFormat', () => { ]); expect(segments).toEqual([text3, text4]); expect(tables).toEqual([]); + expect(result).toBeFalse(); }); it('Model with selection under table', () => { @@ -796,7 +809,7 @@ describe('clearModelFormat', () => { const segments: any[] = []; const tables: any[] = []; - clearModelFormat(model, blocks, segments, tables); + const result = clearModelFormat(model, blocks, segments, tables); expect(model).toEqual({ blockGroupType: 'Document', @@ -855,5 +868,6 @@ describe('clearModelFormat', () => { expect(blocks).toEqual([]); expect(segments).toEqual([]); expect(tables).toEqual([[table, true]]); + expect(result).toBeFalse(); }); }); diff --git a/packages/roosterjs-content-model-api/test/publicApi/format/clearFormatTest.ts b/packages/roosterjs-content-model-api/test/publicApi/format/clearFormatTest.ts index 80958b94380..489b81fa813 100644 --- a/packages/roosterjs-content-model-api/test/publicApi/format/clearFormatTest.ts +++ b/packages/roosterjs-content-model-api/test/publicApi/format/clearFormatTest.ts @@ -33,4 +33,76 @@ describe('clearFormat', () => { expect(normalizeContentModel.normalizeContentModel).toHaveBeenCalledTimes(1); expect(normalizeContentModel.normalizeContentModel).toHaveBeenCalledWith(model); }); + + it('Clear format with list under quote', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + tagName: 'blockquote', + blockType: 'BlockGroup', + format: {}, + blockGroupType: 'FormatContainer', + blocks: [ + { + formatHolder: { + isSelected: false, + segmentType: 'SelectionMarker', + format: {}, + }, + levels: [{ listType: 'OL', format: {}, dataset: {} }], + blockType: 'BlockGroup', + format: {}, + blockGroupType: 'ListItem', + blocks: [ + { + segments: [ + { + text: 'test', + segmentType: 'Text', + isSelected: true, + format: {}, + }, + ], + blockType: 'Paragraph', + format: {}, + }, + ], + }, + ], + }, + ], + }; + const formatContentModelSpy = jasmine + .createSpy('formatContentModel') + .and.callFake((callback: ContentModelFormatter, options: FormatContentModelOptions) => { + expect(options.apiName).toEqual('clearFormat'); + callback(model, { newEntities: [], deletedEntities: [], newImages: [] }); + }); + const editor = ({ + focus: () => {}, + formatContentModel: formatContentModelSpy, + } as any) as IEditor; + + clearFormat(editor); + + expect(formatContentModelSpy).toHaveBeenCalledTimes(1); + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + segments: [ + { + text: 'test', + segmentType: 'Text', + isSelected: true, + format: {}, + }, + ], + blockType: 'Paragraph', + format: {}, + }, + ], + }); + }); }); diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/copyPaste/CopyPastePlugin.ts b/packages/roosterjs-content-model-core/lib/corePlugin/copyPaste/CopyPastePlugin.ts index 79965ccdcb2..b0fd0c791d9 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/copyPaste/CopyPastePlugin.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/copyPaste/CopyPastePlugin.ts @@ -1,8 +1,8 @@ import { addRangeToSelection } from '../../coreApi/setDOMSelection/addRangeToSelection'; -import { deleteEmptyList } from './deleteEmptyList'; +import { adjustImageSelectionOnSafari } from './utils/adjustImageSelectionOnSafari'; +import { deleteEmptyList } from './utils/deleteEmptyList'; import { onCreateCopyEntityNode } from '../../override/pasteCopyBlockEntityParser'; import { paste } from '../../command/paste/paste'; - import { ChangeSource, contentModelToDom, @@ -110,6 +110,8 @@ class CopyPastePlugin implements PluginWithState { const doc = this.editor.getDocument(); const selection = this.editor.getDOMSelection(); + adjustImageSelectionOnSafari(this.editor, selection); + if (selection && (selection.type != 'range' || !selection.range.collapsed)) { const pasteModel = this.editor.getContentModelCopy('disconnected'); diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/copyPaste/utils/adjustImageSelectionOnSafari.ts b/packages/roosterjs-content-model-core/lib/corePlugin/copyPaste/utils/adjustImageSelectionOnSafari.ts new file mode 100644 index 00000000000..e0c37d9e3da --- /dev/null +++ b/packages/roosterjs-content-model-core/lib/corePlugin/copyPaste/utils/adjustImageSelectionOnSafari.ts @@ -0,0 +1,18 @@ +import type { IEditor, DOMSelection } from 'roosterjs-content-model-types'; + +/** + * @internal + * Adjust Image selection, so the copy by keyboard does not remove image selection. + */ +export function adjustImageSelectionOnSafari(editor: IEditor, selection: DOMSelection | null) { + if (editor.getEnvironment().isSafari && selection?.type == 'image') { + const range = new Range(); + range.setStartBefore(selection.image); + range.setEndAfter(selection.image); + editor.setDOMSelection({ + range, + type: 'range', + isReverted: false, + }); + } +} diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/copyPaste/deleteEmptyList.ts b/packages/roosterjs-content-model-core/lib/corePlugin/copyPaste/utils/deleteEmptyList.ts similarity index 100% rename from packages/roosterjs-content-model-core/lib/corePlugin/copyPaste/deleteEmptyList.ts rename to packages/roosterjs-content-model-core/lib/corePlugin/copyPaste/utils/deleteEmptyList.ts diff --git a/packages/roosterjs-content-model-core/lib/editor/core/DOMHelperImpl.ts b/packages/roosterjs-content-model-core/lib/editor/core/DOMHelperImpl.ts index a4b9a49474a..67eb876eae8 100644 --- a/packages/roosterjs-content-model-core/lib/editor/core/DOMHelperImpl.ts +++ b/packages/roosterjs-content-model-core/lib/editor/core/DOMHelperImpl.ts @@ -81,6 +81,13 @@ class DOMHelperImpl implements DOMHelper { const paddingRight = parseValueWithUnit(style?.paddingRight); return this.contentDiv.clientWidth - (paddingLeft + paddingRight); } + + /** + * Get a deep cloned root element + */ + getClonedRoot(): HTMLElement { + return this.contentDiv.cloneNode(true /*deep*/) as HTMLElement; + } } /** diff --git a/packages/roosterjs-content-model-core/test/corePlugin/copyPaste/utils/adjustImageSelectionOnSafariTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/copyPaste/utils/adjustImageSelectionOnSafariTest.ts new file mode 100644 index 00000000000..6089bc4d604 --- /dev/null +++ b/packages/roosterjs-content-model-core/test/corePlugin/copyPaste/utils/adjustImageSelectionOnSafariTest.ts @@ -0,0 +1,70 @@ +import { adjustImageSelectionOnSafari } from '../../../../lib/corePlugin/copyPaste/utils/adjustImageSelectionOnSafari'; +import type { DOMSelection, IEditor } from 'roosterjs-content-model-types'; + +describe('adjustImageSelectionOnSafari', () => { + let getEnvironmentSpy: jasmine.Spy; + let setDOMSelectionSpy: jasmine.Spy; + let editor: IEditor; + + beforeEach(() => { + getEnvironmentSpy = jasmine.createSpy('getEnvironment'); + setDOMSelectionSpy = jasmine.createSpy('setDOMSelection'); + editor = ({ + getEnvironment: getEnvironmentSpy, + setDOMSelection: setDOMSelectionSpy, + } as any) as IEditor; + }); + + it('should adjustSelection', () => { + getEnvironmentSpy.and.returnValue({ + isSafari: true, + }); + const image = document.createElement('img'); + document.body.appendChild(image); + const selection: DOMSelection = { + type: 'image', + image: image, + }; + const range = new Range(); + range.setStartBefore(image); + range.setEndAfter(image); + + adjustImageSelectionOnSafari(editor, selection); + expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); + expect(setDOMSelectionSpy).toHaveBeenCalledWith({ + range: range, + type: 'range', + isReverted: false, + }); + + document.body.removeChild(image); + }); + + it('should not adjustSelection - it is not safari', () => { + getEnvironmentSpy.and.returnValue({ + isSafari: false, + }); + const image = new Image(); + const selection: DOMSelection = { + type: 'image', + image: image, + }; + + adjustImageSelectionOnSafari(editor, selection); + expect(setDOMSelectionSpy).not.toHaveBeenCalled(); + }); + + it('should not adjustSelection - it is not image', () => { + getEnvironmentSpy.and.returnValue({ + isSafari: true, + }); + const selection: DOMSelection = { + type: 'range', + range: new Range(), + isReverted: false, + }; + + adjustImageSelectionOnSafari(editor, selection); + expect(setDOMSelectionSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/roosterjs-content-model-core/test/corePlugin/copyPaste/deleteEmptyListTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/copyPaste/utils/deleteEmptyListTest.ts similarity index 99% rename from packages/roosterjs-content-model-core/test/corePlugin/copyPaste/deleteEmptyListTest.ts rename to packages/roosterjs-content-model-core/test/corePlugin/copyPaste/utils/deleteEmptyListTest.ts index 5c13f319b09..51d3e7c7355 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/copyPaste/deleteEmptyListTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/copyPaste/utils/deleteEmptyListTest.ts @@ -1,5 +1,5 @@ import { ContentModelBlockGroup, ContentModelSelectionMarker } from 'roosterjs-content-model-types'; -import { deleteEmptyList } from '../../../lib/corePlugin/copyPaste/deleteEmptyList'; +import { deleteEmptyList } from '../../../../lib/corePlugin/copyPaste/utils/deleteEmptyList'; import { deleteSelection } from 'roosterjs-content-model-dom/lib/modelApi/editing/deleteSelection'; import { createContentModelDocument, diff --git a/packages/roosterjs-content-model-core/test/editor/core/DOMHelperImplTest.ts b/packages/roosterjs-content-model-core/test/editor/core/DOMHelperImplTest.ts index fb490373ed3..2481f57a73a 100644 --- a/packages/roosterjs-content-model-core/test/editor/core/DOMHelperImplTest.ts +++ b/packages/roosterjs-content-model-core/test/editor/core/DOMHelperImplTest.ts @@ -350,4 +350,20 @@ describe('DOMHelperImpl', () => { expect(domHelper.getClientWidth()).toBe(1000); }); }); + + describe('getClonedRoot', () => { + it('getClonedRoot', () => { + const mockedClone = 'CLONE' as any; + const cloneSpy = jasmine.createSpy('cloneSpy').and.returnValue(mockedClone); + const mockedDiv: HTMLElement = { + cloneNode: cloneSpy, + } as any; + const domHelper = createDOMHelper(mockedDiv); + + const result = domHelper.getClonedRoot(); + + expect(result).toBe(mockedClone); + expect(cloneSpy).toHaveBeenCalledWith(true); + }); + }); }); diff --git a/packages/roosterjs-content-model-dom/lib/domToModel/processors/brProcessor.ts b/packages/roosterjs-content-model-dom/lib/domToModel/processors/brProcessor.ts index f5677434c77..eb060bdd3b9 100644 --- a/packages/roosterjs-content-model-dom/lib/domToModel/processors/brProcessor.ts +++ b/packages/roosterjs-content-model-dom/lib/domToModel/processors/brProcessor.ts @@ -1,5 +1,6 @@ import { addSegment } from '../../modelApi/common/addSegment'; import { createBr } from '../../modelApi/creators/createBr'; +import { getRegularSelectionOffsets } from '../utils/getRegularSelectionOffsets'; import type { ElementProcessor } from 'roosterjs-content-model-types'; /** @@ -7,11 +8,21 @@ import type { ElementProcessor } from 'roosterjs-content-model-types'; */ export const brProcessor: ElementProcessor = (group, element, context) => { const br = createBr(context.segmentFormat); + const [start, end] = getRegularSelectionOffsets(context, element); + + if (start >= 0) { + context.isInSelection = true; + } if (context.isInSelection) { br.isSelected = true; } const paragraph = addSegment(group, br, context.blockFormat); + + if (end >= 0) { + context.isInSelection = false; + } + context.domIndexer?.onSegment(element, paragraph, [br]); }; diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/editing/mergeModel.ts b/packages/roosterjs-content-model-dom/lib/modelApi/editing/mergeModel.ts index 3ea33badf72..08497fea2a2 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/editing/mergeModel.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/editing/mergeModel.ts @@ -50,20 +50,22 @@ export function mergeModel( const insertPosition = options?.insertPosition ?? deleteSelection(target, [], context).insertPoint; - if (options?.addParagraphAfterMergedContent) { + const { addParagraphAfterMergedContent, mergeFormat, mergeTable } = options || {}; + + if (addParagraphAfterMergedContent && !mergeTable) { const { paragraph, marker } = insertPosition || {}; const newPara = createParagraph(false /* isImplicit */, paragraph?.format, marker?.format); addBlock(source, newPara); } if (insertPosition) { - if (options?.mergeFormat && options.mergeFormat != 'none') { + if (mergeFormat && mergeFormat != 'none') { const newFormat: ContentModelSegmentFormat = { ...(target.format || {}), ...insertPosition.marker.format, }; - applyDefaultFormat(source, newFormat, options?.mergeFormat); + applyDefaultFormat(source, newFormat, mergeFormat); } for (let i = 0; i < source.blocks.length; i++) { @@ -84,8 +86,8 @@ export function mergeModel( break; case 'Table': - if (source.blocks.length == 1 && options?.mergeTable) { - mergeTable(insertPosition, block, source); + if (source.blocks.length == 1 && mergeTable) { + mergeTables(insertPosition, block, source); } else { insertBlock(insertPosition, block); } @@ -176,7 +178,7 @@ function mergeParagraph( } } -function mergeTable( +function mergeTables( markerPosition: InsertPoint, newTable: ContentModelTable, source: ContentModelDocument diff --git a/packages/roosterjs-content-model-dom/test/domToModel/processors/brProcessorTest.ts b/packages/roosterjs-content-model-dom/test/domToModel/processors/brProcessorTest.ts index f92da141de7..9ba29b9601b 100644 --- a/packages/roosterjs-content-model-dom/test/domToModel/processors/brProcessorTest.ts +++ b/packages/roosterjs-content-model-dom/test/domToModel/processors/brProcessorTest.ts @@ -100,4 +100,74 @@ describe('brProcessor', () => { }); expect(onSegmentSpy).toHaveBeenCalledWith(br, paragraphModel, [brModel]); }); + + it('Selection starts in BR', () => { + const doc = createContentModelDocument(); + const div = document.createElement('div'); + const br = document.createElement('br'); + const range = document.createRange(); + + div.appendChild(br); + range.setStart(br, 0); + range.setEnd(div, 1); + context.selection = { + type: 'range', + range: range, + isReverted: false, + }; + + brProcessor(doc, br, context); + + const brModel: ContentModelBr = { + segmentType: 'Br', + format: {}, + isSelected: true, + }; + const paragraphModel: ContentModelParagraph = { + blockType: 'Paragraph', + isImplicit: true, + segments: [brModel], + format: {}, + }; + + expect(doc).toEqual({ + blockGroupType: 'Document', + blocks: [paragraphModel], + }); + expect(context.isInSelection).toBeTrue(); + }); + + it('Selection ends in BR', () => { + const doc = createContentModelDocument(); + const br = document.createElement('br'); + const range = document.createRange(); + + range.setEnd(br, 0); + context.selection = { + type: 'range', + range: range, + isReverted: false, + }; + context.isInSelection = true; + + brProcessor(doc, br, context); + + const brModel: ContentModelBr = { + segmentType: 'Br', + format: {}, + isSelected: true, + }; + const paragraphModel: ContentModelParagraph = { + blockType: 'Paragraph', + isImplicit: true, + segments: [brModel], + format: {}, + }; + + expect(doc).toEqual({ + blockGroupType: 'Document', + blocks: [paragraphModel], + }); + expect(context.isInSelection).toBeFalse(); + }); }); diff --git a/packages/roosterjs-content-model-dom/test/modelApi/editing/mergeModelTest.ts b/packages/roosterjs-content-model-dom/test/modelApi/editing/mergeModelTest.ts index f6e6b58e40b..6d55c59b8d7 100644 --- a/packages/roosterjs-content-model-dom/test/modelApi/editing/mergeModelTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelApi/editing/mergeModelTest.ts @@ -1507,6 +1507,157 @@ describe('mergeModel', () => { }); }); + it('table to table, merge table 4, mergeTable and addParagraphAfterMergedContent should be noop', () => { + const majorModel = createContentModelDocument(); + const sourceModel = createContentModelDocument(); + + const para1 = createParagraph(); + const text1 = createText('test1'); + const cell01 = createTableCell(false, false, false, { backgroundColor: '01' }); + const cell02 = createTableCell(false, false, false, { backgroundColor: '02' }); + const cell11 = createTableCell(false, false, false, { backgroundColor: '11' }); + const cell12 = createTableCell(false, false, false, { backgroundColor: '12' }); + const cell21 = createTableCell(false, false, false, { backgroundColor: '21' }); + const cell22 = createTableCell(false, false, false, { backgroundColor: '22' }); + const cell31 = createTableCell(false, false, false, { backgroundColor: '31' }); + const cell32 = createTableCell(false, false, false, { backgroundColor: '32' }); + const table1 = createTable(4); + + para1.segments.push(text1); + text1.isSelected = true; + cell12.blocks.push(para1); + table1.rows = [ + { format: {}, height: 0, cells: [cell01, cell02] }, + { format: {}, height: 0, cells: [cell11, cell12] }, + { format: {}, height: 0, cells: [cell21, cell22] }, + { format: {}, height: 0, cells: [cell31, cell32] }, + ]; + + majorModel.blocks.push(table1); + + const newPara1 = createParagraph(); + const newText1 = createText('newText1'); + const newCell11 = createTableCell(false, false, false, { backgroundColor: 'n11' }); + const newCell12 = createTableCell(false, false, false, { backgroundColor: 'n12' }); + const newCell21 = createTableCell(false, false, false, { backgroundColor: 'n21' }); + const newCell22 = createTableCell(false, false, false, { backgroundColor: 'n22' }); + const newTable1 = createTable(2); + + newPara1.segments.push(newText1); + newCell12.blocks.push(newPara1); + newTable1.rows = [ + { format: {}, height: 0, cells: [newCell11, newCell12] }, + { format: {}, height: 0, cells: [newCell21, newCell22] }, + ]; + + sourceModel.blocks.push(newTable1); + + spyOn(applyTableFormat, 'applyTableFormat'); + spyOn(normalizeTable, 'normalizeTable'); + + const result = mergeModel( + majorModel, + sourceModel, + { newEntities: [], deletedEntities: [], newImages: [] }, + { + mergeTable: true, + addParagraphAfterMergedContent: true, + } + ); + + const marker: ContentModelSelectionMarker = { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }; + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [marker], + format: {}, + isImplicit: true, + }; + const tableCell: ContentModelTableCell = { + blockGroupType: 'TableCell', + blocks: [paragraph], + format: { + backgroundColor: 'n11', + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }; + const table: ContentModelTable = { + blockType: 'Table', + rows: [ + { + format: {}, + height: 0, + cells: [ + cell01, + cell02, + { + blockGroupType: 'TableCell', + blocks: [], + format: { + backgroundColor: '02', + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + { + format: {}, + height: 0, + cells: [cell11, tableCell, newCell12], + }, + { format: {}, height: 0, cells: [cell21, newCell21, newCell22] }, + { + format: {}, + height: 0, + cells: [ + cell31, + cell32, + { + blockGroupType: 'TableCell', + blocks: [], + format: { + backgroundColor: '32', + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + ], + format: {}, + widths: [], + dataset: {}, + }; + + expect(normalizeTable.normalizeTable).toHaveBeenCalledTimes(1); + expect(majorModel).toEqual({ + blockGroupType: 'Document', + blocks: [table], + }); + expect(result).toEqual({ + marker, + paragraph, + path: [tableCell, majorModel], + tableContext: { + table, + rowIndex: 1, + colIndex: 1, + isWholeTableSelected: false, + }, + }); + }); + it('Use customized insert position', () => { const majorModel = createContentModelDocument(); const sourceModel = createContentModelDocument(); @@ -3870,4 +4021,35 @@ describe('mergeModel', () => { ], }); }); + + it('Merge model with addParagraphAfterMergedContent and mergeTable, addParagraphAfterMergedContent should be noop', () => { + const source = createContentModelDocument(); + const para = createParagraph(); + para.segments.push(createText('Merge')); + source.blocks.push(para); + + const target = createContentModelDocument(); + const paraTarget = createParagraph(); + paraTarget.segments.push(createSelectionMarker()); + target.blocks.push(paraTarget); + + mergeModel(target, source, undefined, { + addParagraphAfterMergedContent: true, + mergeTable: true, + }); + + expect(target).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { segmentType: 'Text', text: 'Merge', format: {} }, + { segmentType: 'SelectionMarker', isSelected: true, format: {} }, + ], + format: {}, + }, + ], + }); + }); }); diff --git a/packages/roosterjs-content-model-plugins/lib/autoFormat/AutoFormatPlugin.ts b/packages/roosterjs-content-model-plugins/lib/autoFormat/AutoFormatPlugin.ts index b0936bdf511..81fb2695c0e 100644 --- a/packages/roosterjs-content-model-plugins/lib/autoFormat/AutoFormatPlugin.ts +++ b/packages/roosterjs-content-model-plugins/lib/autoFormat/AutoFormatPlugin.ts @@ -7,6 +7,7 @@ import { transformFraction } from './numbers/transformFraction'; import { transformHyphen } from './hyphen/transformHyphen'; import { transformOrdinals } from './numbers/transformOrdinals'; import { unlink } from './link/unlink'; +import type { AutoFormatOptions } from './interface/AutoFormatOptions'; import type { ContentChangedEvent, EditorInputEvent, @@ -17,46 +18,6 @@ import type { PluginEvent, } from 'roosterjs-content-model-types'; -/** - * Options to customize the Content Model Auto Format Plugin - */ -export type AutoFormatOptions = { - /** - * When true, after type *, ->, -, --, => , —, > and space key a type of bullet list will be triggered. @default true - */ - autoBullet?: boolean; - - /** - * When true, after type 1, A, a, i, I followed by ., ), - or between () and space key a type of numbering list will be triggered. @default true - */ - autoNumbering?: boolean; - - /** - * When press backspace before a link, remove the hyperlink - */ - autoUnlink?: boolean; - - /** - * When paste content, create hyperlink for the pasted link - */ - autoLink?: boolean; - - /** - * Transform -- into hyphen, if typed between two words - */ - autoHyphen?: boolean; - - /** - * Transform 1/2, 1/4, 3/4 into fraction character - */ - autoFraction?: boolean; - - /** - * Transform ordinal numbers into superscript - */ - autoOrdinals?: boolean; -}; - /** * @internal */ @@ -80,11 +41,13 @@ export class AutoFormatPlugin implements EditorPlugin { * @param options An optional parameter that takes in an object of type AutoFormatOptions, which includes the following properties: * - autoBullet: A boolean that enables or disables automatic bullet list formatting. Defaults to false. * - autoNumbering: A boolean that enables or disables automatic numbering formatting. Defaults to false. - * - autoLink: A boolean that enables or disables automatic hyperlink creation when pasting or typing content. Defaults to false. - * - autoUnlink: A boolean that enables or disables automatic hyperlink removal when pressing backspace. Defaults to false. * - autoHyphen: A boolean that enables or disables automatic hyphen transformation. Defaults to false. * - autoFraction: A boolean that enables or disables automatic fraction transformation. Defaults to false. * - autoOrdinals: A boolean that enables or disables automatic ordinal number transformation. Defaults to false. + * - autoLink: A boolean that enables or disables automatic hyperlink url address creation when pasting or typing content. Defaults to false. + * - autoUnlink: A boolean that enables or disables automatic hyperlink removal when pressing backspace. Defaults to false. + * - autoTel: A boolean that enables or disables automatic hyperlink telephone numbers transformation. Defaults to false. + * - autoMailto: A boolean that enables or disables automatic hyperlink email address transformation. Defaults to false. */ constructor(private options: AutoFormatOptions = DefaultOptions) {} @@ -161,6 +124,8 @@ export class AutoFormatPlugin implements EditorPlugin { autoHyphen, autoFraction, autoOrdinals, + autoMailto, + autoTel, } = this.options; let shouldHyphen = false; let shouldLink = false; @@ -178,11 +143,16 @@ export class AutoFormatPlugin implements EditorPlugin { ); } - if (autoLink) { + if (autoLink || autoTel || autoMailto) { shouldLink = createLinkAfterSpace( previousSegment, paragraph, - context + context, + { + autoLink, + autoTel, + autoMailto, + } ); } @@ -243,9 +213,13 @@ export class AutoFormatPlugin implements EditorPlugin { } private handleContentChangedEvent(editor: IEditor, event: ContentChangedEvent) { - const { autoLink } = this.options; - if (event.source == 'Paste' && autoLink) { - createLink(editor); + const { autoLink, autoTel, autoMailto } = this.options; + if (event.source == 'Paste' && (autoLink || autoTel || autoMailto)) { + createLink(editor, { + autoLink, + autoTel, + autoMailto, + }); } } } diff --git a/packages/roosterjs-content-model-plugins/lib/autoFormat/interface/AutoFormatOptions.ts b/packages/roosterjs-content-model-plugins/lib/autoFormat/interface/AutoFormatOptions.ts new file mode 100644 index 00000000000..50682210fac --- /dev/null +++ b/packages/roosterjs-content-model-plugins/lib/autoFormat/interface/AutoFormatOptions.ts @@ -0,0 +1,31 @@ +import type { AutoLinkOptions } from './AutoLinkOptions'; + +/** + * Options to customize the Content Model Auto Format Plugin + */ +export interface AutoFormatOptions extends AutoLinkOptions { + /** + * When true, after type *, ->, -, --, => , —, > and space key a type of bullet list will be triggered. + */ + autoBullet?: boolean; + + /** + * When true, after type 1, A, a, i, I followed by ., ), - or between () and space key a type of numbering list will be triggered. + */ + autoNumbering?: boolean; + + /** + * Transform -- into hyphen, if typed between two words + */ + autoHyphen?: boolean; + + /** + * Transform 1/2, 1/4, 3/4 into fraction character + */ + autoFraction?: boolean; + + /** + * Transform ordinal numbers into superscript + */ + autoOrdinals?: boolean; +} diff --git a/packages/roosterjs-content-model-plugins/lib/autoFormat/interface/AutoLinkOptions.ts b/packages/roosterjs-content-model-plugins/lib/autoFormat/interface/AutoLinkOptions.ts new file mode 100644 index 00000000000..a8f6113d5d4 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/lib/autoFormat/interface/AutoLinkOptions.ts @@ -0,0 +1,24 @@ +/** + * Options to customize the Auto link options in Auto Format Plugin + */ +export interface AutoLinkOptions { + /** + * When press backspace before a link, remove the hyperlink + */ + autoUnlink?: boolean; + + /** + * When paste or type content with a link, create hyperlink for the link + */ + autoLink?: boolean; + + /** + * When paste content or type content with telephone, create hyperlink for the telephone number + */ + autoTel?: boolean; + + /** + * When paste or type a content with mailto, create hyperlink for the content + */ + autoMailto?: boolean; +} diff --git a/packages/roosterjs-content-model-plugins/lib/autoFormat/link/createLink.ts b/packages/roosterjs-content-model-plugins/lib/autoFormat/link/createLink.ts index 65993fffcc4..fe29906835c 100644 --- a/packages/roosterjs-content-model-plugins/lib/autoFormat/link/createLink.ts +++ b/packages/roosterjs-content-model-plugins/lib/autoFormat/link/createLink.ts @@ -1,11 +1,13 @@ import { addLink, ChangeSource } from 'roosterjs-content-model-dom'; -import { formatTextSegmentBeforeSelectionMarker, matchLink } from 'roosterjs-content-model-api'; -import type { ContentModelLink, IEditor, LinkData } from 'roosterjs-content-model-types'; +import { formatTextSegmentBeforeSelectionMarker } from 'roosterjs-content-model-api'; +import { getLinkUrl } from './getLinkUrl'; +import type { AutoLinkOptions } from '../interface/AutoLinkOptions'; +import type { ContentModelLink, IEditor } from 'roosterjs-content-model-types'; /** * @internal */ -export function createLink(editor: IEditor) { +export function createLink(editor: IEditor, autoLinkOptions: AutoLinkOptions) { let anchorNode: Node | null = null; const links: ContentModelLink[] = []; formatTextSegmentBeforeSelectionMarker( @@ -15,11 +17,11 @@ export function createLink(editor: IEditor) { links.push(linkSegment.link); return true; } - let linkData: LinkData | null = null; - if (!linkSegment.link && (linkData = matchLink(linkSegment.text))) { + let linkUrl: string | undefined = undefined; + if (!linkSegment.link && (linkUrl = getLinkUrl(linkSegment.text, autoLinkOptions))) { addLink(linkSegment, { format: { - href: linkData.normalizedUrl, + href: linkUrl, underline: true, }, dataset: {}, diff --git a/packages/roosterjs-content-model-plugins/lib/autoFormat/link/createLinkAfterSpace.ts b/packages/roosterjs-content-model-plugins/lib/autoFormat/link/createLinkAfterSpace.ts index 95898e30d08..737917e25ab 100644 --- a/packages/roosterjs-content-model-plugins/lib/autoFormat/link/createLinkAfterSpace.ts +++ b/packages/roosterjs-content-model-plugins/lib/autoFormat/link/createLinkAfterSpace.ts @@ -1,8 +1,9 @@ -import { matchLink, splitTextSegment } from 'roosterjs-content-model-api'; +import { getLinkUrl } from './getLinkUrl'; +import { splitTextSegment } from 'roosterjs-content-model-api'; +import type { AutoLinkOptions } from '../interface/AutoLinkOptions'; import type { ContentModelText, FormatContentModelContext, - LinkData, ShallowMutableContentModelParagraph, } from 'roosterjs-content-model-types'; @@ -12,12 +13,13 @@ import type { export function createLinkAfterSpace( previousSegment: ContentModelText, paragraph: ShallowMutableContentModelParagraph, - context: FormatContentModelContext + context: FormatContentModelContext, + autoLinkOptions: AutoLinkOptions ) { const link = previousSegment.text.split(' ').pop(); const url = link?.trim(); - let linkData: LinkData | null = null; - if (url && link && (linkData = matchLink(url))) { + let linkUrl: string | undefined = undefined; + if (url && link && (linkUrl = getLinkUrl(url, autoLinkOptions))) { const linkSegment = splitTextSegment( previousSegment, paragraph, @@ -26,7 +28,7 @@ export function createLinkAfterSpace( ); linkSegment.link = { format: { - href: linkData.normalizedUrl, + href: linkUrl, underline: true, }, dataset: {}, diff --git a/packages/roosterjs-content-model-plugins/lib/autoFormat/link/getLinkUrl.ts b/packages/roosterjs-content-model-plugins/lib/autoFormat/link/getLinkUrl.ts new file mode 100644 index 00000000000..8c242f790ff --- /dev/null +++ b/packages/roosterjs-content-model-plugins/lib/autoFormat/link/getLinkUrl.ts @@ -0,0 +1,26 @@ +import { matchLink } from 'roosterjs-content-model-api'; +import type { AutoLinkOptions } from '../interface/AutoLinkOptions'; + +const COMMON_REGEX = `[\s]*[a-zA-Z0-9+][\s]*`; +const TELEPHONE_REGEX = `(T|t)el:${COMMON_REGEX}`; +const MAILTO_REGEX = `(M|m)ailto:${COMMON_REGEX}`; + +/** + * @internal + */ +export function getLinkUrl(text: string, autoLinkOptions: AutoLinkOptions): string | undefined { + const { autoLink, autoMailto, autoTel } = autoLinkOptions; + const linkMatch = autoLink ? matchLink(text)?.normalizedUrl : undefined; + const telMatch = autoTel ? matchTel(text) : undefined; + const mailtoMatch = autoMailto ? matchMailTo(text) : undefined; + + return linkMatch || telMatch || mailtoMatch; +} + +function matchTel(text: string) { + return text.match(TELEPHONE_REGEX) ? text.toLocaleLowerCase() : undefined; +} + +function matchMailTo(text: string) { + return text.match(MAILTO_REGEX) ? text.toLocaleLowerCase() : undefined; +} diff --git a/packages/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts b/packages/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts index 82bf6f54b52..0e3af9c35c3 100644 --- a/packages/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts +++ b/packages/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts @@ -63,7 +63,7 @@ export class EditPlugin implements EditorPlugin { */ initialize(editor: IEditor) { this.editor = editor; - this.handleNormalEnter = this.editor.isExperimentalFeatureEnabled('PersistCache'); + this.handleNormalEnter = this.editor.isExperimentalFeatureEnabled('HandleEnterKey'); if (editor.getEnvironment().isAndroid) { this.disposer = this.editor.attachDomEvent({ diff --git a/packages/roosterjs-content-model-plugins/lib/edit/keyboardEnter.ts b/packages/roosterjs-content-model-plugins/lib/edit/keyboardEnter.ts index ef765e5e231..b8b2217563e 100644 --- a/packages/roosterjs-content-model-plugins/lib/edit/keyboardEnter.ts +++ b/packages/roosterjs-content-model-plugins/lib/edit/keyboardEnter.ts @@ -1,7 +1,12 @@ import { deleteEmptyQuote } from './deleteSteps/deleteEmptyQuote'; -import { deleteSelection, normalizeContentModel, runEditSteps } from 'roosterjs-content-model-dom'; import { handleEnterOnList } from './inputSteps/handleEnterOnList'; import { handleEnterOnParagraph } from './inputSteps/handleEnterOnParagraph'; +import { + ChangeSource, + deleteSelection, + normalizeContentModel, + runEditSteps, +} from 'roosterjs-content-model-dom'; import type { IEditor } from 'roosterjs-content-model-types'; /** @@ -49,6 +54,7 @@ export function keyboardEnter( { rawEvent, scrollCaretIntoView: true, + changeSource: ChangeSource.Keyboard, } ); } diff --git a/packages/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts b/packages/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts index fe8dc9e7bfc..95ffb0d8667 100644 --- a/packages/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts +++ b/packages/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts @@ -1,4 +1,9 @@ -import { deleteSelection, isModifierKey, normalizeContentModel } from 'roosterjs-content-model-dom'; +import { + ChangeSource, + deleteSelection, + isModifierKey, + normalizeContentModel, +} from 'roosterjs-content-model-dom'; import type { DOMSelection, IEditor } from 'roosterjs-content-model-types'; /** @@ -32,6 +37,7 @@ export function keyboardInput(editor: IEditor, rawEvent: KeyboardEvent) { { scrollCaretIntoView: true, rawEvent, + changeSource: ChangeSource.Keyboard, } ); diff --git a/packages/roosterjs-content-model-plugins/lib/edit/keyboardTab.ts b/packages/roosterjs-content-model-plugins/lib/edit/keyboardTab.ts index 7c274f2f0cc..495b79fdc80 100644 --- a/packages/roosterjs-content-model-plugins/lib/edit/keyboardTab.ts +++ b/packages/roosterjs-content-model-plugins/lib/edit/keyboardTab.ts @@ -1,9 +1,13 @@ -import { getOperationalBlocks, isBlockGroupOfType } from 'roosterjs-content-model-dom'; import { handleTabOnList } from './tabUtils/handleTabOnList'; import { handleTabOnParagraph } from './tabUtils/handleTabOnParagraph'; import { handleTabOnTable } from './tabUtils/handleTabOnTable'; import { handleTabOnTableCell } from './tabUtils/handleTabOnTableCell'; import { setModelIndentation } from 'roosterjs-content-model-api'; +import { + ChangeSource, + getOperationalBlocks, + isBlockGroupOfType, +} from 'roosterjs-content-model-dom'; import type { ContentModelListItem, ContentModelTableCell, @@ -37,6 +41,7 @@ export function keyboardTab(editor: IEditor, rawEvent: KeyboardEvent) { }, { apiName: 'handleTabKey', + changeSource: ChangeSource.Keyboard, } ); return true; diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts index cea7b9d8b69..2c9529e995a 100644 --- a/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts @@ -53,6 +53,8 @@ const DefaultOptions: Partial = { const MouseRightButton = 2; const DRAG_ID = '_dragging'; +const IMAGE_EDIT_CLASS = 'imageEdit'; +const IMAGE_EDIT_CLASS_CARET = 'imageEditCaretColor'; /** * ImageEdit plugin handles the following image editing features: @@ -66,7 +68,7 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { private shadowSpan: HTMLSpanElement | null = null; private selectedImage: HTMLImageElement | null = null; protected wrapper: HTMLSpanElement | null = null; - private imageEditInfo: ImageMetadataFormat | null = null; + protected imageEditInfo: ImageMetadataFormat | null = null; private imageHTMLOptions: ImageHtmlOptions | null = null; private dndHelpers: DragAndDropHelper[] = []; private clonedImage: HTMLImageElement | null = null; @@ -384,9 +386,11 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { this.croppers = croppers; this.zoomScale = editor.getDOMHelper().calculateZoomScale(); - editor.setEditorStyle('imageEdit', `outline-style:none!important;`, [ + editor.setEditorStyle(IMAGE_EDIT_CLASS, `outline-style:none!important;`, [ `span:has(>img${getSafeIdSelector(this.selectedImage.id)})`, ]); + + editor.setEditorStyle(IMAGE_EDIT_CLASS_CARET, `caret-color: transparent;`); } public startRotateAndResize(editor: IEditor, image: HTMLImageElement) { @@ -607,7 +611,8 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { } private cleanInfo() { - this.editor?.setEditorStyle('imageEdit', null); + this.editor?.setEditorStyle(IMAGE_EDIT_CLASS, null); + this.editor?.setEditorStyle(IMAGE_EDIT_CLASS_CARET, null); this.selectedImage = null; this.shadowSpan = null; this.wrapper = null; diff --git a/packages/roosterjs-content-model-plugins/lib/index.ts b/packages/roosterjs-content-model-plugins/lib/index.ts index 4ff598eb9ce..89af142dedc 100644 --- a/packages/roosterjs-content-model-plugins/lib/index.ts +++ b/packages/roosterjs-content-model-plugins/lib/index.ts @@ -4,7 +4,9 @@ export { TableEditFeatureName } from './tableEdit/editors/features/TableEditFeat export { PastePlugin } from './paste/PastePlugin'; export { DefaultSanitizers } from './paste/DefaultSanitizers'; export { EditPlugin, EditOptions } from './edit/EditPlugin'; -export { AutoFormatPlugin, AutoFormatOptions } from './autoFormat/AutoFormatPlugin'; +export { AutoFormatPlugin } from './autoFormat/AutoFormatPlugin'; +export { AutoFormatOptions } from './autoFormat/interface/AutoFormatOptions'; +export { AutoLinkOptions } from './autoFormat/interface/AutoLinkOptions'; export { ShortcutBold, diff --git a/packages/roosterjs-content-model-plugins/test/autoFormat/AutoFormatPluginTest.ts b/packages/roosterjs-content-model-plugins/test/autoFormat/AutoFormatPluginTest.ts index 9203fa27645..e799ed4441b 100644 --- a/packages/roosterjs-content-model-plugins/test/autoFormat/AutoFormatPluginTest.ts +++ b/packages/roosterjs-content-model-plugins/test/autoFormat/AutoFormatPluginTest.ts @@ -1,7 +1,8 @@ import * as createLink from '../../lib/autoFormat/link/createLink'; import * as formatTextSegmentBeforeSelectionMarker from 'roosterjs-content-model-api/lib/publicApi/utils/formatTextSegmentBeforeSelectionMarker'; import * as unlink from '../../lib/autoFormat/link/unlink'; -import { AutoFormatOptions, AutoFormatPlugin } from '../../lib/autoFormat/AutoFormatPlugin'; +import { AutoFormatOptions } from '../../lib/autoFormat/interface/AutoFormatOptions'; +import { AutoFormatPlugin } from '../../lib/autoFormat/AutoFormatPlugin'; import { ChangeSource } from '../../../roosterjs-content-model-dom/lib/constants/ChangeSource'; import { createLinkAfterSpace } from '../../lib/autoFormat/link/createLinkAfterSpace'; import { keyboardListTrigger } from '../../lib/autoFormat/list/keyboardListTrigger'; @@ -176,9 +177,7 @@ describe('Content Model Auto Format Plugin Test', () => { function runTest( event: ContentChangedEvent, shouldCallTrigger: boolean, - options?: { - autoLink: boolean; - } + options: AutoFormatOptions ) { const plugin = new AutoFormatPlugin(options as AutoFormatOptions); plugin.initialize(editor); @@ -186,7 +185,7 @@ describe('Content Model Auto Format Plugin Test', () => { plugin.onPluginEvent(event); if (shouldCallTrigger) { - expect(createLinkSpy).toHaveBeenCalledWith(editor); + expect(createLinkSpy).toHaveBeenCalledWith(editor, options); } else { expect(createLinkSpy).not.toHaveBeenCalled(); } @@ -199,6 +198,8 @@ describe('Content Model Auto Format Plugin Test', () => { }; runTest(event, true, { autoLink: true, + autoMailto: true, + autoTel: true, }); }); @@ -207,7 +208,7 @@ describe('Content Model Auto Format Plugin Test', () => { eventType: 'contentChanged', source: 'Paste', }; - runTest(event, false, { autoLink: false }); + runTest(event, false, { autoLink: false, autoMailto: false, autoTel: false }); }); it('should not call createLink - not paste', () => { @@ -217,6 +218,8 @@ describe('Content Model Auto Format Plugin Test', () => { }; runTest(event, false, { autoLink: true, + autoMailto: true, + autoTel: true, }); }); }); @@ -305,9 +308,7 @@ describe('Content Model Auto Format Plugin Test', () => { context: FormatContentModelContext ) => { const result = - options && - options.autoLink && - createLinkAfterSpace(segment, paragraph, context); + options && createLinkAfterSpace(segment, paragraph, context, options); expect(result).toBe(expectResult); @@ -328,6 +329,26 @@ describe('Content Model Auto Format Plugin Test', () => { }); }); + it('should call createLinkAfterSpace | autoTel', () => { + const event: EditorInputEvent = { + eventType: 'input', + rawEvent: { data: ' ', preventDefault: () => {}, inputType: 'insertText' } as any, + }; + runTest(event, true, { + autoTel: true, + }); + }); + + it('should call createLinkAfterSpace | autoMailto', () => { + const event: EditorInputEvent = { + eventType: 'input', + rawEvent: { data: ' ', preventDefault: () => {}, inputType: 'insertText' } as any, + }; + runTest(event, true, { + autoMailto: true, + }); + }); + it('should not call createLinkAfterSpace - disable options', () => { const event: EditorInputEvent = { eventType: 'input', diff --git a/packages/roosterjs-content-model-plugins/test/autoFormat/link/createLinkAfterSpaceTest.ts b/packages/roosterjs-content-model-plugins/test/autoFormat/link/createLinkAfterSpaceTest.ts index 9ad724e4b9f..0bf95c157d4 100644 --- a/packages/roosterjs-content-model-plugins/test/autoFormat/link/createLinkAfterSpaceTest.ts +++ b/packages/roosterjs-content-model-plugins/test/autoFormat/link/createLinkAfterSpaceTest.ts @@ -14,7 +14,11 @@ describe('createLinkAfterSpace', () => { context: FormatContentModelContext, expectedResult: boolean ) { - const result = createLinkAfterSpace(previousSegment, paragraph, context); + const result = createLinkAfterSpace(previousSegment, paragraph, context, { + autoLink: true, + autoMailto: true, + autoTel: true, + }); expect(result).toBe(expectedResult); } @@ -85,7 +89,11 @@ describe('formatTextSegmentBeforeSelectionMarker - createLinkAfterSpace', () => formatContentModel: formatWithContentModelSpy, } as any, (_model, previousSegment, paragraph, _markerFormat, context) => { - return createLinkAfterSpace(previousSegment, paragraph, context); + return createLinkAfterSpace(previousSegment, paragraph, context, { + autoLink: true, + autoMailto: true, + autoTel: true, + }); } ); @@ -385,4 +393,476 @@ describe('formatTextSegmentBeforeSelectionMarker - createLinkAfterSpace', () => }; runTest(input, expected, true); }); + + it('telephone link', () => { + const input: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'tel:9999-9999', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + const expected: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'tel:9999-9999', + format: {}, + link: { + format: { + href: 'tel:9999-9999', + underline: true, + }, + dataset: {}, + }, + isSelected: undefined, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(input, expected, true); + }); + + it('telephone link with +', () => { + const input: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'tel:+9999-9999', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + const expected: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'tel:+9999-9999', + format: {}, + link: { + format: { + href: 'tel:+9999-9999', + underline: true, + }, + dataset: {}, + }, + isSelected: undefined, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(input, expected, true); + }); + + it('telephone link with T', () => { + const input: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Tel:9999-9999', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + const expected: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Tel:9999-9999', + format: {}, + link: { + format: { + href: 'tel:9999-9999', + underline: true, + }, + dataset: {}, + }, + isSelected: undefined, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(input, expected, true); + }); + + it('mailto link', () => { + const input: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'mailto:test', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + const expected: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'mailto:test', + format: {}, + link: { + format: { + href: 'mailto:test', + underline: true, + }, + dataset: {}, + }, + isSelected: undefined, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(input, expected, true); + }); + + it('mailto link with M', () => { + const input: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Mailto:test', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + const expected: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Mailto:test', + format: {}, + link: { + format: { + href: 'mailto:test', + underline: true, + }, + dataset: {}, + }, + isSelected: undefined, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(input, expected, true); + }); + + it('telephone link with space', () => { + const input: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'tel: 9999-9999', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + const expected: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'tel: 9999-9999', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(input, expected, false); + }); + + it('mailto link with space', () => { + const input: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'mailto: 9999-9999', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + const expected: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'mailto: 9999-9999', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(input, expected, false); + }); + + it('telephone link spelled wrong', () => { + const input: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'tels:9999-9999', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + const expected: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'tels:9999-9999', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(input, expected, false); + }); + + it('mailto link spelled wrong', () => { + const input: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'mailTo:9999-9999', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + const expected: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'mailTo:9999-9999', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(input, expected, false); + }); }); diff --git a/packages/roosterjs-content-model-plugins/test/autoFormat/link/createLinkTest.ts b/packages/roosterjs-content-model-plugins/test/autoFormat/link/createLinkTest.ts index edd4e5e7e31..4b709ab5a60 100644 --- a/packages/roosterjs-content-model-plugins/test/autoFormat/link/createLinkTest.ts +++ b/packages/roosterjs-content-model-plugins/test/autoFormat/link/createLinkTest.ts @@ -20,10 +20,13 @@ describe('createLink', () => { expect(options.changeSource).toBe(ChangeSource.AutoLink); }); - createLink({ - focus: () => {}, - formatContentModel: formatWithContentModelSpy, - } as any); + createLink( + { + focus: () => {}, + formatContentModel: formatWithContentModelSpy, + } as any, + { autoLink: true, autoMailto: true, autoTel: true } + ); expect(formatWithContentModelSpy).toHaveBeenCalled(); expect(input).toEqual(expectedModel); @@ -165,4 +168,471 @@ describe('createLink', () => { runTest(input, input, true); }); + + it('telephone link', () => { + const input: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'tel:9999-9999', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + const expected: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'tel:9999-9999', + format: {}, + link: { + format: { + href: 'tel:9999-9999', + underline: true, + }, + dataset: {}, + }, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(input, expected, true); + }); + + it('telephone link with +', () => { + const input: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'tel:+9999-9999', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + const expected: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'tel:+9999-9999', + format: {}, + link: { + format: { + href: 'tel:+9999-9999', + underline: true, + }, + dataset: {}, + }, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(input, expected, true); + }); + + it('telephone link with T', () => { + const input: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Tel:9999-9999', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + const expected: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Tel:9999-9999', + format: {}, + link: { + format: { + href: 'tel:9999-9999', + underline: true, + }, + dataset: {}, + }, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(input, expected, true); + }); + + it('mailto link', () => { + const input: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'mailto:test', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + const expected: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'mailto:test', + format: {}, + link: { + format: { + href: 'mailto:test', + underline: true, + }, + dataset: {}, + }, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(input, expected, true); + }); + + it('mailto link with M', () => { + const input: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Mailto:test', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + const expected: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Mailto:test', + format: {}, + link: { + format: { + href: 'mailto:test', + underline: true, + }, + dataset: {}, + }, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(input, expected, true); + }); + + it('telephone link with space', () => { + const input: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'tel: 9999-9999', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + const expected: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'tel: 9999-9999', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(input, expected, false); + }); + + it('mailto link with space', () => { + const input: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'mailto: 9999-9999', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + const expected: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'mailto: 9999-9999', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(input, expected, false); + }); + + it('telephone link spelled wrong', () => { + const input: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'tels:9999-9999', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + const expected: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'tels:9999-9999', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(input, expected, false); + }); + + it('mailto link spelled wrong', () => { + const input: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'mailTo:9999-9999', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + const expected: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'mailTo:9999-9999', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(input, expected, false); + }); }); diff --git a/packages/roosterjs-content-model-plugins/test/autoFormat/link/getLinkUrlTest.ts b/packages/roosterjs-content-model-plugins/test/autoFormat/link/getLinkUrlTest.ts new file mode 100644 index 00000000000..66d117a4d32 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/test/autoFormat/link/getLinkUrlTest.ts @@ -0,0 +1,53 @@ +import { AutoLinkOptions } from '../../../lib/autoFormat/interface/AutoLinkOptions'; +import { getLinkUrl } from '../../../lib/autoFormat/link/getLinkUrl'; + +describe('getLinkUrl', () => { + function runTest(text: string, options: AutoLinkOptions, expectedResult: string | undefined) { + const link = getLinkUrl(text, options); + expect(link).toBe(expectedResult); + } + + it('link', () => { + runTest('http://www.bing.com', { autoLink: true }, 'http://www.bing.com'); + }); + + it('do not return link', () => { + runTest('wwww.test.com', { autoLink: false }, undefined); + }); + + it('invalid link', () => { + runTest('www3w.test.com', { autoLink: true }, undefined); + }); + + it('telephone', () => { + runTest('tel:999999', { autoTel: true }, 'tel:999999'); + }); + + it('telephone with T', () => { + runTest('Tel:999999', { autoTel: true }, 'tel:999999'); + }); + + it('do not return telephone', () => { + runTest('tel:999999', { autoTel: false }, undefined); + }); + + it('invalid telephone', () => { + runTest('tels:999999', { autoTel: true }, undefined); + }); + + it('mailto', () => { + runTest('mailto:test', { autoMailto: true }, 'mailto:test'); + }); + + it('mailto with M', () => { + runTest('Mailto:test', { autoMailto: true }, 'mailto:test'); + }); + + it('do not return mailto', () => { + runTest('mailto:test', { autoMailto: false }, undefined); + }); + + it('invalid mailto', () => { + runTest('mailtos:test', { autoMailto: true }, undefined); + }); +}); diff --git a/packages/roosterjs-content-model-plugins/test/edit/EditPluginTest.ts b/packages/roosterjs-content-model-plugins/test/edit/EditPluginTest.ts index bbe1a0acb9b..6ad3df27402 100644 --- a/packages/roosterjs-content-model-plugins/test/edit/EditPluginTest.ts +++ b/packages/roosterjs-content-model-plugins/test/edit/EditPluginTest.ts @@ -162,7 +162,7 @@ describe('EditPlugin', () => { it('Enter, normal enter enabled', () => { isExperimentalFeatureEnabledSpy.and.callFake( - (featureName: string) => featureName == 'PersistCache' + (featureName: string) => featureName == 'HandleEnterKey' ); plugin = new EditPlugin(); const rawEvent = { which: 13, key: 'Enter' } as any; diff --git a/packages/roosterjs-content-model-plugins/test/imageEdit/ImageEditPluginTest.ts b/packages/roosterjs-content-model-plugins/test/imageEdit/ImageEditPluginTest.ts index f0bbc6fd7da..1d8108ae389 100644 --- a/packages/roosterjs-content-model-plugins/test/imageEdit/ImageEditPluginTest.ts +++ b/packages/roosterjs-content-model-plugins/test/imageEdit/ImageEditPluginTest.ts @@ -430,4 +430,61 @@ describe('ImageEditPlugin', () => { expect(formatContentModelSpy).toHaveBeenCalledTimes(3); plugin.dispose(); }); + + it('flip setEditorStyle', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Image', + src: 'test', + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: 'rgb(0, 0, 0)', + id: 'image_0', + maxWidth: '1800px', + }, + dataset: {}, + isSelectedAsImageSelection: true, + isSelected: true, + }, + ], + format: {}, + segmentFormat: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: 'rgb(0, 0, 0)', + }, + }, + ], + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: '#000000', + }, + }; + const plugin = new ImageEditPlugin(); + const editor = initEditor('image_edit', [plugin], model); + spyOn(editor, 'setEditorStyle').and.callThrough(); + + plugin.initialize(editor); + plugin.flipImage('horizontal'); + plugin.dispose(); + + expect(editor.setEditorStyle).toHaveBeenCalledWith( + 'imageEdit', + 'outline-style:none!important;', + ['span:has(>img#image_0)'] + ); + expect(editor.setEditorStyle).toHaveBeenCalledWith( + 'imageEditCaretColor', + 'caret-color: transparent;' + ); + expect(editor.setEditorStyle).toHaveBeenCalledWith('imageEdit', null); + expect(editor.setEditorStyle).toHaveBeenCalledWith('imageEditCaretColor', null); + }); }); diff --git a/packages/roosterjs-content-model-types/lib/editor/ExperimentalFeature.ts b/packages/roosterjs-content-model-types/lib/editor/ExperimentalFeature.ts index 15159013fd9..46e58d639df 100644 --- a/packages/roosterjs-content-model-types/lib/editor/ExperimentalFeature.ts +++ b/packages/roosterjs-content-model-types/lib/editor/ExperimentalFeature.ts @@ -12,4 +12,8 @@ export type ExperimentalFeature = /** * Workaround for the Legacy Image Edit */ - | 'LegacyImageSelection'; + | 'LegacyImageSelection' + /** + * Use Content Model handle ENTER key + */ + | 'HandleEnterKey'; diff --git a/packages/roosterjs-content-model-types/lib/parameter/DOMHelper.ts b/packages/roosterjs-content-model-types/lib/parameter/DOMHelper.ts index 91bd2d976ad..27169dd2681 100644 --- a/packages/roosterjs-content-model-types/lib/parameter/DOMHelper.ts +++ b/packages/roosterjs-content-model-types/lib/parameter/DOMHelper.ts @@ -92,4 +92,9 @@ export interface DOMHelper { * Get the width of the editable area of the editor content div */ getClientWidth(): number; + + /** + * Get a deep cloned root element + */ + getClonedRoot(): HTMLElement; } diff --git a/versions.json b/versions.json index c6bdf62c320..f1699a478dd 100644 --- a/versions.json +++ b/versions.json @@ -1,8 +1,6 @@ { "react": "9.0.0", - "main": "9.10.0", + "main": "9.11.0", "legacyAdapter": "8.62.1", - "overrides": { - "roosterjs-content-model-plugins": "9.10.1" - } + "overrides": {} } diff --git a/yarn.lock b/yarn.lock index 939728d0c1b..b003e590332 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1445,10 +1445,10 @@ bindings@^1.5.0: dependencies: file-uri-to-path "1.0.0" -body-parser@1.20.2: - version "1.20.2" - resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.2.tgz#6feb0e21c4724d06de7ff38da36dad4f57a747fd" - integrity sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA== +body-parser@1.20.3, body-parser@^1.19.0: + version "1.20.3" + resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.3.tgz#1953431221c6fb5cd63c4b36d53fab0928e548c6" + integrity sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g== dependencies: bytes "3.1.2" content-type "~1.0.5" @@ -1458,29 +1458,11 @@ body-parser@1.20.2: http-errors "2.0.0" iconv-lite "0.4.24" on-finished "2.4.1" - qs "6.11.0" + qs "6.13.0" raw-body "2.5.2" type-is "~1.6.18" unpipe "1.0.0" -body-parser@^1.19.0: - version "1.20.1" - resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.1.tgz#b1812a8912c195cd371a3ee5e66faa2338a5c668" - integrity sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw== - dependencies: - bytes "3.1.2" - content-type "~1.0.4" - debug "2.6.9" - depd "2.0.0" - destroy "1.2.0" - http-errors "2.0.0" - iconv-lite "0.4.24" - on-finished "2.4.1" - qs "6.11.0" - raw-body "2.5.1" - type-is "~1.6.18" - unpipe "1.0.0" - bonjour@^3.5.0: version "3.5.0" resolved "https://registry.yarnpkg.com/bonjour/-/bonjour-3.5.0.tgz#8e890a183d8ee9a2393b3844c691a42bcf7bc9f5" @@ -1582,6 +1564,17 @@ call-bind@^1.0.0, call-bind@^1.0.2: function-bind "^1.1.1" get-intrinsic "^1.0.2" +call-bind@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.7.tgz#06016599c40c56498c18769d2730be242b6fa3b9" + integrity sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w== + dependencies: + es-define-property "^1.0.0" + es-errors "^1.3.0" + function-bind "^1.1.2" + get-intrinsic "^1.2.4" + set-function-length "^1.2.1" + callsites@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" @@ -2096,6 +2089,15 @@ define-data-property@^1.0.1: gopd "^1.0.1" has-property-descriptors "^1.0.0" +define-data-property@^1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/define-data-property/-/define-data-property-1.1.4.tgz#894dc141bb7d3060ae4366f6a0107e68fbe48c5e" + integrity sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A== + dependencies: + es-define-property "^1.0.0" + es-errors "^1.3.0" + gopd "^1.0.1" + define-properties@^1.1.3, define-properties@^1.1.4, define-properties@^1.2.0, define-properties@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.2.1.tgz#10781cc616eb951a80a034bafcaa7377f6af2b6c" @@ -2247,10 +2249,10 @@ dom-serialize@^2.2.1: extend "^3.0.0" void-elements "^2.0.0" -dompurify@2.3.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-2.3.0.tgz#07bb39515e491588e5756b1d3e8375b5964814e2" - integrity sha512-VV5C6Kr53YVHGOBKO/F86OYX6/iLTw2yVSI721gKetxpHCK/V5TaLEf9ODjRgl1KLSWRMY6cUhAbv/c+IUnwQw== +dompurify@2.5.4: + version "2.5.4" + resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-2.5.4.tgz#347e91070963b22db31c7c8d0ce9a0a2c3c08746" + integrity sha512-l5NNozANzaLPPe0XaAwvg3uZcHtDBnziX/HjsY1UcDj1MxTK8Dd0Kv096jyPK5HRzs/XM5IMj20dW8Fk+HnbUA== ecc-jsbn@~0.1.1: version "0.1.2" @@ -2295,6 +2297,11 @@ encodeurl@~1.0.2: resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" integrity sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w== +encodeurl@~2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-2.0.0.tgz#7b8ea898077d7e409d3ac45474ea38eaf0857a58" + integrity sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg== + end-of-stream@^1.1.0: version "1.4.1" resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.1.tgz#ed29634d19baba463b6ce6b80a37213eab71ec43" @@ -2404,6 +2411,18 @@ es-abstract@^1.22.1: unbox-primitive "^1.0.2" which-typed-array "^1.1.11" +es-define-property@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.0.tgz#c7faefbdff8b2696cf5f46921edfb77cc4ba3845" + integrity sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ== + dependencies: + get-intrinsic "^1.2.4" + +es-errors@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f" + integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw== + es-iterator-helpers@^1.0.12: version "1.0.15" resolved "https://registry.yarnpkg.com/es-iterator-helpers/-/es-iterator-helpers-1.0.15.tgz#bd81d275ac766431d19305923707c3efd9f1ae40" @@ -2754,36 +2773,36 @@ expand-tilde@^2.0.0, expand-tilde@^2.0.2: homedir-polyfill "^1.0.1" express@^4.17.1: - version "4.19.2" - resolved "https://registry.yarnpkg.com/express/-/express-4.19.2.tgz#e25437827a3aa7f2a827bc8171bbbb664a356465" - integrity sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q== + version "4.21.0" + resolved "https://registry.yarnpkg.com/express/-/express-4.21.0.tgz#d57cb706d49623d4ac27833f1cbc466b668eb915" + integrity sha512-VqcNGcj/Id5ZT1LZ/cfihi3ttTn+NJmkli2eZADigjq29qTlWi/hAQ43t/VLPq8+UX06FCEx3ByOYet6ZFblng== dependencies: accepts "~1.3.8" array-flatten "1.1.1" - body-parser "1.20.2" + body-parser "1.20.3" content-disposition "0.5.4" content-type "~1.0.4" cookie "0.6.0" cookie-signature "1.0.6" debug "2.6.9" depd "2.0.0" - encodeurl "~1.0.2" + encodeurl "~2.0.0" escape-html "~1.0.3" etag "~1.8.1" - finalhandler "1.2.0" + finalhandler "1.3.1" fresh "0.5.2" http-errors "2.0.0" - merge-descriptors "1.0.1" + merge-descriptors "1.0.3" methods "~1.1.2" on-finished "2.4.1" parseurl "~1.3.3" - path-to-regexp "0.1.7" + path-to-regexp "0.1.10" proxy-addr "~2.0.7" - qs "6.11.0" + qs "6.13.0" range-parser "~1.2.1" safe-buffer "5.2.1" - send "0.18.0" - serve-static "1.15.0" + send "0.19.0" + serve-static "1.16.2" setprototypeof "1.2.0" statuses "2.0.1" type-is "~1.6.18" @@ -2940,13 +2959,13 @@ finalhandler@1.1.2: statuses "~1.5.0" unpipe "~1.0.0" -finalhandler@1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.2.0.tgz#7d23fe5731b207b4640e4fcd00aec1f9207a7b32" - integrity sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg== +finalhandler@1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.3.1.tgz#0c575f1d1d324ddd1da35ad7ece3df7d19088019" + integrity sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ== dependencies: debug "2.6.9" - encodeurl "~1.0.2" + encodeurl "~2.0.0" escape-html "~1.0.3" on-finished "2.4.1" parseurl "~1.3.3" @@ -3101,6 +3120,11 @@ function-bind@^1.1.1: resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A== +function-bind@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c" + integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== + function.prototype.name@^1.1.5, function.prototype.name@^1.1.6: version "1.1.6" resolved "https://registry.yarnpkg.com/function.prototype.name/-/function.prototype.name-1.1.6.tgz#cdf315b7d90ee77a4c6ee216c3c3362da07533fd" @@ -3150,6 +3174,17 @@ get-intrinsic@^1.1.1, get-intrinsic@^1.1.3, get-intrinsic@^1.2.0, get-intrinsic@ has-proto "^1.0.1" has-symbols "^1.0.3" +get-intrinsic@^1.2.4: + version "1.2.4" + resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.2.4.tgz#e385f5a4b5227d449c3eabbad05494ef0abbeadd" + integrity sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ== + dependencies: + es-errors "^1.3.0" + function-bind "^1.1.2" + has-proto "^1.0.1" + has-symbols "^1.0.3" + hasown "^2.0.0" + get-stream@^4.0.0: version "4.1.0" resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-4.1.0.tgz#c1b255575f3dc21d59bfc79cd3d2b46b1c3a54b5" @@ -3394,6 +3429,13 @@ has-property-descriptors@^1.0.0: dependencies: get-intrinsic "^1.1.1" +has-property-descriptors@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz#963ed7d071dc7bf5f084c5bfbe0d1b6222586854" + integrity sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg== + dependencies: + es-define-property "^1.0.0" + has-proto@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/has-proto/-/has-proto-1.0.1.tgz#1885c1305538958aff469fef37937c22795408e0" @@ -3457,6 +3499,13 @@ hasha@^2.2.0: is-stream "^1.0.1" pinkie-promise "^2.0.0" +hasown@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.2.tgz#003eaf91be7adc372e84ec59dc37252cedb80003" + integrity sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ== + dependencies: + function-bind "^1.1.2" + homedir-polyfill@^1.0.1: version "1.0.3" resolved "https://registry.yarnpkg.com/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz#743298cef4e5af3e194161fbadcc2151d3a058e8" @@ -4622,10 +4671,10 @@ memory-fs@^0.4.0, memory-fs@^0.4.1: errno "^0.1.3" readable-stream "^2.0.1" -merge-descriptors@1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61" - integrity sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w== +merge-descriptors@1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.3.tgz#d80319a65f3c7935351e5cfdac8f9318504dbed5" + integrity sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ== merge-source-map@^1.1.0: version "1.1.0" @@ -4928,6 +4977,11 @@ object-inspect@^1.12.3: resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.12.3.tgz#ba62dffd67ee256c8c086dfae69e016cd1f198b9" integrity sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g== +object-inspect@^1.13.1: + version "1.13.2" + resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.2.tgz#dea0088467fb991e67af4058147a24824a3043ff" + integrity sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g== + object-inspect@^1.9.0: version "1.12.2" resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.12.2.tgz#c0641f26394532f28ab8d796ab954e43c009a8ea" @@ -5241,10 +5295,10 @@ path-parse@^1.0.7: resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== -path-to-regexp@0.1.7: - version "0.1.7" - resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c" - integrity sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ== +path-to-regexp@0.1.10: + version "0.1.10" + resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.10.tgz#67e9108c5c0551b9e5326064387de4763c4d5f8b" + integrity sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w== path-type@^4.0.0: version "4.0.0" @@ -5515,12 +5569,12 @@ qjobs@^1.2.0: resolved "https://registry.yarnpkg.com/qjobs/-/qjobs-1.2.0.tgz#c45e9c61800bd087ef88d7e256423bdd49e5d071" integrity sha512-8YOJEHtxpySA3fFDyCRxA+UUV+fA+rTWnuWvylOK/NCjhY+b4ocCtmu8TtsWb+mYeU+GCHf/S66KZF/AsteKHg== -qs@6.11.0: - version "6.11.0" - resolved "https://registry.yarnpkg.com/qs/-/qs-6.11.0.tgz#fd0d963446f7a65e1367e01abd85429453f0c37a" - integrity sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q== +qs@6.13.0: + version "6.13.0" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.13.0.tgz#6ca3bd58439f7e245655798997787b0d88a51906" + integrity sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg== dependencies: - side-channel "^1.0.4" + side-channel "^1.0.6" qs@~6.5.2: version "6.5.3" @@ -5554,16 +5608,6 @@ range-parser@^1.2.1, range-parser@~1.2.1: resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg== -raw-body@2.5.1: - version "2.5.1" - resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.1.tgz#fe1b1628b181b700215e5fd42389f98b71392857" - integrity sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig== - dependencies: - bytes "3.1.2" - http-errors "2.0.0" - iconv-lite "0.4.24" - unpipe "1.0.0" - raw-body@2.5.2: version "2.5.2" resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.2.tgz#99febd83b90e08975087e8f1f9419a149366b68a" @@ -5960,10 +6004,10 @@ semver@^7.3.4, semver@^7.3.7, semver@^7.5.4: dependencies: lru-cache "^6.0.0" -send@0.18.0: - version "0.18.0" - resolved "https://registry.yarnpkg.com/send/-/send-0.18.0.tgz#670167cc654b05f5aa4a767f9113bb371bc706be" - integrity sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg== +send@0.19.0: + version "0.19.0" + resolved "https://registry.yarnpkg.com/send/-/send-0.19.0.tgz#bbc5a388c8ea6c048967049dbeac0e4a3f09d7f8" + integrity sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw== dependencies: debug "2.6.9" depd "2.0.0" @@ -5999,21 +6043,33 @@ serve-index@^1.9.1: mime-types "~2.1.17" parseurl "~1.3.2" -serve-static@1.15.0: - version "1.15.0" - resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.15.0.tgz#faaef08cffe0a1a62f60cad0c4e513cff0ac9540" - integrity sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g== +serve-static@1.16.2: + version "1.16.2" + resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.16.2.tgz#b6a5343da47f6bdd2673848bf45754941e803296" + integrity sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw== dependencies: - encodeurl "~1.0.2" + encodeurl "~2.0.0" escape-html "~1.0.3" parseurl "~1.3.3" - send "0.18.0" + send "0.19.0" set-blocking@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" integrity sha1-BF+XgtARrppoA93TgrJDkrPYkPc= +set-function-length@^1.2.1: + version "1.2.2" + resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.2.2.tgz#aac72314198eaed975cf77b2c3b6b880695e5449" + integrity sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg== + dependencies: + define-data-property "^1.1.4" + es-errors "^1.3.0" + function-bind "^1.1.2" + get-intrinsic "^1.2.4" + gopd "^1.0.1" + has-property-descriptors "^1.0.2" + set-function-name@^2.0.0, set-function-name@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/set-function-name/-/set-function-name-2.0.1.tgz#12ce38b7954310b9f61faa12701620a0c882793a" @@ -6092,6 +6148,16 @@ side-channel@^1.0.4: get-intrinsic "^1.0.2" object-inspect "^1.9.0" +side-channel@^1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.6.tgz#abd25fb7cd24baf45466406b1096b7831c9215f2" + integrity sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA== + dependencies: + call-bind "^1.0.7" + es-errors "^1.3.0" + get-intrinsic "^1.2.4" + object-inspect "^1.13.1" + signal-exit@^3.0.0: version "3.0.2" resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.2.tgz#b5fdc08f1287ea1178628e415e25132b73646c6d"