From 4459182baa8b5981428553d03bd35e085326226f Mon Sep 17 00:00:00 2001 From: "Francis Meng (from Dev Box)" Date: Mon, 23 Mar 2026 10:44:23 -0700 Subject: [PATCH 01/15] fix(markdown): accept angle-bracketed, data/blob and relative URLs for links/images in splitParagraphSegments --- .../utils/splitParagraphSegments.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/roosterjs-content-model-markdown/lib/markdownToModel/utils/splitParagraphSegments.ts b/packages/roosterjs-content-model-markdown/lib/markdownToModel/utils/splitParagraphSegments.ts index 21fcc43c358a..3b381651f43a 100644 --- a/packages/roosterjs-content-model-markdown/lib/markdownToModel/utils/splitParagraphSegments.ts +++ b/packages/roosterjs-content-model-markdown/lib/markdownToModel/utils/splitParagraphSegments.ts @@ -1,4 +1,4 @@ -const linkRegex = /(\[([^\[]+)\]\((https?:\/\/[^\)]+)\))|(\!\[([^\[]+)\]\((https?:\/\/[^\)]+)\))/g; +const linkRegex = /(\[([^\[]+)\]\(([^\)]+)\))|(\!\[([^\[]+)\]\(([^\)]+)\))/g; /** * @internal @@ -10,6 +10,19 @@ interface MarkdownSegment { } const isValidUrl = (url: string) => { + if (!url) return false; + + // Accept common non-http schemes and relative paths + if ( + url.startsWith('data:') || + url.startsWith('blob:') || + url.startsWith('/') || + url.startsWith('./') || + url.startsWith('../') + ) { + return true; + } + try { new URL(url); return true; From 6fba0f45fc610ef866895cff784944218d00f69e Mon Sep 17 00:00:00 2001 From: "Francis Meng (from Dev Box)" Date: Mon, 23 Mar 2026 11:02:00 -0700 Subject: [PATCH 02/15] fix test --- .../lib/markdownToModel/utils/splitParagraphSegments.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/roosterjs-content-model-markdown/lib/markdownToModel/utils/splitParagraphSegments.ts b/packages/roosterjs-content-model-markdown/lib/markdownToModel/utils/splitParagraphSegments.ts index 3b381651f43a..22726d81f879 100644 --- a/packages/roosterjs-content-model-markdown/lib/markdownToModel/utils/splitParagraphSegments.ts +++ b/packages/roosterjs-content-model-markdown/lib/markdownToModel/utils/splitParagraphSegments.ts @@ -10,7 +10,9 @@ interface MarkdownSegment { } const isValidUrl = (url: string) => { - if (!url) return false; + if (!url) { + return false; + } // Accept common non-http schemes and relative paths if ( From 6ffcc57453b002fda7d6ab6614f938095fc2e113 Mon Sep 17 00:00:00 2001 From: "Francis Meng (from Dev Box)" Date: Thu, 26 Mar 2026 14:46:26 -0700 Subject: [PATCH 03/15] fix test --- .../utils/splitParagraphSegments.ts | 33 ++++++++++++------- 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/packages/roosterjs-content-model-markdown/lib/markdownToModel/utils/splitParagraphSegments.ts b/packages/roosterjs-content-model-markdown/lib/markdownToModel/utils/splitParagraphSegments.ts index 22726d81f879..103f00a0a452 100644 --- a/packages/roosterjs-content-model-markdown/lib/markdownToModel/utils/splitParagraphSegments.ts +++ b/packages/roosterjs-content-model-markdown/lib/markdownToModel/utils/splitParagraphSegments.ts @@ -33,6 +33,15 @@ const isValidUrl = (url: string) => { } }; +function pushText(result: MarkdownSegment[], text: string) { + const last = result[result.length - 1]; + if (last && last.type === 'text') { + last.text += text; + } else { + result.push({ type: 'text', text, url: '' }); + } +} + /** * @internal */ @@ -43,28 +52,28 @@ export function splitParagraphSegments(text: string): MarkdownSegment[] { while ((match = linkRegex.exec(text)) !== null) { if (match.index > lastIndex) { - result.push({ type: 'text', text: text.slice(lastIndex, match.index), url: '' }); + pushText(result, text.slice(lastIndex, match.index)); } if (match[2] && match[3]) { - result.push( - isValidUrl(match[3]) - ? { type: 'link', text: match[2], url: match[3] } - : { type: 'text', text: match[0], url: '' } - ); + if (isValidUrl(match[3])) { + result.push({ type: 'link', text: match[2], url: match[3] }); + } else { + pushText(result, match[0]); + } } else if (match[5] && match[6]) { - result.push( - isValidUrl(match[6]) - ? { type: 'image', text: match[5], url: match[6] } - : { type: 'text', text: match[0], url: '' } - ); + if (isValidUrl(match[6])) { + result.push({ type: 'image', text: match[5], url: match[6] }); + } else { + pushText(result, match[0]); + } } lastIndex = linkRegex.lastIndex; } if (lastIndex < text.length) { - result.push({ type: 'text', text: text.slice(lastIndex), url: '' }); + pushText(result, text.slice(lastIndex)); } return result; From edac6a381786c08ad76b0fadbb9e053b75f53137 Mon Sep 17 00:00:00 2001 From: "Francis Meng (from Dev Box)" Date: Thu, 26 Mar 2026 15:20:54 -0700 Subject: [PATCH 04/15] update --- .../lib/markdownToModel/utils/splitParagraphSegments.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/roosterjs-content-model-markdown/lib/markdownToModel/utils/splitParagraphSegments.ts b/packages/roosterjs-content-model-markdown/lib/markdownToModel/utils/splitParagraphSegments.ts index 103f00a0a452..c1e1192953dc 100644 --- a/packages/roosterjs-content-model-markdown/lib/markdownToModel/utils/splitParagraphSegments.ts +++ b/packages/roosterjs-content-model-markdown/lib/markdownToModel/utils/splitParagraphSegments.ts @@ -26,8 +26,8 @@ const isValidUrl = (url: string) => { } try { - new URL(url); - return true; + const parsed = new URL(url); + return parsed.protocol === 'http:' || parsed.protocol === 'https:'; } catch (_) { return false; } From a7f338d921e80b60216b85943c6b07f1c2b69f58 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 30 Mar 2026 10:03:27 -0700 Subject: [PATCH 05/15] Bump picomatch from 2.2.1 to 2.3.2 (#3314) Bumps [picomatch](https://github.com/micromatch/picomatch) from 2.2.1 to 2.3.2. - [Release notes](https://github.com/micromatch/picomatch/releases) - [Changelog](https://github.com/micromatch/picomatch/blob/master/CHANGELOG.md) - [Commits](https://github.com/micromatch/picomatch/commits/2.3.2) --- updated-dependencies: - dependency-name: picomatch dependency-version: 2.3.2 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- yarn.lock | 23 ++++------------------- 1 file changed, 4 insertions(+), 19 deletions(-) diff --git a/yarn.lock b/yarn.lock index 70a8cee8f0f8..2554bdd8a401 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5388,25 +5388,10 @@ picocolors@^1.1.1: resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== -picomatch@^2.0.4: - version "2.2.1" - resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.2.1.tgz#21bac888b6ed8601f831ce7816e335bc779f0a4a" - integrity sha512-ISBaA8xQNmwELC7eOjqFKMESB2VIqt4PPDD0nsS95b/9dZXvVKOlz9keMSnoGGKcOHXfTvDD6WMaRoSc9UuhRA== - -picomatch@^2.0.5: - version "2.0.7" - resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.0.7.tgz#514169d8c7cd0bdbeecc8a2609e34a7163de69f6" - integrity sha512-oLHIdio3tZ0qH76NybpeneBhYVj0QFTfXEFTc/B3zKQspYfYYkWYgFsmzo+4kvId/bQRcNkVeguI3y+CD22BtA== - -picomatch@^2.2.1: - version "2.3.0" - resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.0.tgz#f1f061de8f6a4bf022892e2d128234fb98302972" - integrity sha512-lY1Q/PiJGC2zOv/z391WOTD+Z02bCgsFfvxoXXf6h7kv9o+WmsmzYqrAwY63sNgOxE4xEdq0WyUnXfKeBrSvYw== - -picomatch@^2.3.1: - version "2.3.1" - resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" - integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== +picomatch@^2.0.4, picomatch@^2.0.5, picomatch@^2.2.1, picomatch@^2.3.1: + version "2.3.2" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.2.tgz#5a942915e26b372dc0f0e6753149a16e6b1c5601" + integrity sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA== pify@^2.0.0: version "2.3.0" From e6198df99078c863546a2d1d347a8a9842f51f7f Mon Sep 17 00:00:00 2001 From: "Francis Meng (from Dev Box)" Date: Mon, 30 Mar 2026 11:35:24 -0700 Subject: [PATCH 06/15] fix(markdown): validate URL protocol and add tests for splitParagraphSegments - Add protocol whitelist (http/https) to isValidUrl to prevent browsers accepting arbitrary schemes like ht3tps:// as valid URLs - Merge adjacent text segments so consecutive invalid matches collapse into a single text segment - Add regex comment explaining capture groups with examples - Expand test suite with 15 new cases covering http, query strings, fragments, adjacent links, invalid URLs, relative/data/blob paths Co-Authored-By: Claude Sonnet 4.6 --- .../utils/splitParagraphSegments.ts | 7 ++ .../utils/splitParagraphSegmentsTest.ts | 106 ++++++++++++++++++ 2 files changed, 113 insertions(+) diff --git a/packages/roosterjs-content-model-markdown/lib/markdownToModel/utils/splitParagraphSegments.ts b/packages/roosterjs-content-model-markdown/lib/markdownToModel/utils/splitParagraphSegments.ts index c1e1192953dc..1aca3f61713a 100644 --- a/packages/roosterjs-content-model-markdown/lib/markdownToModel/utils/splitParagraphSegments.ts +++ b/packages/roosterjs-content-model-markdown/lib/markdownToModel/utils/splitParagraphSegments.ts @@ -1,3 +1,10 @@ +// Matches markdown links and images in a string. +// Group 1 (full link): [text](url) e.g. [Click here](https://example.com) +// Group 2: link text e.g. "Click here" +// Group 3: link url e.g. "https://example.com" +// Group 4 (full image): ![alt](url) e.g. ![Logo](https://example.com/logo.png) +// Group 5: alt text e.g. "Logo" +// Group 6: image url e.g. "https://example.com/logo.png" const linkRegex = /(\[([^\[]+)\]\(([^\)]+)\))|(\!\[([^\[]+)\]\(([^\)]+)\))/g; /** diff --git a/packages/roosterjs-content-model-markdown/test/markdownToModel/utils/splitParagraphSegmentsTest.ts b/packages/roosterjs-content-model-markdown/test/markdownToModel/utils/splitParagraphSegmentsTest.ts index 64964704bd31..c3171417916f 100644 --- a/packages/roosterjs-content-model-markdown/test/markdownToModel/utils/splitParagraphSegmentsTest.ts +++ b/packages/roosterjs-content-model-markdown/test/markdownToModel/utils/splitParagraphSegmentsTest.ts @@ -81,4 +81,110 @@ describe('splitLinksAndImages', () => { ] ); }); + + it('should treat invalid link as text but still render valid image', () => { + runTest('[link](ht3tps://www.example.com) and ![image](https://www.example.com)', [ + { text: '[link](ht3tps://www.example.com) and ', type: 'text', url: '' }, + { text: 'image', type: 'image', url: 'https://www.example.com' }, + ]); + }); + + it('should render valid link but treat invalid image as text', () => { + runTest('[link](https://www.example.com) and ![image](http3s://www.example.com)', [ + { text: 'link', type: 'link', url: 'https://www.example.com' }, + { text: ' and ![image](http3s://www.example.com)', type: 'text', url: '' }, + ]); + }); + + it('should accept data: URL for image', () => { + runTest('![image](data:image/png;base64,abc123)', [ + { text: 'image', type: 'image', url: 'data:image/png;base64,abc123' }, + ]); + }); + + it('should accept blob: URL for image', () => { + runTest('![image](blob:https://example.com/some-id)', [ + { text: 'image', type: 'image', url: 'blob:https://example.com/some-id' }, + ]); + }); + + it('should accept absolute path for link', () => { + runTest('[link](/path/to/page)', [{ text: 'link', type: 'link', url: '/path/to/page' }]); + }); + + it('should accept relative path with ./ for link', () => { + runTest('[link](./relative/path)', [ + { text: 'link', type: 'link', url: './relative/path' }, + ]); + }); + + it('should accept relative path with ../ for link', () => { + runTest('[link](../parent/path)', [{ text: 'link', type: 'link', url: '../parent/path' }]); + }); + + it('should handle text before and after a link', () => { + runTest('before [link](https://www.example.com) after', [ + { text: 'before ', type: 'text', url: '' }, + { text: 'link', type: 'link', url: 'https://www.example.com' }, + { text: ' after', type: 'text', url: '' }, + ]); + }); + + it('should accept http: URL', () => { + runTest('[link](http://www.example.com)', [ + { text: 'link', type: 'link', url: 'http://www.example.com' }, + ]); + }); + + it('should accept URL with query string and fragment', () => { + runTest('[link](https://www.example.com/page?q=1&r=2#section)', [ + { + text: 'link', + type: 'link', + url: 'https://www.example.com/page?q=1&r=2#section', + }, + ]); + }); + + it('should handle two adjacent links with no text between', () => { + runTest('[first](https://www.example.com/1)[second](https://www.example.com/2)', [ + { text: 'first', type: 'link', url: 'https://www.example.com/1' }, + { text: 'second', type: 'link', url: 'https://www.example.com/2' }, + ]); + }); + + it('should treat a single invalid link as plain text', () => { + runTest('[link](ht3tps://www.example.com)', [ + { text: '[link](ht3tps://www.example.com)', type: 'text', url: '' }, + ]); + }); + + it('should treat a single invalid image as plain text', () => { + runTest('![image](http3s://www.example.com)', [ + { text: '![image](http3s://www.example.com)', type: 'text', url: '' }, + ]); + }); + + it('should treat partial markdown syntax as plain text', () => { + runTest('[not a link] and (not a url)', [ + { text: '[not a link] and (not a url)', type: 'text', url: '' }, + ]); + }); + + it('should accept relative path for image', () => { + runTest('![image](./images/photo.png)', [ + { text: 'image', type: 'image', url: './images/photo.png' }, + ]); + }); + + it('should handle multiple images in a row', () => { + runTest( + '![first](https://www.example.com/1.png) ![second](https://www.example.com/2.png)', + [ + { text: 'first', type: 'image', url: 'https://www.example.com/1.png' }, + { text: ' ', type: 'text', url: '' }, + { text: 'second', type: 'image', url: 'https://www.example.com/2.png' }, + ] + ); + }); }); From c65d5a8db4ec85ca4d5298c5e2b0a9258ef09645 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 30 Mar 2026 14:30:55 -0700 Subject: [PATCH 07/15] Bump handlebars from 4.7.7 to 4.7.9 (#3315) Bumps [handlebars](https://github.com/handlebars-lang/handlebars.js) from 4.7.7 to 4.7.9. - [Release notes](https://github.com/handlebars-lang/handlebars.js/releases) - [Changelog](https://github.com/handlebars-lang/handlebars.js/blob/v4.7.9/release-notes.md) - [Commits](https://github.com/handlebars-lang/handlebars.js/compare/v4.7.7...v4.7.9) --- updated-dependencies: - dependency-name: handlebars dependency-version: 4.7.9 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Jiuqing Song --- yarn.lock | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/yarn.lock b/yarn.lock index 2554bdd8a401..b7075d7ea2fc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3426,12 +3426,12 @@ handle-thing@^2.0.0: integrity sha512-d4sze1JNC454Wdo2fkuyzCr6aHcbL6PGGuFAz0Li/NcOm1tCHGnWDRmJP85dh9IhQErTc2svWFEX5xHIOo//kQ== handlebars@^4.7.7: - version "4.7.7" - resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.7.7.tgz#9ce33416aad02dbd6c8fafa8240d5d98004945a1" - integrity sha512-aAcXm5OAfE/8IXkcZvCepKU3VzW1/39Fb5ZuqMtgI/hT8X2YgoMvBY5dLhq/cpOvw7Lk1nK/UF71aLG/ZnVYRA== + version "4.7.9" + resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.7.9.tgz#6f139082ab58dc4e5a0e51efe7db5ae890d56a0f" + integrity sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ== dependencies: minimist "^1.2.5" - neo-async "^2.6.0" + neo-async "^2.6.2" source-map "^0.6.1" wordwrap "^1.0.0" optionalDependencies: @@ -4950,7 +4950,7 @@ negotiator@0.6.3: resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd" integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg== -neo-async@^2.6.0, neo-async@^2.6.1, neo-async@^2.6.2: +neo-async@^2.6.1, neo-async@^2.6.2: version "2.6.2" resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f" integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw== From e429c23e108598368c5daea78553772127ace1df Mon Sep 17 00:00:00 2001 From: Julia Roldi <87443959+juliaroldi@users.noreply.github.com> Date: Thu, 9 Apr 2026 17:57:40 -0300 Subject: [PATCH 08/15] Prevent drop malicious content on the editor (#3319) Introduced protection to prevent potentially harmful HTML content from being added to the editor through drag-and-drop. The DragAndDrop Plugin was implemented to manage external content drops, block the default drop action, sanitize any dropped content, and insert only the sanitized content into the editor. --- demo/scripts/controlsV2/mainPane/MainPane.tsx | 2 + .../editorOptions/EditorOptionsPlugin.ts | 1 + .../sidePane/editorOptions/OptionState.ts | 1 + .../sidePane/editorOptions/Plugins.tsx | 1 + .../editorOptions/codes/PluginsCode.ts | 2 + .../editorOptions/codes/SimplePluginCode.ts | 6 + .../lib/corePlugin/domEvent/DOMEventPlugin.ts | 26 +- .../corePlugin/domEvent/DomEventPluginTest.ts | 36 +- .../lib/dragAndDrop/DragAndDropPlugin.ts | 96 ++++ .../utils/cleanForbiddenElements.ts | 18 + .../dragAndDrop/utils/handleDroppedContent.ts | 50 ++ .../lib/index.ts | 1 + .../test/dragAndDrop/DragAndDropPluginTest.ts | 232 +++++++++ .../utils/cleanForbiddenElementsTest.ts | 100 ++++ .../utils/handleDroppedContentTest.ts | 482 ++++++++++++++++++ .../lib/event/BeforeDropEvent.ts | 6 + .../lib/event/PluginEvent.ts | 2 + .../lib/event/PluginEventType.ts | 7 +- .../lib/index.ts | 1 + 19 files changed, 1056 insertions(+), 14 deletions(-) create mode 100644 packages/roosterjs-content-model-plugins/lib/dragAndDrop/DragAndDropPlugin.ts create mode 100644 packages/roosterjs-content-model-plugins/lib/dragAndDrop/utils/cleanForbiddenElements.ts create mode 100644 packages/roosterjs-content-model-plugins/lib/dragAndDrop/utils/handleDroppedContent.ts create mode 100644 packages/roosterjs-content-model-plugins/test/dragAndDrop/DragAndDropPluginTest.ts create mode 100644 packages/roosterjs-content-model-plugins/test/dragAndDrop/utils/cleanForbiddenElementsTest.ts create mode 100644 packages/roosterjs-content-model-plugins/test/dragAndDrop/utils/handleDroppedContentTest.ts create mode 100644 packages/roosterjs-content-model-types/lib/event/BeforeDropEvent.ts diff --git a/demo/scripts/controlsV2/mainPane/MainPane.tsx b/demo/scripts/controlsV2/mainPane/MainPane.tsx index 1b2c912928f3..6dc8a389581d 100644 --- a/demo/scripts/controlsV2/mainPane/MainPane.tsx +++ b/demo/scripts/controlsV2/mainPane/MainPane.tsx @@ -62,6 +62,7 @@ import { AnnouncePlugin, AutoFormatPlugin, CustomReplacePlugin, + DragAndDropPlugin, EditPlugin, HiddenPropertyPlugin, HyperlinkPlugin, @@ -577,6 +578,7 @@ export class MainPane extends React.Component<{}, MainPaneState> { }), pluginList.touch && new TouchPlugin(), pluginList.announce && new AnnouncePlugin(), + pluginList.dragAndDrop && new DragAndDropPlugin(), ].filter(x => !!x); } } diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts b/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts index 83b03db04669..319412e4e6c2 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts +++ b/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts @@ -24,6 +24,7 @@ const initialState: OptionState = { hiddenProperty: true, touch: true, announce: true, + dragAndDrop: true, }, defaultFormat: { fontFamily: 'Calibri', diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/OptionState.ts b/demo/scripts/controlsV2/sidePane/editorOptions/OptionState.ts index 291915581f5c..c4acf529d1f4 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/OptionState.ts +++ b/demo/scripts/controlsV2/sidePane/editorOptions/OptionState.ts @@ -26,6 +26,7 @@ export interface BuildInPluginList { hiddenProperty: boolean; touch: boolean; announce: boolean; + dragAndDrop: boolean; } export interface OptionState { diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/Plugins.tsx b/demo/scripts/controlsV2/sidePane/editorOptions/Plugins.tsx index cad56c7d3920..44022a59ccb0 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/Plugins.tsx +++ b/demo/scripts/controlsV2/sidePane/editorOptions/Plugins.tsx @@ -360,6 +360,7 @@ export class Plugins extends PluginsBase { {this.renderPluginItem('hiddenProperty', 'Hidden Property')} {this.renderPluginItem('touch', 'Touch')} {this.renderPluginItem('announce', 'Announce')} + {this.renderPluginItem('dragAndDrop', 'DragAndDrop')} ); diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/codes/PluginsCode.ts b/demo/scripts/controlsV2/sidePane/editorOptions/codes/PluginsCode.ts index 63cbde5f6751..c33dad30f78f 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/codes/PluginsCode.ts +++ b/demo/scripts/controlsV2/sidePane/editorOptions/codes/PluginsCode.ts @@ -5,6 +5,7 @@ import { OptionState } from '../OptionState'; import { WatermarkCode } from './WatermarkCode'; import { + DragAndDropPluginCode, EditPluginCode, PastePluginCode, TableEditPluginCode, @@ -45,6 +46,7 @@ export class PluginsCode extends PluginsCodeBase { pluginList.watermark && new WatermarkCode(state.watermarkText), pluginList.markdown && new MarkdownCode(state.markdownOptions), pluginList.imageEditPlugin && new ImageEditPluginCode(), + pluginList.dragAndDrop && new DragAndDropPluginCode(), ]); } } diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/codes/SimplePluginCode.ts b/demo/scripts/controlsV2/sidePane/editorOptions/codes/SimplePluginCode.ts index b078ab59a2ac..c3ff947cace6 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/codes/SimplePluginCode.ts +++ b/demo/scripts/controlsV2/sidePane/editorOptions/codes/SimplePluginCode.ts @@ -39,3 +39,9 @@ export class ImageEditPluginCode extends SimplePluginCode { super('ImageEditPlugin'); } } + +export class DragAndDropPluginCode extends SimplePluginCode { + constructor() { + super('DragAndDropPlugin'); + } +} diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/domEvent/DOMEventPlugin.ts b/packages/roosterjs-content-model-core/lib/corePlugin/domEvent/DOMEventPlugin.ts index 74135ff0fb5b..317dbb77559c 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/domEvent/DOMEventPlugin.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/domEvent/DOMEventPlugin.ts @@ -85,7 +85,7 @@ class DOMEventPlugin implements PluginWithState { // 4. Drag and Drop event dragstart: { beforeDispatch: this.onDragStart }, - drop: { beforeDispatch: this.onDrop }, + drop: { beforeDispatch: (event: DragEvent) => this.onDrop(event) }, // 5. Pointer event pointerdown: { beforeDispatch: (event: PointerEvent) => this.onPointerDown(event) }, @@ -137,17 +137,23 @@ class DOMEventPlugin implements PluginWithState { } }; - private onDrop = () => { - const doc = this.editor?.getDocument(); - - doc?.defaultView?.requestAnimationFrame(() => { - if (this.editor) { - this.editor.takeSnapshot(); - this.editor.triggerEvent('contentChanged', { - source: ChangeSource.Drop, + private onDrop = (e: DragEvent) => { + if (this.editor) { + const beforeDropEvent = this.editor.triggerEvent('beforeDrop', { + rawEvent: e, + }); + if (!beforeDropEvent?.rawEvent.defaultPrevented) { + const doc = this.editor.getDocument(); + doc?.defaultView?.requestAnimationFrame(() => { + if (this.editor) { + this.editor.takeSnapshot(); + this.editor.triggerEvent('contentChanged', { + source: ChangeSource.Drop, + }); + } }); } - }); + } }; private onScroll = (e: Event) => { diff --git a/packages/roosterjs-content-model-core/test/corePlugin/domEvent/DomEventPluginTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/domEvent/DomEventPluginTest.ts index 7285bbb16376..25b6897d9562 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/domEvent/DomEventPluginTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/domEvent/DomEventPluginTest.ts @@ -209,7 +209,6 @@ describe('DOMEventPlugin verify event handlers while disallow keyboard event pro expect(triggerEventSpy).toHaveBeenCalled(); }); - it('verify input event for non-character value', () => { spyOn(eventUtils, 'isCharacterValue').and.returnValue(false); const stopPropagation = jasmine.createSpy(); @@ -282,7 +281,6 @@ describe('DOMEventPlugin verify event handlers while disallow keyboard event pro expect(stopPropagation).toHaveBeenCalled(); expect(triggerEventSpy).toHaveBeenCalled(); }); - }); describe('DOMEventPlugin handle mouse down and mouse up event', () => { @@ -553,8 +551,16 @@ describe('DOMEventPlugin handle other event', () => { it('Trigger onDrop event', () => { const takeSnapshotSpy = jasmine.createSpy('takeSnapshot'); editor.takeSnapshot = takeSnapshotSpy; + const mockedEvent = { + dataTransfer: { + getData: () => '', + }, + defaultPrevented: false, + } as any; - eventMap.drop.beforeDispatch(); + triggerEvent.and.returnValue({ rawEvent: mockedEvent }); + + eventMap.drop.beforeDispatch(mockedEvent); expect(plugin.getState()).toEqual({ isInIME: false, scrollContainer: scrollContainer, @@ -562,9 +568,33 @@ describe('DOMEventPlugin handle other event', () => { mouseDownY: null, mouseUpEventListerAdded: false, }); + expect(triggerEvent).toHaveBeenCalledWith('beforeDrop', { + rawEvent: mockedEvent, + }); expect(takeSnapshotSpy).toHaveBeenCalledWith(); expect(triggerEvent).toHaveBeenCalledWith('contentChanged', { source: ChangeSource.Drop, }); }); + + it('Trigger onDrop event with defaultPrevented', () => { + const takeSnapshotSpy = jasmine.createSpy('takeSnapshot'); + editor.takeSnapshot = takeSnapshotSpy; + const mockedEvent = { + dataTransfer: { + getData: () => '', + }, + defaultPrevented: true, + } as any; + + triggerEvent.and.returnValue({ rawEvent: mockedEvent }); + + eventMap.drop.beforeDispatch(mockedEvent); + + expect(triggerEvent).toHaveBeenCalledWith('beforeDrop', { + rawEvent: mockedEvent, + }); + expect(takeSnapshotSpy).not.toHaveBeenCalled(); + expect(triggerEvent).not.toHaveBeenCalledWith('contentChanged', jasmine.anything()); + }); }); diff --git a/packages/roosterjs-content-model-plugins/lib/dragAndDrop/DragAndDropPlugin.ts b/packages/roosterjs-content-model-plugins/lib/dragAndDrop/DragAndDropPlugin.ts new file mode 100644 index 000000000000..2f9dcab15887 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/lib/dragAndDrop/DragAndDropPlugin.ts @@ -0,0 +1,96 @@ +import { handleDroppedContent } from './utils/handleDroppedContent'; +import type { EditorPlugin, IEditor, PluginEvent } from 'roosterjs-content-model-types'; + +/** + * Options for DragAndDrop plugin + */ +export interface DragAndDropOptions { + /** + * Forbidden elements that cannot be dropped in the editor + * @default ['iframe'] + */ + forbiddenElements?: string[]; +} + +const DefaultOptions = { + forbiddenElements: ['iframe'], +}; + +/** + * DragAndDrop plugin, handles ContentChanged event when change source is "Drop" + * to sanitize dropped content, similar to how PastePlugin sanitizes pasted content. + */ +export class DragAndDropPlugin implements EditorPlugin { + private editor: IEditor | null = null; + private forbiddenElements: string[] = []; + private isInternalDragging: boolean = false; + private disposer: (() => void) | null = null; + + /** + * Construct a new instance of DragAndDropPlugin + */ + constructor(options: DragAndDropOptions = DefaultOptions) { + this.forbiddenElements = options.forbiddenElements ?? []; + } + + /** + * Get name of this plugin + */ + getName() { + return 'DragAndDrop'; + } + + /** + * The first method that editor will call to a plugin when editor is initializing. + * It will pass in the editor instance, plugin should take this chance to save the + * editor reference so that it can call to any editor method or format API later. + * @param editor The editor object + */ + initialize(editor: IEditor) { + this.editor = editor; + this.disposer = editor.attachDomEvent({ + dragstart: { + beforeDispatch: _ev => { + this.isInternalDragging = true; + }, + }, + }); + } + + /** + * The last method that editor will call to a plugin before it is disposed. + * Plugin can take this chance to clear the reference to editor. After this method is + * called, plugin should not call to any editor method since it will result in error. + */ + dispose() { + this.editor = null; + if (this.disposer) { + this.disposer(); + this.disposer = null; + } + this.isInternalDragging = false; + this.forbiddenElements = []; + } + + /** + * Core method for a plugin. Once an event happens in editor, editor will call this + * method of each plugin to handle the event as long as the event is not handled + * exclusively by another plugin. + * @param event The event to handle: + */ + onPluginEvent(event: PluginEvent) { + if (this.editor && event.eventType == 'beforeDrop') { + if (this.isInternalDragging) { + this.isInternalDragging = false; + } else { + const dropEvent = event.rawEvent; + const html = dropEvent.dataTransfer?.getData('text/html'); + + if (html) { + handleDroppedContent(this.editor, dropEvent, html, this.forbiddenElements); + } + } + return; + } + } +} diff --git a/packages/roosterjs-content-model-plugins/lib/dragAndDrop/utils/cleanForbiddenElements.ts b/packages/roosterjs-content-model-plugins/lib/dragAndDrop/utils/cleanForbiddenElements.ts new file mode 100644 index 000000000000..881280cc164e --- /dev/null +++ b/packages/roosterjs-content-model-plugins/lib/dragAndDrop/utils/cleanForbiddenElements.ts @@ -0,0 +1,18 @@ +/** + * @internal + * Remove all forbidden elements from a parsed HTML document + * @param doc The parsed HTML document to clean + * @param forbiddenElements Array of tag names to remove (e.g., ['iframe', 'script']) + */ +export function cleanForbiddenElements(doc: Document, forbiddenElements: string[]): void { + if (forbiddenElements.length === 0) { + return; + } + + const selector = forbiddenElements.join(','); + const elements = Array.from(doc.body.querySelectorAll(selector)); + + for (const element of elements) { + element.parentNode?.removeChild(element); + } +} diff --git a/packages/roosterjs-content-model-plugins/lib/dragAndDrop/utils/handleDroppedContent.ts b/packages/roosterjs-content-model-plugins/lib/dragAndDrop/utils/handleDroppedContent.ts new file mode 100644 index 000000000000..a6e27b65badb --- /dev/null +++ b/packages/roosterjs-content-model-plugins/lib/dragAndDrop/utils/handleDroppedContent.ts @@ -0,0 +1,50 @@ +import { cleanForbiddenElements } from './cleanForbiddenElements'; +import { + createDomToModelContext, + domToContentModel, + getNodePositionFromEvent, + mergeModel, +} from 'roosterjs-content-model-dom'; +import type { IEditor } from 'roosterjs-content-model-types'; + +/** + * @internal + * Handle dropped HTML content by inserting it at the drop position + */ +export function handleDroppedContent( + editor: IEditor, + event: DragEvent, + html: string, + forbiddenElements: string[] +): void { + const doc = editor.getDocument(); + const domPosition = getNodePositionFromEvent(doc, editor.getDOMHelper(), event.x, event.y); + + if (domPosition) { + event.preventDefault(); + event.stopPropagation(); + + const range = doc.createRange(); + range.setStart(domPosition.node, domPosition.offset); + range.collapse(true); + + const parsedHtml = editor.getDOMCreator().htmlToDOM(html); + cleanForbiddenElements(parsedHtml, forbiddenElements); + + const droppedModel = domToContentModel(parsedHtml.body, createDomToModelContext()); + + editor.formatContentModel( + (model, context) => { + mergeModel(model, droppedModel, context); + return true; + }, + { + selectionOverride: { + type: 'range', + range, + isReverted: false, + }, + } + ); + } +} diff --git a/packages/roosterjs-content-model-plugins/lib/index.ts b/packages/roosterjs-content-model-plugins/lib/index.ts index e5f452e5c837..f8a6a87222fe 100644 --- a/packages/roosterjs-content-model-plugins/lib/index.ts +++ b/packages/roosterjs-content-model-plugins/lib/index.ts @@ -53,3 +53,4 @@ export { FindReplaceContext } from './findReplace/types/FindReplaceContext'; export { HighlightHelper } from './findReplace/types/HighlightHelper'; export { FindReplaceHighlightOptions } from './findReplace/types/FindReplaceHighlightOptions'; export { AnnouncePlugin } from './announce/AnnouncePlugin'; +export { DragAndDropPlugin, DragAndDropOptions } from './dragAndDrop/DragAndDropPlugin'; diff --git a/packages/roosterjs-content-model-plugins/test/dragAndDrop/DragAndDropPluginTest.ts b/packages/roosterjs-content-model-plugins/test/dragAndDrop/DragAndDropPluginTest.ts new file mode 100644 index 000000000000..c053c4da05c1 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/test/dragAndDrop/DragAndDropPluginTest.ts @@ -0,0 +1,232 @@ +import * as handleDroppedContentFile from '../../lib/dragAndDrop/utils/handleDroppedContent'; +import { DragAndDropPlugin } from '../../lib/dragAndDrop/DragAndDropPlugin'; +import { IEditor } from 'roosterjs-content-model-types'; + +describe('DragAndDropPlugin', () => { + let plugin: DragAndDropPlugin; + let editor: IEditor; + let attachDomEventSpy: jasmine.Spy; + let disposerSpy: jasmine.Spy; + let eventMap: Record; + + beforeEach(() => { + disposerSpy = jasmine.createSpy('disposer'); + attachDomEventSpy = jasmine.createSpy('attachDomEvent').and.callFake((map: any) => { + eventMap = map; + return disposerSpy; + }); + + editor = ({ + attachDomEvent: attachDomEventSpy, + } as any) as IEditor; + }); + + afterEach(() => { + plugin?.dispose(); + }); + + describe('initialization', () => { + it('should return correct name', () => { + plugin = new DragAndDropPlugin(); + expect(plugin.getName()).toBe('DragAndDrop'); + }); + + it('should initialize with default options', () => { + plugin = new DragAndDropPlugin(); + plugin.initialize(editor); + + expect(attachDomEventSpy).toHaveBeenCalled(); + expect(eventMap.dragstart).toBeDefined(); + }); + + it('should initialize with custom forbidden elements', () => { + plugin = new DragAndDropPlugin({ forbiddenElements: ['script', 'object'] }); + plugin.initialize(editor); + + expect(attachDomEventSpy).toHaveBeenCalled(); + }); + + it('should dispose correctly', () => { + plugin = new DragAndDropPlugin(); + plugin.initialize(editor); + + plugin.dispose(); + + expect(disposerSpy).toHaveBeenCalled(); + }); + }); + + describe('dragstart event', () => { + it('should set isInternalDragging to true when drag starts', () => { + plugin = new DragAndDropPlugin(); + plugin.initialize(editor); + + const target = document.createElement('div'); + + eventMap.dragstart.beforeDispatch({ target } as any); + + // Verify by checking that beforeDrop event with HTML does not call handleDroppedContent + const handleDroppedContentSpy = spyOn(handleDroppedContentFile, 'handleDroppedContent'); + + plugin.onPluginEvent({ + eventType: 'beforeDrop', + rawEvent: { + dataTransfer: { + getData: () => '
test
', + }, + } as any, + }); + + expect(handleDroppedContentSpy).not.toHaveBeenCalled(); + }); + }); + + describe('onPluginEvent - beforeDrop', () => { + let handleDroppedContentSpy: jasmine.Spy; + + beforeEach(() => { + handleDroppedContentSpy = spyOn(handleDroppedContentFile, 'handleDroppedContent'); + plugin = new DragAndDropPlugin(); + plugin.initialize(editor); + }); + + it('should call handleDroppedContent when HTML is dropped from external source', () => { + const html = '
dropped content
'; + const dropEvent = { + dataTransfer: { + getData: () => html, + }, + } as any; + + plugin.onPluginEvent({ + eventType: 'beforeDrop', + rawEvent: dropEvent, + }); + + expect(handleDroppedContentSpy).toHaveBeenCalledWith(editor, dropEvent, html, [ + 'iframe', + ]); + }); + + it('should use custom forbidden elements', () => { + plugin.dispose(); + plugin = new DragAndDropPlugin({ forbiddenElements: ['script', 'object'] }); + plugin.initialize(editor); + + const html = '
dropped content
'; + const dropEvent = { + dataTransfer: { + getData: () => html, + }, + } as any; + + plugin.onPluginEvent({ + eventType: 'beforeDrop', + rawEvent: dropEvent, + }); + + expect(handleDroppedContentSpy).toHaveBeenCalledWith(editor, dropEvent, html, [ + 'script', + 'object', + ]); + }); + + it('should not call handleDroppedContent when no HTML in dataTransfer', () => { + const dropEvent = { + dataTransfer: { + getData: () => '', + }, + } as any; + + plugin.onPluginEvent({ + eventType: 'beforeDrop', + rawEvent: dropEvent, + }); + + expect(handleDroppedContentSpy).not.toHaveBeenCalled(); + }); + + it('should not call handleDroppedContent when dataTransfer is null', () => { + const dropEvent = { + dataTransfer: null, + } as any; + + plugin.onPluginEvent({ + eventType: 'beforeDrop', + rawEvent: dropEvent, + }); + + expect(handleDroppedContentSpy).not.toHaveBeenCalled(); + }); + + it('should not call handleDroppedContent for internal drag and drop', () => { + // Simulate internal drag start + const target = document.createElement('div'); + eventMap.dragstart.beforeDispatch({ target } as any); + + const html = '
dragged content
'; + const dropEvent = { + dataTransfer: { + getData: () => html, + }, + } as any; + + plugin.onPluginEvent({ + eventType: 'beforeDrop', + rawEvent: dropEvent, + }); + + expect(handleDroppedContentSpy).not.toHaveBeenCalled(); + }); + + it('should ignore other event types', () => { + plugin.onPluginEvent({ + eventType: 'keyDown', + rawEvent: {} as any, + } as any); + + expect(handleDroppedContentSpy).not.toHaveBeenCalled(); + }); + }); + + describe('edge cases', () => { + it('should not process events when editor is null', () => { + const handleDroppedContentSpy = spyOn(handleDroppedContentFile, 'handleDroppedContent'); + + plugin = new DragAndDropPlugin(); + // Don't initialize, so editor is null + + plugin.onPluginEvent({ + eventType: 'beforeDrop', + rawEvent: { + dataTransfer: { + getData: () => '
test
', + }, + } as any, + }); + + expect(handleDroppedContentSpy).not.toHaveBeenCalled(); + }); + + it('should handle empty forbidden elements array', () => { + const handleDroppedContentSpy = spyOn(handleDroppedContentFile, 'handleDroppedContent'); + + plugin = new DragAndDropPlugin({ forbiddenElements: [] }); + plugin.initialize(editor); + + const html = '
content
'; + const dropEvent = { + dataTransfer: { + getData: () => html, + }, + } as any; + + plugin.onPluginEvent({ + eventType: 'beforeDrop', + rawEvent: dropEvent, + }); + + expect(handleDroppedContentSpy).toHaveBeenCalledWith(editor, dropEvent, html, []); + }); + }); +}); diff --git a/packages/roosterjs-content-model-plugins/test/dragAndDrop/utils/cleanForbiddenElementsTest.ts b/packages/roosterjs-content-model-plugins/test/dragAndDrop/utils/cleanForbiddenElementsTest.ts new file mode 100644 index 000000000000..9e96e918b992 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/test/dragAndDrop/utils/cleanForbiddenElementsTest.ts @@ -0,0 +1,100 @@ +import { cleanForbiddenElements } from '../../../lib/dragAndDrop/utils/cleanForbiddenElements'; + +describe('cleanForbiddenElements', () => { + it('should do nothing when forbiddenElements is empty', () => { + const doc = document.implementation.createHTMLDocument(); + doc.body.innerHTML = '
'; + + cleanForbiddenElements(doc, []); + + expect(doc.body.innerHTML).toBe( + '
' + ); + }); + + it('should remove iframe elements when iframe is in forbiddenElements', () => { + const doc = document.implementation.createHTMLDocument(); + doc.body.innerHTML = '

content

'; + + cleanForbiddenElements(doc, ['iframe']); + + expect(doc.body.innerHTML).toBe('

content

'); + }); + + it('should remove script elements when script is in forbiddenElements', () => { + const doc = document.implementation.createHTMLDocument(); + doc.body.innerHTML = '

content

'; + + cleanForbiddenElements(doc, ['script']); + + expect(doc.body.innerHTML).toBe('

content

'); + }); + + it('should remove multiple forbidden element types', () => { + const doc = document.implementation.createHTMLDocument(); + doc.body.innerHTML = + '

content

'; + + cleanForbiddenElements(doc, ['iframe', 'script']); + + expect(doc.body.innerHTML).toBe('

content

'); + }); + + it('should remove all instances of forbidden elements', () => { + const doc = document.implementation.createHTMLDocument(); + doc.body.innerHTML = + '

text'; + + cleanForbiddenElements(doc, ['iframe']); + + expect(doc.body.innerHTML).toBe('

text'); + }); + + it('should remove nested forbidden elements', () => { + const doc = document.implementation.createHTMLDocument(); + doc.body.innerHTML = '
'; + + cleanForbiddenElements(doc, ['iframe']); + + expect(doc.body.innerHTML).toBe('
'); + }); + + it('should handle custom forbidden elements', () => { + const doc = document.implementation.createHTMLDocument(); + doc.body.innerHTML = + '

safe

'; + + cleanForbiddenElements(doc, ['object', 'embed']); + + expect(doc.body.innerHTML).toBe('

safe

'); + }); + + it('should not remove elements not in forbiddenElements list', () => { + const doc = document.implementation.createHTMLDocument(); + doc.body.innerHTML = '

content

'; + + cleanForbiddenElements(doc, ['script']); + + expect(doc.body.innerHTML).toBe( + '

content

' + ); + }); + + it('should handle empty body', () => { + const doc = document.implementation.createHTMLDocument(); + doc.body.innerHTML = ''; + + cleanForbiddenElements(doc, ['iframe', 'script']); + + expect(doc.body.innerHTML).toBe(''); + }); + + it('should handle body with no forbidden elements', () => { + const doc = document.implementation.createHTMLDocument(); + doc.body.innerHTML = '

safe content

more content
'; + + cleanForbiddenElements(doc, ['iframe', 'script']); + + expect(doc.body.innerHTML).toBe('

safe content

more content
'); + }); +}); diff --git a/packages/roosterjs-content-model-plugins/test/dragAndDrop/utils/handleDroppedContentTest.ts b/packages/roosterjs-content-model-plugins/test/dragAndDrop/utils/handleDroppedContentTest.ts new file mode 100644 index 000000000000..3ddc805eb21c --- /dev/null +++ b/packages/roosterjs-content-model-plugins/test/dragAndDrop/utils/handleDroppedContentTest.ts @@ -0,0 +1,482 @@ +import * as cleanForbiddenElementsFile from '../../../lib/dragAndDrop/utils/cleanForbiddenElements'; +import * as getNodePositionFromEventFile from 'roosterjs-content-model-dom/lib/domUtils/event/getNodePositionFromEvent'; +import { handleDroppedContent } from '../../../lib/dragAndDrop/utils/handleDroppedContent'; +import { + ContentModelDocument, + ContentModelParagraph, + ContentModelText, + IEditor, +} from 'roosterjs-content-model-types'; +import { + createContentModelDocument, + createParagraph, + createSelectionMarker, + createText, +} from 'roosterjs-content-model-dom'; + +describe('handleDroppedContent', () => { + let editor: IEditor; + let doc: Document; + let getNodePositionFromEventSpy: jasmine.Spy; + let getDOMHelperSpy: jasmine.Spy; + let getDOMCreatorSpy: jasmine.Spy; + let htmlToDOMSpy: jasmine.Spy; + let formatContentModelSpy: jasmine.Spy; + let cleanForbiddenElementsSpy: jasmine.Spy; + + beforeEach(() => { + doc = document; + + getNodePositionFromEventSpy = spyOn( + getNodePositionFromEventFile, + 'getNodePositionFromEvent' + ); + cleanForbiddenElementsSpy = spyOn(cleanForbiddenElementsFile, 'cleanForbiddenElements'); + + getDOMHelperSpy = jasmine.createSpy('getDOMHelper').and.returnValue({}); + htmlToDOMSpy = jasmine.createSpy('htmlToDOM'); + getDOMCreatorSpy = jasmine.createSpy('getDOMCreator').and.returnValue({ + htmlToDOM: htmlToDOMSpy, + }); + formatContentModelSpy = jasmine.createSpy('formatContentModel'); + + editor = ({ + getDocument: () => doc, + getDOMHelper: getDOMHelperSpy, + getDOMCreator: getDOMCreatorSpy, + formatContentModel: formatContentModelSpy, + } as any) as IEditor; + }); + + it('should do nothing when domPosition is null', () => { + getNodePositionFromEventSpy.and.returnValue(null); + const preventDefaultSpy = jasmine.createSpy('preventDefault'); + const stopPropagationSpy = jasmine.createSpy('stopPropagation'); + + const event = { + x: 100, + y: 200, + preventDefault: preventDefaultSpy, + stopPropagation: stopPropagationSpy, + } as any; + + handleDroppedContent(editor, event, '

test

', ['iframe']); + + expect(getNodePositionFromEventSpy).toHaveBeenCalledWith(doc, {}, 100, 200); + expect(preventDefaultSpy).not.toHaveBeenCalled(); + expect(stopPropagationSpy).not.toHaveBeenCalled(); + expect(htmlToDOMSpy).not.toHaveBeenCalled(); + expect(formatContentModelSpy).not.toHaveBeenCalled(); + }); + + it('should insert dropped content at the correct position', () => { + const textNode = document.createTextNode('test'); + getNodePositionFromEventSpy.and.returnValue({ + node: textNode, + offset: 2, + }); + + const parsedDoc = document.implementation.createHTMLDocument(); + parsedDoc.body.innerHTML = '

dropped content

'; + htmlToDOMSpy.and.returnValue(parsedDoc); + + const preventDefaultSpy = jasmine.createSpy('preventDefault'); + const stopPropagationSpy = jasmine.createSpy('stopPropagation'); + + const event = { + x: 100, + y: 200, + preventDefault: preventDefaultSpy, + stopPropagation: stopPropagationSpy, + } as any; + + handleDroppedContent(editor, event, '

dropped content

', ['iframe', 'script']); + + expect(preventDefaultSpy).toHaveBeenCalled(); + expect(stopPropagationSpy).toHaveBeenCalled(); + expect(htmlToDOMSpy).toHaveBeenCalledWith('

dropped content

'); + expect(cleanForbiddenElementsSpy).toHaveBeenCalledWith(parsedDoc, ['iframe', 'script']); + expect(formatContentModelSpy).toHaveBeenCalled(); + + const formatCall = formatContentModelSpy.calls.mostRecent(); + const options = formatCall.args[1]; + expect(options.selectionOverride.type).toBe('range'); + expect(options.selectionOverride.isReverted).toBe(false); + }); + + it('should create range at correct position', () => { + const container = document.createElement('div'); + container.innerHTML = 'hello world'; + const textNode = container.firstChild!; + + getNodePositionFromEventSpy.and.returnValue({ + node: textNode, + offset: 5, + }); + + const parsedDoc = document.implementation.createHTMLDocument(); + parsedDoc.body.innerHTML = 'inserted'; + htmlToDOMSpy.and.returnValue(parsedDoc); + + const event = { + x: 50, + y: 75, + preventDefault: jasmine.createSpy('preventDefault'), + stopPropagation: jasmine.createSpy('stopPropagation'), + } as any; + + handleDroppedContent(editor, event, 'inserted', []); + + const formatCall = formatContentModelSpy.calls.mostRecent(); + const options = formatCall.args[1]; + const range = options.selectionOverride.range as Range; + + expect(range.startContainer).toBe(textNode); + expect(range.startOffset).toBe(5); + expect(range.collapsed).toBe(true); + }); + + it('should call cleanForbiddenElements with correct parameters', () => { + const textNode = document.createTextNode('test'); + getNodePositionFromEventSpy.and.returnValue({ + node: textNode, + offset: 0, + }); + + const parsedDoc = document.implementation.createHTMLDocument(); + parsedDoc.body.innerHTML = '
'; + htmlToDOMSpy.and.returnValue(parsedDoc); + + const event = { + x: 0, + y: 0, + preventDefault: jasmine.createSpy('preventDefault'), + stopPropagation: jasmine.createSpy('stopPropagation'), + } as any; + + const forbiddenElements = ['iframe', 'script', 'object']; + handleDroppedContent(editor, event, '
', forbiddenElements); + + expect(cleanForbiddenElementsSpy).toHaveBeenCalledWith(parsedDoc, forbiddenElements); + }); + + it('should handle empty forbidden elements list', () => { + const textNode = document.createTextNode('test'); + getNodePositionFromEventSpy.and.returnValue({ + node: textNode, + offset: 0, + }); + + const parsedDoc = document.implementation.createHTMLDocument(); + parsedDoc.body.innerHTML = '

content

'; + htmlToDOMSpy.and.returnValue(parsedDoc); + + const event = { + x: 0, + y: 0, + preventDefault: jasmine.createSpy('preventDefault'), + stopPropagation: jasmine.createSpy('stopPropagation'), + } as any; + + handleDroppedContent(editor, event, '

content

', []); + + expect(cleanForbiddenElementsSpy).toHaveBeenCalledWith(parsedDoc, []); + expect(formatContentModelSpy).toHaveBeenCalled(); + }); +}); + +describe('handleDroppedContent - model verification', () => { + let editor: IEditor; + let doc: Document; + let getNodePositionFromEventSpy: jasmine.Spy; + let getDOMHelperSpy: jasmine.Spy; + let getDOMCreatorSpy: jasmine.Spy; + let htmlToDOMSpy: jasmine.Spy; + let capturedCallback: ((model: ContentModelDocument, context: any) => boolean) | null; + + beforeEach(() => { + doc = document; + capturedCallback = null; + + getNodePositionFromEventSpy = spyOn( + getNodePositionFromEventFile, + 'getNodePositionFromEvent' + ); + + getDOMHelperSpy = jasmine.createSpy('getDOMHelper').and.returnValue({}); + htmlToDOMSpy = jasmine.createSpy('htmlToDOM'); + getDOMCreatorSpy = jasmine.createSpy('getDOMCreator').and.returnValue({ + htmlToDOM: htmlToDOMSpy, + }); + + editor = ({ + getDocument: () => doc, + getDOMHelper: getDOMHelperSpy, + getDOMCreator: getDOMCreatorSpy, + formatContentModel: (callback: any, _options: any) => { + capturedCallback = callback; + }, + } as any) as IEditor; + }); + + it('should merge dropped paragraph with text into model', () => { + const textNode = document.createTextNode('existing'); + getNodePositionFromEventSpy.and.returnValue({ + node: textNode, + offset: 0, + }); + + const parsedDoc = document.implementation.createHTMLDocument(); + parsedDoc.body.innerHTML = '

dropped text

'; + htmlToDOMSpy.and.returnValue(parsedDoc); + + const event = { + x: 0, + y: 0, + preventDefault: jasmine.createSpy('preventDefault'), + stopPropagation: jasmine.createSpy('stopPropagation'), + } as any; + + handleDroppedContent(editor, event, '

dropped text

', []); + + // Create a model to merge into + const model = createContentModelDocument(); + const para = createParagraph(); + para.segments.push(createSelectionMarker()); + model.blocks.push(para); + + // Execute the captured callback + expect(capturedCallback).not.toBeNull(); + const result = capturedCallback!(model, {}); + + expect(result).toBe(true); + // Verify model has been modified - should now contain the dropped content + expect(model.blocks.length).toBeGreaterThan(0); + + // Find text segments in the model + const textSegments: ContentModelText[] = []; + model.blocks.forEach(block => { + if (block.blockType === 'Paragraph') { + (block as ContentModelParagraph).segments.forEach(segment => { + if (segment.segmentType === 'Text') { + textSegments.push(segment as ContentModelText); + } + }); + } + }); + + expect(textSegments.length).toBeGreaterThan(0); + expect(textSegments.some(seg => seg.text === 'dropped text')).toBe(true); + }); + + it('should merge dropped bold text into model', () => { + const textNode = document.createTextNode('existing'); + getNodePositionFromEventSpy.and.returnValue({ + node: textNode, + offset: 0, + }); + + const parsedDoc = document.implementation.createHTMLDocument(); + parsedDoc.body.innerHTML = '

bold text

'; + htmlToDOMSpy.and.returnValue(parsedDoc); + + const event = { + x: 0, + y: 0, + preventDefault: jasmine.createSpy('preventDefault'), + stopPropagation: jasmine.createSpy('stopPropagation'), + } as any; + + handleDroppedContent(editor, event, '

bold text

', []); + + // Create initial model with selection + const model = createContentModelDocument(); + const para = createParagraph(); + para.segments.push(createSelectionMarker()); + model.blocks.push(para); + + // Execute callback + expect(capturedCallback).not.toBeNull(); + const result = capturedCallback!(model, {}); + + expect(result).toBe(true); + + // Find text segments and verify bold formatting + const textSegments: ContentModelText[] = []; + model.blocks.forEach(block => { + if (block.blockType === 'Paragraph') { + (block as ContentModelParagraph).segments.forEach(segment => { + if (segment.segmentType === 'Text') { + textSegments.push(segment as ContentModelText); + } + }); + } + }); + + const boldSegment = textSegments.find(seg => seg.text === 'bold text'); + expect(boldSegment).toBeDefined(); + expect(boldSegment?.format.fontWeight).toBe('bold'); + }); + + it('should merge dropped content into existing model with text', () => { + const textNode = document.createTextNode('existing'); + getNodePositionFromEventSpy.and.returnValue({ + node: textNode, + offset: 0, + }); + + const parsedDoc = document.implementation.createHTMLDocument(); + parsedDoc.body.innerHTML = '

new content

'; + htmlToDOMSpy.and.returnValue(parsedDoc); + + const event = { + x: 0, + y: 0, + preventDefault: jasmine.createSpy('preventDefault'), + stopPropagation: jasmine.createSpy('stopPropagation'), + } as any; + + handleDroppedContent(editor, event, '

new content

', []); + + // Create model with existing text + const model = createContentModelDocument(); + const para = createParagraph(); + para.segments.push(createText('existing text'), createSelectionMarker()); + model.blocks.push(para); + + // Verify initial state + expect(model.blocks.length).toBe(1); + + // Execute callback + expect(capturedCallback).not.toBeNull(); + const result = capturedCallback!(model, {}); + + expect(result).toBe(true); + + // Find all text in the model after merge + const allText: string[] = []; + model.blocks.forEach(block => { + if (block.blockType === 'Paragraph') { + (block as ContentModelParagraph).segments.forEach(segment => { + if (segment.segmentType === 'Text') { + allText.push((segment as ContentModelText).text); + } + }); + } + }); + + // Model should contain both existing and new content + expect(allText.some(text => text.includes('existing text'))).toBe(true); + expect(allText.some(text => text === 'new content')).toBe(true); + }); + + it('should remove forbidden elements before merging into model', () => { + const textNode = document.createTextNode('existing'); + getNodePositionFromEventSpy.and.returnValue({ + node: textNode, + offset: 0, + }); + + const parsedDoc = document.implementation.createHTMLDocument(); + parsedDoc.body.innerHTML = '

safe content

'; + htmlToDOMSpy.and.returnValue(parsedDoc); + + const event = { + x: 0, + y: 0, + preventDefault: jasmine.createSpy('preventDefault'), + stopPropagation: jasmine.createSpy('stopPropagation'), + } as any; + + handleDroppedContent(editor, event, '

safe content

', [ + 'iframe', + ]); + + // Create model + const model = createContentModelDocument(); + const para = createParagraph(); + para.segments.push(createSelectionMarker()); + model.blocks.push(para); + + // Execute callback + expect(capturedCallback).not.toBeNull(); + const result = capturedCallback!(model, {}); + + expect(result).toBe(true); + + // Verify no iframe entity in the model (iframe would become an entity) + let hasIframeEntity = false; + model.blocks.forEach(block => { + if (block.blockType === 'Entity') { + const wrapper = (block as any).wrapper as HTMLElement; + if (wrapper?.tagName?.toLowerCase() === 'iframe') { + hasIframeEntity = true; + } + } + }); + + expect(hasIframeEntity).toBe(false); + + // Verify safe content is present + const textSegments: ContentModelText[] = []; + model.blocks.forEach(block => { + if (block.blockType === 'Paragraph') { + (block as ContentModelParagraph).segments.forEach(segment => { + if (segment.segmentType === 'Text') { + textSegments.push(segment as ContentModelText); + } + }); + } + }); + + expect(textSegments.some(seg => seg.text === 'safe content')).toBe(true); + }); + + it('should merge multiple paragraphs into model', () => { + const textNode = document.createTextNode('existing'); + getNodePositionFromEventSpy.and.returnValue({ + node: textNode, + offset: 0, + }); + + const parsedDoc = document.implementation.createHTMLDocument(); + parsedDoc.body.innerHTML = '

first paragraph

second paragraph

'; + htmlToDOMSpy.and.returnValue(parsedDoc); + + const event = { + x: 0, + y: 0, + preventDefault: jasmine.createSpy('preventDefault'), + stopPropagation: jasmine.createSpy('stopPropagation'), + } as any; + + handleDroppedContent(editor, event, '

first paragraph

second paragraph

', []); + + // Create model + const model = createContentModelDocument(); + const para = createParagraph(); + para.segments.push(createSelectionMarker()); + model.blocks.push(para); + + // Execute callback + expect(capturedCallback).not.toBeNull(); + const result = capturedCallback!(model, {}); + + expect(result).toBe(true); + + // Find all text content + const allText: string[] = []; + model.blocks.forEach(block => { + if (block.blockType === 'Paragraph') { + (block as ContentModelParagraph).segments.forEach(segment => { + if (segment.segmentType === 'Text') { + allText.push((segment as ContentModelText).text); + } + }); + } + }); + + expect(allText.some(text => text === 'first paragraph')).toBe(true); + expect(allText.some(text => text === 'second paragraph')).toBe(true); + }); +}); diff --git a/packages/roosterjs-content-model-types/lib/event/BeforeDropEvent.ts b/packages/roosterjs-content-model-types/lib/event/BeforeDropEvent.ts new file mode 100644 index 000000000000..aba3290d9b8e --- /dev/null +++ b/packages/roosterjs-content-model-types/lib/event/BeforeDropEvent.ts @@ -0,0 +1,6 @@ +import type { BasePluginDomEvent } from './BasePluginEvent'; + +/** + * Data of BeforeDropEvent + */ +export interface BeforeDropEvent extends BasePluginDomEvent<'beforeDrop', DragEvent> {} diff --git a/packages/roosterjs-content-model-types/lib/event/PluginEvent.ts b/packages/roosterjs-content-model-types/lib/event/PluginEvent.ts index 0df043681822..0480c66e284f 100644 --- a/packages/roosterjs-content-model-types/lib/event/PluginEvent.ts +++ b/packages/roosterjs-content-model-types/lib/event/PluginEvent.ts @@ -2,6 +2,7 @@ import type { FindResultChangedEvent } from './FindResultChangedEvent'; import type { BeforeAddUndoSnapshotEvent } from './BeforeAddUndoSnapshotEvent'; import type { BeforeCutCopyEvent } from './BeforeCutCopyEvent'; import type { BeforeDisposeEvent } from './BeforeDisposeEvent'; +import type { BeforeDropEvent } from './BeforeDropEvent'; import type { BeforeKeyboardEditingEvent } from './BeforeKeyboardEditingEvent'; import type { BeforePasteEvent } from './BeforePasteEvent'; import type { BeforeSetContentEvent } from './BeforeSetContentEvent'; @@ -32,6 +33,7 @@ export type PluginEvent = | BeforeAddUndoSnapshotEvent | BeforeCutCopyEvent | BeforeDisposeEvent + | BeforeDropEvent | BeforeKeyboardEditingEvent | BeforeLogicalRootChangeEvent | BeforePasteEvent diff --git a/packages/roosterjs-content-model-types/lib/event/PluginEventType.ts b/packages/roosterjs-content-model-types/lib/event/PluginEventType.ts index 310732fb9f1b..5b2371532eec 100644 --- a/packages/roosterjs-content-model-types/lib/event/PluginEventType.ts +++ b/packages/roosterjs-content-model-types/lib/event/PluginEventType.ts @@ -166,4 +166,9 @@ export type PluginEventType = /** * Find result changed event */ - | 'findResultChanged'; + | 'findResultChanged' + + /** + * Let plugin know when a content will be dropped + */ + | 'beforeDrop'; diff --git a/packages/roosterjs-content-model-types/lib/index.ts b/packages/roosterjs-content-model-types/lib/index.ts index e27aa0946737..028f45357bfa 100644 --- a/packages/roosterjs-content-model-types/lib/index.ts +++ b/packages/roosterjs-content-model-types/lib/index.ts @@ -469,6 +469,7 @@ export { BasePluginEvent, BasePluginDomEvent } from './event/BasePluginEvent'; export { BeforeAddUndoSnapshotEvent } from './event/BeforeAddUndoSnapshotEvent'; export { BeforeCutCopyEvent } from './event/BeforeCutCopyEvent'; export { BeforeDisposeEvent } from './event/BeforeDisposeEvent'; +export { BeforeDropEvent } from './event/BeforeDropEvent'; export { BeforeKeyboardEditingEvent } from './event/BeforeKeyboardEditingEvent'; export { BeforePasteEvent, MergePastedContentFunc } from './event/BeforePasteEvent'; export { BeforeSetContentEvent } from './event/BeforeSetContentEvent'; From 576e6cb2ca8297a1fd74af7a86125fee1fa736b8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 10 Apr 2026 09:56:09 -0700 Subject: [PATCH 09/15] Bump lodash from 4.17.23 to 4.18.1 (#3321) Bumps [lodash](https://github.com/lodash/lodash) from 4.17.23 to 4.18.1. - [Release notes](https://github.com/lodash/lodash/releases) - [Commits](https://github.com/lodash/lodash/compare/4.17.23...4.18.1) --- updated-dependencies: - dependency-name: lodash dependency-version: 4.18.1 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index b7075d7ea2fc..70bf167a22d3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4612,9 +4612,9 @@ lodash.merge@^4.6.2: integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== lodash@^4.0.1, lodash@^4.17.11, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.21: - version "4.17.23" - resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.23.tgz#f113b0378386103be4f6893388c73d0bde7f2c5a" - integrity sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w== + version "4.18.1" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.18.1.tgz#ff2b66c1f6326d59513de2407bf881439812771c" + integrity sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q== log4js@^6.4.1: version "6.4.1" From 971eb40bdd27f0953cc3271ae836c1fbad1c266e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 21 Apr 2026 15:06:55 -0700 Subject: [PATCH 10/15] Bump follow-redirects from 1.15.6 to 1.16.0 (#3322) Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.15.6 to 1.16.0. - [Release notes](https://github.com/follow-redirects/follow-redirects/releases) - [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.15.6...v1.16.0) --- updated-dependencies: - dependency-name: follow-redirects dependency-version: 1.16.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 70bf167a22d3..aa6c3bd98d70 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3075,9 +3075,9 @@ flatted@^3.2.4, flatted@^3.2.7: integrity sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA== follow-redirects@^1.0.0: - version "1.15.6" - resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.6.tgz#7f815c0cda4249c74ff09e95ef97c23b5fd0399b" - integrity sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA== + version "1.16.0" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.16.0.tgz#28474a159d3b9d11ef62050a14ed60e4df6d61bc" + integrity sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw== for-each@^0.3.3: version "0.3.3" From 2f85ab6a1cfd5d02360e9c0930721257392413c5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 21 Apr 2026 15:07:49 -0700 Subject: [PATCH 11/15] Bump dompurify from 2.5.4 to 3.4.0 (#3323) Bumps [dompurify](https://github.com/cure53/DOMPurify) from 2.5.4 to 3.4.0. - [Release notes](https://github.com/cure53/DOMPurify/releases) - [Commits](https://github.com/cure53/DOMPurify/compare/2.5.4...3.4.0) --- updated-dependencies: - dependency-name: dompurify dependency-version: 3.4.0 dependency-type: direct:development ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Jiuqing Song --- package.json | 2 +- yarn.lock | 12 +++++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index a35fd48100d7..bf5a6a272f28 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,7 @@ "coverage-istanbul-loader": "3.0.5", "css-loader": "3.5.3", "detect-port": "^1.3.0", - "dompurify": "2.5.4", + "dompurify": "3.4.0", "eslint": "^8.50.0", "eslint-plugin-etc": "^2.0.3", "eslint-plugin-react": "^7.33.2", diff --git a/yarn.lock b/yarn.lock index aa6c3bd98d70..652d7587dfbf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -773,7 +773,7 @@ resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.5.3.tgz#9a726e116beb26c24f1ccd6850201e1246122e04" integrity sha512-OxepLK9EuNEIPxWNME+C6WwbRAOOI2o2BaQEGzz5Lu2e4Z5eDnEo+/aVEDMIXywoJitJ7xWd641wrGLZdtwRyw== -"@types/trusted-types@*": +"@types/trusted-types@*", "@types/trusted-types@^2.0.7": version "2.0.7" resolved "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz#baccb07a970b91707df3a3e8ba6896c57ead2d11" integrity sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw== @@ -2292,10 +2292,12 @@ dom-serialize@^2.2.1: extend "^3.0.0" void-elements "^2.0.0" -dompurify@2.5.4: - version "2.5.4" - resolved "https://registry.npmjs.org/dompurify/-/dompurify-2.5.4.tgz#347e91070963b22db31c7c8d0ce9a0a2c3c08746" - integrity sha512-l5NNozANzaLPPe0XaAwvg3uZcHtDBnziX/HjsY1UcDj1MxTK8Dd0Kv096jyPK5HRzs/XM5IMj20dW8Fk+HnbUA== +dompurify@3.4.0: + version "3.4.0" + resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.4.0.tgz#b1fc33ebdadb373241621e0a30e4ad81573dfd0b" + integrity sha512-nolgK9JcaUXMSmW+j1yaSvaEaoXYHwWyGJlkoCTghc97KgGDDSnpoU/PlEnw63Ah+TGKFOyY+X5LnxaWbCSfXg== + optionalDependencies: + "@types/trusted-types" "^2.0.7" ecc-jsbn@~0.1.1: version "0.1.2" From 0b42312c63dabeb3da1eee95c7800a19bf781951 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Wed, 22 Apr 2026 10:06:47 -0700 Subject: [PATCH 12/15] Revert "Strip invisible Unicode from content model at editor initialization" (#3324) --- .../lib/publicApi/utils/checkXss.ts | 11 +- .../test/publicApi/utils/checkXssTest.ts | 32 -- .../lib/editor/Editor.ts | 3 - .../test/editor/EditorTest.ts | 8 +- .../lib/domUtils/stripInvisibleUnicode.ts | 18 -- .../roosterjs-content-model-dom/lib/index.ts | 2 - .../common/sanitizeInvisibleUnicode.ts | 89 ------ .../domUtils/stripInvisibleUnicodeTest.ts | 206 ------------- .../common/sanitizeInvisibleUnicodeTest.ts | 284 ------------------ 9 files changed, 9 insertions(+), 644 deletions(-) delete mode 100644 packages/roosterjs-content-model-dom/lib/domUtils/stripInvisibleUnicode.ts delete mode 100644 packages/roosterjs-content-model-dom/lib/modelApi/common/sanitizeInvisibleUnicode.ts delete mode 100644 packages/roosterjs-content-model-dom/test/domUtils/stripInvisibleUnicodeTest.ts delete mode 100644 packages/roosterjs-content-model-dom/test/modelApi/common/sanitizeInvisibleUnicodeTest.ts diff --git a/packages/roosterjs-content-model-api/lib/publicApi/utils/checkXss.ts b/packages/roosterjs-content-model-api/lib/publicApi/utils/checkXss.ts index 49408eff7d21..5f192bb4a174 100644 --- a/packages/roosterjs-content-model-api/lib/publicApi/utils/checkXss.ts +++ b/packages/roosterjs-content-model-api/lib/publicApi/utils/checkXss.ts @@ -1,15 +1,10 @@ -import { stripInvisibleUnicode } from 'roosterjs-content-model-dom'; - /** * @internal Check if there is XSS attack in the link * @param link The link to be checked - * @returns The safe link with invisible Unicode characters stripped, or empty string if there is XSS attack - * @remarks This function strips invisible Unicode characters (zero-width chars, Unicode Tags, etc.) - * and checks for patterns like s\nc\nr\ni\np\nt: to prevent XSS attacks. This may block some valid links, + * @returns The safe link, or empty string if there is XSS attack + * @remarks This function checks for patterns like s\nc\nr\ni\np\nt: to prevent XSS attacks. This may block some valid links, * but it is necessary for security reasons. We treat the word "script" as safe if there are "/" before it. */ export function checkXss(link: string): string { - // Defense-in-depth: strip invisible Unicode even if already handled elsewhere - const sanitized = stripInvisibleUnicode(link); - return sanitized.match(/^[^\/]*s\n*c\n*r\n*i\n*p\n*t\n*:/i) ? '' : sanitized; + return link.match(/^[^\/]*s\n*c\n*r\n*i\n*p\n*t\n*:/i) ? '' : link; } diff --git a/packages/roosterjs-content-model-api/test/publicApi/utils/checkXssTest.ts b/packages/roosterjs-content-model-api/test/publicApi/utils/checkXssTest.ts index 13afbf7309d4..9069906435da 100644 --- a/packages/roosterjs-content-model-api/test/publicApi/utils/checkXssTest.ts +++ b/packages/roosterjs-content-model-api/test/publicApi/utils/checkXssTest.ts @@ -30,36 +30,4 @@ describe('checkXss', () => { const link = 'https://example.com/script:.js'; expect(checkXss(link)).toBe(link); }); - - it('should strip invisible Unicode from link', () => { - const link = 'https://www\u200B.example\u200C.com'; - expect(checkXss(link)).toBe('https://www.example.com'); - }); - - it('should strip invisible Unicode from mailto link', () => { - const link = 'mailto:\u200Buser@example.com'; - expect(checkXss(link)).toBe('mailto:user@example.com'); - }); - - it('should detect XSS hidden behind invisible Unicode in script:', () => { - // script: with zero-width spaces between characters should still be caught - const link = 's\u200Bc\u200Cr\u200Di\u200Ep\u200Ft:alert(1)'; - expect(checkXss(link)).toBe(''); - }); - - it('should strip Unicode Tags (supplementary plane) from link', () => { - // U+E0061 = \uDB40\uDC61 (Tag Latin Small Letter A) - const link = 'mailto:\uDB40\uDC61user@example.com'; - expect(checkXss(link)).toBe('mailto:user@example.com'); - }); - - it('should strip bidirectional marks from link', () => { - const link = 'mailto:\u202Auser\u202E@example.com'; - expect(checkXss(link)).toBe('mailto:user@example.com'); - }); - - it('should strip invisible Unicode from mailto subject and body', () => { - const link = 'mailto:user@example.com?subject=Hello\u200BWorld&body=Test\u200CContent'; - expect(checkXss(link)).toBe('mailto:user@example.com?subject=HelloWorld&body=TestContent'); - }); }); diff --git a/packages/roosterjs-content-model-core/lib/editor/Editor.ts b/packages/roosterjs-content-model-core/lib/editor/Editor.ts index 6ac364a1b68e..a0fe1140632b 100644 --- a/packages/roosterjs-content-model-core/lib/editor/Editor.ts +++ b/packages/roosterjs-content-model-core/lib/editor/Editor.ts @@ -6,7 +6,6 @@ import { transformColor, createDomToModelContextWithConfig, domToContentModel, - sanitizeInvisibleUnicode, } from 'roosterjs-content-model-dom'; import type { ContentModelDocument, @@ -53,8 +52,6 @@ export class Editor implements IEditor { const initialModel = options.initialModel ?? createEmptyModel(this.core.format.defaultFormat); - sanitizeInvisibleUnicode(initialModel); - this.core.api.setContentModel( this.core, initialModel, diff --git a/packages/roosterjs-content-model-core/test/editor/EditorTest.ts b/packages/roosterjs-content-model-core/test/editor/EditorTest.ts index d3329027a544..c6ff9050e646 100644 --- a/packages/roosterjs-content-model-core/test/editor/EditorTest.ts +++ b/packages/roosterjs-content-model-core/test/editor/EditorTest.ts @@ -26,12 +26,14 @@ describe('Editor', () => { updateKnownColorSpy = jasmine.createSpy('updateKnownColor'); createEditorCoreSpy = spyOn(createEditorCore, 'createEditorCore').and.callThrough(); setContentModelSpy = jasmine.createSpy('setContentModel'); - createEmptyModelSpy = spyOn(createEmptyModel, 'createEmptyModel').and.callThrough(); + createEmptyModelSpy = spyOn(createEmptyModel, 'createEmptyModel'); }); it('ctor and dispose, no options', () => { const div = document.createElement('div'); + createEmptyModelSpy.and.callThrough(); + const editor = new Editor(div); expect(createEditorCoreSpy).toHaveBeenCalledWith(div, {}); @@ -65,7 +67,7 @@ describe('Editor', () => { } as any; const setContentModelSpy = jasmine.createSpy('setContentModel'); const disposeErrorHandlerSpy = jasmine.createSpy('disposeErrorHandler'); - const mockedInitialModel = { blocks: [] } as any; + const mockedInitialModel = 'INITMODEL' as any; const options: EditorOptions = { plugins: [mockedPlugin1, mockedPlugin2], disposeErrorHandler: disposeErrorHandlerSpy, @@ -76,6 +78,8 @@ describe('Editor', () => { }, }; + createEmptyModelSpy.and.callThrough(); + const editor = new Editor(div, options); expect(createEditorCoreSpy).toHaveBeenCalledWith(div, options); diff --git a/packages/roosterjs-content-model-dom/lib/domUtils/stripInvisibleUnicode.ts b/packages/roosterjs-content-model-dom/lib/domUtils/stripInvisibleUnicode.ts deleted file mode 100644 index 20bfdb56697e..000000000000 --- a/packages/roosterjs-content-model-dom/lib/domUtils/stripInvisibleUnicode.ts +++ /dev/null @@ -1,18 +0,0 @@ -const INVISIBLE_UNICODE_REGEX = - // eslint-disable-next-line no-misleading-character-class - /[\u00AD\u034F\u061C\u115F\u1160\u17B4\u17B5\u180B-\u180E\u200B-\u200F\u202A-\u202E\u2028\u2029\u2060-\u2064\u2066-\u2069\u3164\uFEFF\uFFA0\uFFF9-\uFFFB]|\uDB40[\uDC01-\uDCFF]/g; - -/** - * Strip invisible Unicode characters from a string. - * This removes zero-width characters, bidirectional marks, Unicode Tags (U+E0001-U+E00FF), - * interlinear annotation anchors, Mongolian free variation selectors, - * and other invisible formatting characters that can be used to hide content in links. - * - * @remarks This function strips ZWJ (U+200D) which may affect emoji sequences. - * It should only be applied to href attributes, not to visible text content. - * @param value The string to strip invisible characters from - * @returns The string with invisible characters removed - */ -export function stripInvisibleUnicode(value: string): string { - return value.replace(INVISIBLE_UNICODE_REGEX, ''); -} diff --git a/packages/roosterjs-content-model-dom/lib/index.ts b/packages/roosterjs-content-model-dom/lib/index.ts index 477ddc1f5b7d..df09746614b0 100644 --- a/packages/roosterjs-content-model-dom/lib/index.ts +++ b/packages/roosterjs-content-model-dom/lib/index.ts @@ -70,7 +70,6 @@ export { addTextSegment } from './modelApi/common/addTextSegment'; export { normalizeParagraph } from './modelApi/common/normalizeParagraph'; export { normalizeContentModel } from './modelApi/common/normalizeContentModel'; -export { sanitizeInvisibleUnicode } from './modelApi/common/sanitizeInvisibleUnicode'; export { isGeneralSegment } from './modelApi/typeCheck/isGeneralSegment'; export { unwrapBlock } from './modelApi/common/unwrapBlock'; export { addSegment } from './modelApi/common/addSegment'; @@ -119,7 +118,6 @@ export { isCharacterValue, isModifierKey, isCursorMovingKey } from './domUtils/e export { getNodePositionFromEvent } from './domUtils/event/getNodePositionFromEvent'; export { combineBorderValue, extractBorderValues } from './domUtils/style/borderValues'; export { isPunctuation, isSpace, normalizeText } from './domUtils/stringUtil'; -export { stripInvisibleUnicode } from './domUtils/stripInvisibleUnicode'; export { parseTableCells } from './domUtils/table/parseTableCells'; export { readFile } from './domUtils/readFile'; export { retrieveDocumentMetadata } from './domUtils/retrieveDocumentMetadata'; diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/common/sanitizeInvisibleUnicode.ts b/packages/roosterjs-content-model-dom/lib/modelApi/common/sanitizeInvisibleUnicode.ts deleted file mode 100644 index 51853bd7f49d..000000000000 --- a/packages/roosterjs-content-model-dom/lib/modelApi/common/sanitizeInvisibleUnicode.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { stripInvisibleUnicode } from '../../domUtils/stripInvisibleUnicode'; -import type { - ContentModelBlock, - ContentModelBlockGroup, - ContentModelDocument, - ContentModelSegment, -} from 'roosterjs-content-model-types'; - -/** - * Strip invisible Unicode characters from all text and link hrefs in a content model. - * This sanitizes the model at initialization time to prevent hidden content in links - * or text (e.g. zero-width chars, bidirectional marks, Unicode Tags). - * For General segments, all Text nodes under the element are also sanitized. - * @param model The content model document to sanitize in-place - */ -export function sanitizeInvisibleUnicode(model: ContentModelDocument): void { - sanitizeBlockGroup(model); -} - -function sanitizeBlockGroup(group: ContentModelBlockGroup): void { - for (const block of group.blocks) { - sanitizeBlock(block); - } -} - -function sanitizeBlock(block: ContentModelBlock): void { - switch (block.blockType) { - case 'Paragraph': - for (const segment of block.segments) { - sanitizeSegment(segment); - } - break; - - case 'Table': - for (const row of block.rows) { - for (const cell of row.cells) { - sanitizeBlockGroup(cell); - } - } - break; - - case 'BlockGroup': - sanitizeBlockGroup(block); - - if (block.blockGroupType === 'General' && block.element) { - sanitizeTextNodes(block.element); - } - break; - - case 'Entity': - case 'Divider': - break; - } -} - -function sanitizeSegment(segment: ContentModelSegment): void { - if (segment.link?.format.href) { - segment.link.format.href = stripInvisibleUnicode(segment.link.format.href); - } - - switch (segment.segmentType) { - case 'Text': - segment.text = stripInvisibleUnicode(segment.text); - break; - - case 'General': - sanitizeTextNodes(segment.element); - sanitizeBlockGroup(segment); - break; - - case 'Image': - case 'Entity': - case 'Br': - case 'SelectionMarker': - break; - } -} - -function sanitizeTextNodes(element: HTMLElement): void { - const walker = element.ownerDocument.createTreeWalker(element, NodeFilter.SHOW_TEXT); - - let node: Text | null; - - while ((node = walker.nextNode() as Text | null)) { - if (node.nodeValue) { - node.nodeValue = stripInvisibleUnicode(node.nodeValue); - } - } -} diff --git a/packages/roosterjs-content-model-dom/test/domUtils/stripInvisibleUnicodeTest.ts b/packages/roosterjs-content-model-dom/test/domUtils/stripInvisibleUnicodeTest.ts deleted file mode 100644 index a598bccf784c..000000000000 --- a/packages/roosterjs-content-model-dom/test/domUtils/stripInvisibleUnicodeTest.ts +++ /dev/null @@ -1,206 +0,0 @@ -import { stripInvisibleUnicode } from '../../lib/domUtils/stripInvisibleUnicode'; - -describe('stripInvisibleUnicode', () => { - it('should return empty string for empty input', () => { - expect(stripInvisibleUnicode('')).toBe(''); - }); - - it('should return the same string when no invisible characters are present', () => { - expect(stripInvisibleUnicode('mailto:user@example.com')).toBe('mailto:user@example.com'); - }); - - it('should return the same string for regular text with no invisible chars', () => { - expect(stripInvisibleUnicode('Hello World! 123')).toBe('Hello World! 123'); - }); - - it('should strip zero-width space (U+200B)', () => { - expect(stripInvisibleUnicode('mailto:\u200Buser@example.com')).toBe( - 'mailto:user@example.com' - ); - }); - - it('should strip zero-width non-joiner (U+200C)', () => { - expect(stripInvisibleUnicode('mailto:\u200Cuser@example.com')).toBe( - 'mailto:user@example.com' - ); - }); - - it('should strip zero-width joiner (U+200D)', () => { - expect(stripInvisibleUnicode('mailto:\u200Duser@example.com')).toBe( - 'mailto:user@example.com' - ); - }); - - it('should strip left-to-right mark (U+200E)', () => { - expect(stripInvisibleUnicode('mailto:\u200Euser@example.com')).toBe( - 'mailto:user@example.com' - ); - }); - - it('should strip right-to-left mark (U+200F)', () => { - expect(stripInvisibleUnicode('mailto:\u200Fuser@example.com')).toBe( - 'mailto:user@example.com' - ); - }); - - it('should strip BOM / zero-width no-break space (U+FEFF)', () => { - expect(stripInvisibleUnicode('\uFEFFmailto:user@example.com')).toBe( - 'mailto:user@example.com' - ); - }); - - it('should strip soft hyphen (U+00AD)', () => { - expect(stripInvisibleUnicode('mailto:us\u00ADer@example.com')).toBe( - 'mailto:user@example.com' - ); - }); - - it('should strip bidirectional override characters (U+202A-U+202E)', () => { - expect(stripInvisibleUnicode('mailto:\u202Auser\u202E@example.com')).toBe( - 'mailto:user@example.com' - ); - }); - - it('should strip bidirectional isolate characters (U+2066-U+2069)', () => { - expect(stripInvisibleUnicode('mailto:\u2066user\u2069@example.com')).toBe( - 'mailto:user@example.com' - ); - }); - - it('should strip word joiner (U+2060)', () => { - expect(stripInvisibleUnicode('mailto:\u2060user@example.com')).toBe( - 'mailto:user@example.com' - ); - }); - - it('should strip Arabic letter mark (U+061C)', () => { - expect(stripInvisibleUnicode('mailto:\u061Cuser@example.com')).toBe( - 'mailto:user@example.com' - ); - }); - - it('should strip combining grapheme joiner (U+034F)', () => { - expect(stripInvisibleUnicode('mailto:\u034Fuser@example.com')).toBe( - 'mailto:user@example.com' - ); - }); - - it('should strip Mongolian vowel separator (U+180E)', () => { - expect(stripInvisibleUnicode('mailto:\u180Euser@example.com')).toBe( - 'mailto:user@example.com' - ); - }); - - it('should strip line separator (U+2028) and paragraph separator (U+2029)', () => { - expect(stripInvisibleUnicode('mailto:\u2028user\u2029@example.com')).toBe( - 'mailto:user@example.com' - ); - }); - - it('should strip Unicode Tags (U+E0001-U+E007F) - supplementary plane', () => { - // U+E0061 = surrogate pair \uDB40\uDC61 (Tag Latin Small Letter A) - // U+E0062 = surrogate pair \uDB40\uDC62 (Tag Latin Small Letter B) - expect(stripInvisibleUnicode('mailto:\uDB40\uDC61\uDB40\uDC62user@example.com')).toBe( - 'mailto:user@example.com' - ); - }); - - it('should strip Unicode Tag Begin (U+E0001)', () => { - // U+E0001 = surrogate pair \uDB40\uDC01 - expect(stripInvisibleUnicode('mailto:\uDB40\uDC01user@example.com')).toBe( - 'mailto:user@example.com' - ); - }); - - it('should strip Unicode Tag Cancel (U+E007F)', () => { - // U+E007F = surrogate pair \uDB40\uDC7F - expect(stripInvisibleUnicode('mailto:\uDB40\uDC7Fuser@example.com')).toBe( - 'mailto:user@example.com' - ); - }); - - it('should strip multiple different invisible characters in one string', () => { - expect( - stripInvisibleUnicode( - 'mailto:\u200B\u200C\u200D\u200E\u200F\u202A\u202E\uFEFFuser@example.com' - ) - ).toBe('mailto:user@example.com'); - }); - - it('should return empty string when input contains only invisible characters', () => { - expect(stripInvisibleUnicode('\u200B\u200C\u200D\uFEFF')).toBe(''); - }); - - it('should handle invisible characters scattered throughout a mailto link', () => { - expect( - stripInvisibleUnicode('m\u200Ba\u200Ci\u200Dl\u200Et\u200Fo\u202A:user@example.com') - ).toBe('mailto:user@example.com'); - }); - - it('should strip invisible characters from mailto subject parameter', () => { - expect( - stripInvisibleUnicode( - 'mailto:user@example.com?subject=Hello\u200B\u200CWorld&body=Test\u200DContent' - ) - ).toBe('mailto:user@example.com?subject=HelloWorld&body=TestContent'); - }); - - it('should handle http links with invisible characters', () => { - expect(stripInvisibleUnicode('https://www\u200B.example\u200C.com')).toBe( - 'https://www.example.com' - ); - }); - - it('should not strip regular visible Unicode characters', () => { - // Common non-ASCII visible chars should be preserved - expect(stripInvisibleUnicode('mailto:über@example.com')).toBe('mailto:über@example.com'); - expect(stripInvisibleUnicode('mailto:用户@example.com')).toBe('mailto:用户@example.com'); - expect(stripInvisibleUnicode('mailto:пользователь@example.com')).toBe( - 'mailto:пользователь@example.com' - ); - }); - - it('should preserve emoji characters', () => { - expect(stripInvisibleUnicode('mailto:user@example.com?subject=Hello 👋')).toBe( - 'mailto:user@example.com?subject=Hello 👋' - ); - }); - - it('should strip Hangul fillers', () => { - expect(stripInvisibleUnicode('mailto:\u115F\u1160\u3164\uFFA0user@example.com')).toBe( - 'mailto:user@example.com' - ); - }); - - it('should strip Khmer vowel inherent characters', () => { - expect(stripInvisibleUnicode('mailto:\u17B4\u17B5user@example.com')).toBe( - 'mailto:user@example.com' - ); - }); - - it('should strip Mongolian free variation selectors (U+180B-U+180D)', () => { - expect(stripInvisibleUnicode('mailto:\u180B\u180C\u180Duser@example.com')).toBe( - 'mailto:user@example.com' - ); - }); - - it('should strip interlinear annotation anchors (U+FFF9-U+FFFB)', () => { - expect(stripInvisibleUnicode('mailto:\uFFF9\uFFFA\uFFFBuser@example.com')).toBe( - 'mailto:user@example.com' - ); - }); - - it('should strip extended Unicode Tags beyond U+E007F (U+E0080-U+E00FF)', () => { - // U+E0080 = surrogate pair \uDB40\uDC80 - // U+E00FF = surrogate pair \uDB40\uDCFF - expect(stripInvisibleUnicode('mailto:\uDB40\uDC80\uDB40\uDCFFuser@example.com')).toBe( - 'mailto:user@example.com' - ); - }); - - it('should not modify URL-encoded sequences (percent-encoded content is preserved as-is)', () => { - // %E2%80%8B is URL-encoded U+200B - we do not decode, as the content may be intentional - const link = 'mailto:%E2%80%8Buser@example.com'; - expect(stripInvisibleUnicode(link)).toBe(link); - }); -}); diff --git a/packages/roosterjs-content-model-dom/test/modelApi/common/sanitizeInvisibleUnicodeTest.ts b/packages/roosterjs-content-model-dom/test/modelApi/common/sanitizeInvisibleUnicodeTest.ts deleted file mode 100644 index 57d05c49ca27..000000000000 --- a/packages/roosterjs-content-model-dom/test/modelApi/common/sanitizeInvisibleUnicodeTest.ts +++ /dev/null @@ -1,284 +0,0 @@ -import { sanitizeInvisibleUnicode } from '../../../lib/modelApi/common/sanitizeInvisibleUnicode'; -import { ContentModelDocument } from 'roosterjs-content-model-types'; - -describe('sanitizeInvisibleUnicode', () => { - it('should not modify model with no invisible characters', () => { - const model: ContentModelDocument = { - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - segments: [ - { - segmentType: 'Text', - format: {}, - text: 'Hello World', - }, - ], - }, - ], - }; - - sanitizeInvisibleUnicode(model); - - expect(model.blocks[0]).toEqual({ - blockType: 'Paragraph', - format: {}, - segments: [ - { - segmentType: 'Text', - format: {}, - text: 'Hello World', - }, - ], - }); - }); - - it('should strip invisible Unicode from text segments', () => { - const model: ContentModelDocument = { - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - segments: [ - { - segmentType: 'Text', - format: {}, - text: 'Hello\u200B\u200CWorld', - }, - ], - }, - ], - }; - - sanitizeInvisibleUnicode(model); - - expect((model.blocks[0] as any).segments[0].text).toBe('HelloWorld'); - }); - - it('should strip invisible Unicode from link href', () => { - const model: ContentModelDocument = { - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - segments: [ - { - segmentType: 'Text', - format: {}, - text: 'Click here', - link: { - format: { - href: 'mailto:\u200Buser@example.com', - underline: true, - }, - dataset: {}, - }, - }, - ], - }, - ], - }; - - sanitizeInvisibleUnicode(model); - - expect((model.blocks[0] as any).segments[0].link.format.href).toBe( - 'mailto:user@example.com' - ); - }); - - it('should strip invisible Unicode from text inside table cells', () => { - const model: ContentModelDocument = { - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Table', - format: {}, - widths: [100], - rows: [ - { - format: {}, - height: 0, - cells: [ - { - blockGroupType: 'TableCell', - format: {}, - spanAbove: false, - spanLeft: false, - isHeader: false, - dataset: {}, - blocks: [ - { - blockType: 'Paragraph', - format: {}, - segments: [ - { - segmentType: 'Text', - format: {}, - text: 'Cell\u200BText', - }, - ], - }, - ], - }, - ], - }, - ], - dataset: {}, - }, - ], - }; - - sanitizeInvisibleUnicode(model); - - const cell = (model.blocks[0] as any).rows[0].cells[0]; - expect(cell.blocks[0].segments[0].text).toBe('CellText'); - }); - - it('should sanitize text nodes in General segment element', () => { - const element = document.createElement('div'); - element.innerHTML = 'Hello\u200B World\u200C'; - - const model: ContentModelDocument = { - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - segments: [ - { - segmentType: 'General', - blockGroupType: 'General', - blockType: 'BlockGroup', - format: {}, - element: element, - blocks: [], - }, - ], - }, - ], - }; - - sanitizeInvisibleUnicode(model); - - expect(element.textContent).toBe('Hello World'); - }); - - it('should handle empty model', () => { - const model: ContentModelDocument = { - blockGroupType: 'Document', - blocks: [], - }; - - sanitizeInvisibleUnicode(model); - - expect(model.blocks.length).toBe(0); - }); - - it('should not modify URL-encoded sequences in link href', () => { - const href = 'mailto:%E2%80%8Buser@example.com'; - const model: ContentModelDocument = { - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - segments: [ - { - segmentType: 'Text', - format: {}, - text: 'Click', - link: { - format: { - href: href, - underline: true, - }, - dataset: {}, - }, - }, - ], - }, - ], - }; - - sanitizeInvisibleUnicode(model); - - expect((model.blocks[0] as any).segments[0].link.format.href).toBe(href); - }); - - it('should handle nested block groups (e.g. list items)', () => { - const model: ContentModelDocument = { - blockGroupType: 'Document', - blocks: [ - { - blockType: 'BlockGroup', - blockGroupType: 'ListItem', - format: {}, - formatHolder: { segmentType: 'SelectionMarker', format: {}, isSelected: false }, - levels: [{ listType: 'UL', format: {}, dataset: {} }], - blocks: [ - { - blockType: 'Paragraph', - format: {}, - segments: [ - { - segmentType: 'Text', - format: {}, - text: 'Item\u200BOne', - }, - ], - }, - ], - }, - ], - }; - - sanitizeInvisibleUnicode(model); - - expect((model.blocks[0] as any).blocks[0].segments[0].text).toBe('ItemOne'); - }); - - it('should not modify Br or SelectionMarker segments', () => { - const model: ContentModelDocument = { - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - segments: [ - { - segmentType: 'Br', - format: {}, - }, - { - segmentType: 'SelectionMarker', - format: {}, - isSelected: true, - }, - ], - }, - ], - }; - - sanitizeInvisibleUnicode(model); - - expect(model.blocks[0]).toEqual({ - blockType: 'Paragraph', - format: {}, - segments: [ - { - segmentType: 'Br', - format: {}, - }, - { - segmentType: 'SelectionMarker', - format: {}, - isSelected: true, - }, - ], - }); - }); -}); From f37616a25318ae6ef980bb93f0a075eff35af0ca Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Thu, 23 Apr 2026 13:15:12 -0700 Subject: [PATCH 13/15] Fix 422986 (#3325) * Fix 422986 * improve --- .../processors/listItemProcessor.ts | 31 +++++++--- .../domToModel/processors/listProcessor.ts | 7 +-- .../processors/childProcessorTest.ts | 1 + .../processors/listItemProcessorTest.ts | 30 +++++++-- .../test/endToEndTest.ts | 37 +++++++++++ .../word/processPastedContentFromWacTest.ts | 62 ++++++++++++++----- .../lib/context/DomToModelFormatContext.ts | 6 ++ 7 files changed, 140 insertions(+), 34 deletions(-) diff --git a/packages/roosterjs-content-model-dom/lib/domToModel/processors/listItemProcessor.ts b/packages/roosterjs-content-model-dom/lib/domToModel/processors/listItemProcessor.ts index 07d3f1c178a0..61ccf92bd380 100644 --- a/packages/roosterjs-content-model-dom/lib/domToModel/processors/listItemProcessor.ts +++ b/packages/roosterjs-content-model-dom/lib/domToModel/processors/listItemProcessor.ts @@ -1,4 +1,5 @@ import { createListItem } from '../../modelApi/creators/createListItem'; +import { createListLevel } from '../../modelApi/creators/createListLevel'; import { parseFormat } from '../utils/parseFormat'; import { stackFormat } from '../utils/stackFormat'; import type { ElementProcessor } from 'roosterjs-content-model-types'; @@ -8,8 +9,21 @@ import type { ElementProcessor } from 'roosterjs-content-model-types'; */ export const listItemProcessor: ElementProcessor = (group, element, context) => { const { listFormat } = context; + const originalListParent = listFormat.listParent; + let shouldPopListLevel = false; + + try { + listFormat.listParent = listFormat.listParent ?? group; + + const listParent = listFormat.listParent; + + if (listFormat.levels.length == 0) { + listFormat.levels.push( + createListLevel(listFormat.potentialListType || 'UL', context.blockFormat) + ); + shouldPopListLevel = true; + } - if (listFormat.listParent && listFormat.levels.length > 0) { stackFormat( context, { @@ -31,7 +45,7 @@ export const listItemProcessor: ElementProcessor = (group, elemen context ); - listFormat.listParent!.blocks.push(listItem); + listParent.blocks.push(listItem); parseFormat( element, @@ -54,14 +68,11 @@ export const listItemProcessor: ElementProcessor = (group, elemen } } ); - } else { - const currentBlocks = listFormat.listParent?.blocks; - const lastItem = currentBlocks?.[currentBlocks?.length - 1]; + } finally { + if (shouldPopListLevel) { + listFormat.levels.pop(); + } - context.elementProcessors['*']( - lastItem?.blockType == 'BlockGroup' ? lastItem : group, - element, - context - ); + listFormat.listParent = originalListParent; } }; diff --git a/packages/roosterjs-content-model-dom/lib/domToModel/processors/listProcessor.ts b/packages/roosterjs-content-model-dom/lib/domToModel/processors/listProcessor.ts index 92517b7e51bc..e6334346ae51 100644 --- a/packages/roosterjs-content-model-dom/lib/domToModel/processors/listProcessor.ts +++ b/packages/roosterjs-content-model-dom/lib/domToModel/processors/listProcessor.ts @@ -18,10 +18,8 @@ export const listProcessor: ElementProcessor { - const level: ContentModelListLevel = createListLevel( - element.tagName as 'OL' | 'UL', - context.blockFormat - ); + const tagName = element.tagName as 'OL' | 'UL'; + const level: ContentModelListLevel = createListLevel(tagName, context.blockFormat); const { listFormat } = context; parseFormat(element, context.formatParsers.dataset, level.dataset, context); @@ -31,6 +29,7 @@ export const listProcessor: ElementProcessor { levels: [], listParent: undefined, threadItemCounts: [1], + potentialListType: 'OL', }); }); diff --git a/packages/roosterjs-content-model-dom/test/domToModel/processors/listItemProcessorTest.ts b/packages/roosterjs-content-model-dom/test/domToModel/processors/listItemProcessorTest.ts index a39fc7173e7e..2811d3c7859a 100644 --- a/packages/roosterjs-content-model-dom/test/domToModel/processors/listItemProcessorTest.ts +++ b/packages/roosterjs-content-model-dom/test/domToModel/processors/listItemProcessorTest.ts @@ -26,10 +26,21 @@ describe('listItemProcessor', () => { blocks: [ { blockType: 'BlockGroup', - blockGroupType: 'General', - element: li, + blockGroupType: 'ListItem', blocks: [], format: {}, + levels: [ + { + listType: 'UL', + format: {}, + dataset: {}, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: false, + format: {}, + }, }, ], }); @@ -381,10 +392,21 @@ describe('listItemProcessor without format handlers', () => { blocks: [ { blockType: 'BlockGroup', - blockGroupType: 'General', - element: li, + blockGroupType: 'ListItem', blocks: [], format: {}, + levels: [ + { + listType: 'UL', + format: {}, + dataset: {}, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: false, + format: {}, + }, }, ], }); diff --git a/packages/roosterjs-content-model-dom/test/endToEndTest.ts b/packages/roosterjs-content-model-dom/test/endToEndTest.ts index 27fc73fa576f..8d55177d6929 100644 --- a/packages/roosterjs-content-model-dom/test/endToEndTest.ts +++ b/packages/roosterjs-content-model-dom/test/endToEndTest.ts @@ -3027,4 +3027,41 @@ describe('End to end test for DOM => Model => DOM/TEXT', () => { 'www.bing.com' ); }); + + it('LI without UL followed by other blocks', () => { + runTest( + '
  • test
  • other
    ', + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [{ segmentType: 'Text', text: 'test', format: {} }], + format: {}, + isImplicit: true, + }, + ], + levels: [{ listType: 'UL', format: {}, dataset: {} }], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: false, + format: {}, + }, + format: {}, + }, + { + blockType: 'Paragraph', + segments: [{ segmentType: 'Text', text: 'other', format: {} }], + format: {}, + }, + ], + }, + 'test\r\nother', + '
    • test
    other
    ' + ); + }); }); diff --git a/packages/roosterjs-content-model-plugins/test/paste/word/processPastedContentFromWacTest.ts b/packages/roosterjs-content-model-plugins/test/paste/word/processPastedContentFromWacTest.ts index 2a046e3a208a..d254b7198d6a 100644 --- a/packages/roosterjs-content-model-plugins/test/paste/word/processPastedContentFromWacTest.ts +++ b/packages/roosterjs-content-model-plugins/test/paste/word/processPastedContentFromWacTest.ts @@ -2043,17 +2043,16 @@ describe('wordOnlineHandler', () => { // .test // .test // .test - it('shuold process html properly, when list items are not in side ul tag', () => { + it('should process html properly, when list items are not inside ul tag', () => { runTest( '
    • test

    • test

    • test

    • ', - '
    • test

    • test

    • test

    • ', + '
      • test

      • test

      • test

      ', { blockGroupType: 'Document', blocks: [ { blockType: 'BlockGroup', - blockGroupType: 'General', - element: jasmine.anything() as any, + blockGroupType: 'ListItem', blocks: [ { blockType: 'Paragraph', @@ -2064,12 +2063,17 @@ describe('wordOnlineHandler', () => { decorator: { tagName: 'p', format: {} }, }, ], + levels: [{ listType: 'UL', format: {}, dataset: {} }], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: false, + format: {}, + }, format: {}, }, { blockType: 'BlockGroup', - blockGroupType: 'General', - element: jasmine.anything() as any, + blockGroupType: 'ListItem', blocks: [ { blockType: 'Paragraph', @@ -2080,12 +2084,17 @@ describe('wordOnlineHandler', () => { decorator: { tagName: 'p', format: {} }, }, ], + levels: [{ listType: 'UL', format: {}, dataset: {} }], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: false, + format: {}, + }, format: {}, }, { blockType: 'BlockGroup', - blockGroupType: 'General', - element: jasmine.anything() as any, + blockGroupType: 'ListItem', blocks: [ { blockType: 'Paragraph', @@ -2096,6 +2105,12 @@ describe('wordOnlineHandler', () => { decorator: { tagName: 'p', format: {} }, }, ], + levels: [{ listType: 'UL', format: {}, dataset: {} }], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: false, + format: {}, + }, format: {}, }, ], @@ -2345,17 +2360,16 @@ describe('wordOnlineHandler', () => { // result: // 1. text // 2. text - it('shuold process html properly, if list item in a ListContainerWrapper are not inside ol ', () => { + it('should process html properly, if list item in a ListContainerWrapper are not inside ol ', () => { runTest( '
      1. test

      2. test

      3. test

      4. ', - '
      5. test

      6. test

      7. test

      8. ', + '
        1. test

        2. test

        3. test

        ', { blockGroupType: 'Document', blocks: [ { blockType: 'BlockGroup', - blockGroupType: 'General', - element: jasmine.anything() as any, + blockGroupType: 'ListItem', blocks: [ { blockType: 'Paragraph', @@ -2366,12 +2380,17 @@ describe('wordOnlineHandler', () => { decorator: { tagName: 'p', format: {} }, }, ], + levels: [{ listType: 'OL', format: {}, dataset: {} }], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: false, + format: {}, + }, format: {}, }, { blockType: 'BlockGroup', - blockGroupType: 'General', - element: jasmine.anything() as any, + blockGroupType: 'ListItem', blocks: [ { blockType: 'Paragraph', @@ -2382,12 +2401,17 @@ describe('wordOnlineHandler', () => { decorator: { tagName: 'p', format: {} }, }, ], + levels: [{ listType: 'OL', format: {}, dataset: {} }], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: false, + format: {}, + }, format: {}, }, { blockType: 'BlockGroup', - blockGroupType: 'General', - element: jasmine.anything() as any, + blockGroupType: 'ListItem', blocks: [ { blockType: 'Paragraph', @@ -2398,6 +2422,12 @@ describe('wordOnlineHandler', () => { decorator: { tagName: 'p', format: {} }, }, ], + levels: [{ listType: 'OL', format: {}, dataset: {} }], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: false, + format: {}, + }, format: {}, }, ], diff --git a/packages/roosterjs-content-model-types/lib/context/DomToModelFormatContext.ts b/packages/roosterjs-content-model-types/lib/context/DomToModelFormatContext.ts index dbe1884ab1ef..df71a8e58dca 100644 --- a/packages/roosterjs-content-model-types/lib/context/DomToModelFormatContext.ts +++ b/packages/roosterjs-content-model-types/lib/context/DomToModelFormatContext.ts @@ -24,6 +24,12 @@ export interface DomToModelListFormat { * Current list type stack */ levels: ContentModelListLevel[]; + + /** + * This is used for handling an abnormal case where list items are not inside a ul or ol tag + * It is not common and against the HTML specification, but we need to handle it for robustness + */ + potentialListType?: 'OL' | 'UL'; } /** From 7a46ec49cd67153fcdef1b75976034230c0b2a9d Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Thu, 23 Apr 2026 13:42:05 -0700 Subject: [PATCH 14/15] Graduate some experimental features (#3327) --- .../editorOptions/EditorOptionsPlugin.ts | 7 +-- .../editorOptions/ExperimentalFeatures.tsx | 3 -- .../sidePane/editorOptions/Plugins.tsx | 8 ---- .../command/exportContent/exportContent.ts | 10 ++-- .../createEditorContext.ts | 1 - .../lib/editor/core/DOMHelperImpl.ts | 15 +++--- .../lib/editor/core/createEditorCore.ts | 4 +- .../createEditorContextTest.ts | 7 --- .../test/editor/core/DOMHelperImplTest.ts | 18 +------ .../handlers/handleBlockGroupChildren.ts | 2 +- .../lib/modelToDom/handlers/handleList.ts | 22 +++------ .../lib/modelToDom/handlers/handleListItem.ts | 7 +-- .../handlers/handleBlockGroupChildrenTest.ts | 1 - .../modelToDom/handlers/handleListItemTest.ts | 1 - .../modelToDom/handlers/handleListTest.ts | 10 +--- .../lib/edit/EditOptions.ts | 1 + .../lib/edit/EditPlugin.ts | 26 +--------- .../lib/edit/keyboardEnter.ts | 14 +----- .../test/edit/EditPluginTest.ts | 45 +++++------------- .../edit/inputSteps/handleEnterOnListTest.ts | 2 +- .../test/edit/keyboardEnterTest.ts | 11 ++--- .../lib/context/EditorContext.ts | 1 + .../lib/editor/ExperimentalFeature.ts | 47 ++++++++++--------- .../lib/editor/EditorAdapter.ts | 8 +--- 24 files changed, 76 insertions(+), 195 deletions(-) diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts b/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts index 319412e4e6c2..325b199093da 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts +++ b/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts @@ -70,12 +70,7 @@ const initialState: OptionState = { }, customReplacements: emojiReplacements, disableSideResize: false, - experimentalFeatures: new Set([ - 'HandleEnterKey', - 'CloneIndependentRoot', - 'CacheList', - 'TransformTableBorderColors', - ]), + experimentalFeatures: new Set(['TransformTableBorderColors']), }; export class EditorOptionsPlugin extends SidePanePluginImpl { diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/ExperimentalFeatures.tsx b/demo/scripts/controlsV2/sidePane/editorOptions/ExperimentalFeatures.tsx index dc9aa65739af..7de49ba7833f 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/ExperimentalFeatures.tsx +++ b/demo/scripts/controlsV2/sidePane/editorOptions/ExperimentalFeatures.tsx @@ -11,10 +11,7 @@ export class ExperimentalFeatures extends React.Component - {this.renderFeature('HandleEnterKey')} {this.renderFeature('KeepSelectionMarkerWhenEnteringTextNode')} - {this.renderFeature('CloneIndependentRoot')} - {this.renderFeature('CacheList')} {this.renderFeature('TransformTableBorderColors')} ); diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/Plugins.tsx b/demo/scripts/controlsV2/sidePane/editorOptions/Plugins.tsx index 44022a59ccb0..50d9698b3de9 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/Plugins.tsx +++ b/demo/scripts/controlsV2/sidePane/editorOptions/Plugins.tsx @@ -99,7 +99,6 @@ abstract class PluginsBase extends Re export class Plugins extends PluginsBase { private allowExcelNoBorderTable = React.createRef(); private handleTabKey = React.createRef(); - private handleEnterKey = React.createRef(); private listMenu = React.createRef(); private tableMenu = React.createRef(); private imageMenu = React.createRef(); @@ -240,13 +239,6 @@ export class Plugins extends PluginsBase { (state, value) => (state.editPluginOptions.handleTabKey.indentParagraph = value) )} - {this.renderCheckBox( - 'Handle Enter Key', - this.handleEnterKey, - this.props.state.editPluginOptions.shouldHandleEnterKey as boolean, - (state, value) => - (state.editPluginOptions.shouldHandleEnterKey = value) - )} )} {this.renderPluginItem( diff --git a/packages/roosterjs-content-model-core/lib/command/exportContent/exportContent.ts b/packages/roosterjs-content-model-core/lib/command/exportContent/exportContent.ts index 47f07595c8a8..2296099c2df2 100644 --- a/packages/roosterjs-content-model-core/lib/command/exportContent/exportContent.ts +++ b/packages/roosterjs-content-model-core/lib/command/exportContent/exportContent.ts @@ -15,7 +15,7 @@ import type { /** * Export HTML content. If there are entities, this will cause EntityOperation event with option = 'replaceTemporaryContent' to get a dehydrated entity * @param editor The editor to get content from - * @param mode Specify HTML to get HTML. This is the default option + * @param mode Specify HTML to get HTML. * @param options @optional Options for Model to DOM conversion */ export function exportContent(editor: IEditor, mode?: 'HTML', options?: ModelToDomOption): string; @@ -24,9 +24,9 @@ export function exportContent(editor: IEditor, mode?: 'HTML', options?: ModelToD * Export HTML content. If there are entities, this will cause EntityOperation event with option = 'replaceTemporaryContent' to get a dehydrated entity. * This is a fast version, it retrieve HTML content directly from editor without going through content model conversion. * @param editor The editor to get content from - * @param mode Specify HTMLFast to get HTML result. + * @param mode Specify HTMLFast to get HTML result. This is the default option */ -export function exportContent(editor: IEditor, mode: 'HTMLFast'): string; +export function exportContent(editor: IEditor, mode?: 'HTMLFast'): string; /** * Export plain text content @@ -52,7 +52,7 @@ export function exportContent(editor: IEditor, mode: 'PlainTextFast'): string; // Once we are confident that 'HTMLFast' is stable, we can fully switch 'HTML' to use the 'HTMLFast' approach export function exportContent( editor: IEditor, - mode: ExportContentMode | 'HTMLFast' = 'HTML', + mode: ExportContentMode | 'HTMLFast' = 'HTMLFast', optionsOrCallbacks?: ModelToDomOption | ModelToTextCallbacks ): string { let model: ContentModelDocument; @@ -70,6 +70,7 @@ export function exportContent( ); case 'HTMLFast': + default: const clonedRoot = editor.getDOMHelper().getClonedRoot(); if (editor.isDarkMode()) { @@ -89,7 +90,6 @@ export function exportContent( return getHTMLFromDOM(editor, clonedRoot); case 'HTML': - default: model = editor.getContentModelCopy('clean'); const doc = editor.getDocument(); diff --git a/packages/roosterjs-content-model-core/lib/coreApi/createEditorContext/createEditorContext.ts b/packages/roosterjs-content-model-core/lib/coreApi/createEditorContext/createEditorContext.ts index 9a2983ae6601..20f38f2c89cc 100644 --- a/packages/roosterjs-content-model-core/lib/coreApi/createEditorContext/createEditorContext.ts +++ b/packages/roosterjs-content-model-core/lib/coreApi/createEditorContext/createEditorContext.ts @@ -17,7 +17,6 @@ export const createEditorContext: CreateEditorContext = (core, saveIndex) => { darkColorHandler: darkColorHandler, addDelimiterForEntity: true, allowCacheElement: true, - allowCacheListItem: !!core.experimentalFeatures?.includes('CacheList'), domIndexer: saveIndex ? cache.domIndexer : undefined, zoomScale: domHelper.calculateZoomScale(), experimentalFeatures: core.experimentalFeatures ?? [], 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 def7bec62747..016d5a8240b4 100644 --- a/packages/roosterjs-content-model-core/lib/editor/core/DOMHelperImpl.ts +++ b/packages/roosterjs-content-model-core/lib/editor/core/DOMHelperImpl.ts @@ -16,11 +16,14 @@ import type { * @internal */ export interface DOMHelperImplOption { + /** + * @deprecated This is always treated as true now + */ cloneIndependentRoot?: boolean; } class DOMHelperImpl implements DOMHelper { - constructor(private contentDiv: HTMLElement, private options: DOMHelperImplOption) {} + constructor(private contentDiv: HTMLElement, options?: DOMHelperImplOption) {} queryElements(selector: string): HTMLElement[] { return toArray(this.contentDiv.querySelectorAll(selector)) as HTMLElement[]; @@ -123,14 +126,10 @@ class DOMHelperImpl implements DOMHelper { * Get a deep cloned root element */ getClonedRoot(): HTMLElement { - if (this.options.cloneIndependentRoot) { - const doc = this.contentDiv.ownerDocument.implementation.createHTMLDocument(); - const clone = doc.importNode(this.contentDiv, true /*deep*/); + const doc = this.contentDiv.ownerDocument.implementation.createHTMLDocument(); + const clone = doc.importNode(this.contentDiv, true /*deep*/); - return clone; - } else { - return this.contentDiv.cloneNode(true /*deep*/) as HTMLElement; - } + return clone; } /** diff --git a/packages/roosterjs-content-model-core/lib/editor/core/createEditorCore.ts b/packages/roosterjs-content-model-core/lib/editor/core/createEditorCore.ts index a15e5fbf9efb..96398a35276d 100644 --- a/packages/roosterjs-content-model-core/lib/editor/core/createEditorCore.ts +++ b/packages/roosterjs-content-model-core/lib/editor/core/createEditorCore.ts @@ -50,9 +50,7 @@ export function createEditorCore(contentDiv: HTMLDivElement, options: EditorOpti ? options.trustedHTMLHandler : createTrustedHTMLHandler(domCreator), domCreator: domCreator, - domHelper: createDOMHelper(contentDiv, { - cloneIndependentRoot: options.experimentalFeatures?.includes('CloneIndependentRoot'), - }), + domHelper: createDOMHelper(contentDiv), ...getPluginState(corePlugins), disposeErrorHandler: options.disposeErrorHandler, onFixUpModel: options.onFixUpModel, diff --git a/packages/roosterjs-content-model-core/test/coreApi/createEditorContext/createEditorContextTest.ts b/packages/roosterjs-content-model-core/test/coreApi/createEditorContext/createEditorContextTest.ts index 9e97fcc987c8..855e5b8e2364 100644 --- a/packages/roosterjs-content-model-core/test/coreApi/createEditorContext/createEditorContextTest.ts +++ b/packages/roosterjs-content-model-core/test/coreApi/createEditorContext/createEditorContextTest.ts @@ -51,7 +51,6 @@ describe('createEditorContext', () => { experimentalFeatures: [], paragraphMap: mockedParagraphMap, editorViewWidth: 800, - allowCacheListItem: false, }); }); @@ -104,7 +103,6 @@ describe('createEditorContext', () => { experimentalFeatures: [], paragraphMap: mockedParagraphMap, editorViewWidth: 800, - allowCacheListItem: false, }); }); @@ -154,7 +152,6 @@ describe('createEditorContext', () => { experimentalFeatures: [], paragraphMap: mockedParagraphMap, editorViewWidth: 800, - allowCacheListItem: false, }); }); @@ -207,7 +204,6 @@ describe('createEditorContext', () => { experimentalFeatures: [], paragraphMap: mockedParagraphMap, editorViewWidth: 800, - allowCacheListItem: false, }); }); }); @@ -266,7 +262,6 @@ describe('createEditorContext - checkZoomScale', () => { experimentalFeatures: [], paragraphMap: mockedParagraphMap, editorViewWidth: 800, - allowCacheListItem: false, }); }); }); @@ -325,7 +320,6 @@ describe('createEditorContext - checkRootDir', () => { experimentalFeatures: [], paragraphMap: mockedParagraphMap, editorViewWidth: 800, - allowCacheListItem: false, }); }); @@ -347,7 +341,6 @@ describe('createEditorContext - checkRootDir', () => { experimentalFeatures: [], paragraphMap: mockedParagraphMap, editorViewWidth: 800, - allowCacheListItem: false, }); }); }); 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 8fa6f84eb29e..86da6ac65ad5 100644 --- a/packages/roosterjs-content-model-core/test/editor/core/DOMHelperImplTest.ts +++ b/packages/roosterjs-content-model-core/test/editor/core/DOMHelperImplTest.ts @@ -354,20 +354,6 @@ describe('DOMHelperImpl', () => { 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); - }); - - it('getClonedRoot, with CloneIndependentRoot on', () => { const mockedClone = 'CLONE' as any; const cloneSpy = jasmine.createSpy('cloneSpy').and.returnValue(mockedClone); const importNodeSpy = jasmine.createSpy('importNodeSpy').and.returnValue(mockedClone); @@ -381,9 +367,7 @@ describe('DOMHelperImpl', () => { }, }, } as any; - const domHelper = createDOMHelper(mockedDiv, { - cloneIndependentRoot: true, - }); + const domHelper = createDOMHelper(mockedDiv); const result = domHelper.getClonedRoot(); diff --git a/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleBlockGroupChildren.ts b/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleBlockGroupChildren.ts index 8c459f464e55..a5114412de0f 100644 --- a/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleBlockGroupChildren.ts +++ b/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleBlockGroupChildren.ts @@ -54,7 +54,7 @@ export const handleBlockGroupChildren: ContentModelHandler 0) { + if (nodeStack.length > 0) { // Clear list stack, only run to nodeStack[1] because nodeStack[0] is the parent node for (let i = nodeStack.length - 1; i > 0; i--) { const node = nodeStack.pop()?.refNode ?? null; diff --git a/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleList.ts b/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleList.ts index 6ed248c629f6..ae6f190ac7b8 100644 --- a/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleList.ts +++ b/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleList.ts @@ -50,24 +50,18 @@ export const handleList: ContentModelBlockHandler = ( applyMetadata(itemLevel, context.metadataAppliers.listLevel, itemLevel.format, context); } - if ( - context.allowCacheListItem && - parentLevel.refNode && - itemLevel.cachedElement == parentLevel.refNode - ) { + if (parentLevel.refNode && itemLevel.cachedElement == parentLevel.refNode) { // Move refNode to next node since we are reusing this cached element parentLevel.refNode = parentLevel.refNode.nextSibling; } } // Cut off remained list levels that we can't reuse - if (context.allowCacheListItem) { - // Clean up all rest nodes in the reused list levels - for (let i = layer + 1; i < nodeStack.length; i++) { - const stackLevel = nodeStack[i]; + // Clean up all rest nodes in the reused list levels + for (let i = layer + 1; i < nodeStack.length; i++) { + const stackLevel = nodeStack[i]; - cleanUpRestNodes(stackLevel.refNode, context.rewriteFromModel); - } + cleanUpRestNodes(stackLevel.refNode, context.rewriteFromModel); } nodeStack.splice(layer + 1); @@ -83,7 +77,7 @@ export const handleList: ContentModelBlockHandler = ( context.listFormat.currentLevel = layer; - if (context.allowCacheListItem && level.cachedElement) { + if (level.cachedElement) { newList = level.cachedElement; nodeStack[layer].refNode = reuseCachedElement( @@ -112,9 +106,7 @@ export const handleList: ContentModelBlockHandler = ( dataset: { ...level.dataset }, }); - if (context.allowCacheListItem) { - level.cachedElement = newList; - } + level.cachedElement = newList; } applyFormat(newList, context.formatAppliers.listLevelThread, level.format, context); diff --git a/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleListItem.ts b/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleListItem.ts index 5f6ca79ce67e..1d6d51e42f43 100644 --- a/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleListItem.ts +++ b/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleListItem.ts @@ -36,7 +36,7 @@ export const handleListItem: ContentModelBlockHandler = ( let li: HTMLLIElement; let isNewlyCreated = false; - if (context.allowCacheListItem && listItem.cachedElement) { + if (listItem.cachedElement) { li = listItem.cachedElement; // Check if the cached LI is used as refNode under another list level, @@ -62,10 +62,7 @@ export const handleListItem: ContentModelBlockHandler = ( // This happens when outdent a list item to cause it has no list level listParent.insertBefore(li, itemRefNode?.parentNode == listParent ? itemRefNode : null); context.rewriteFromModel.addedBlockElements.push(li); - - if (context.allowCacheListItem) { - listItem.cachedElement = li; - } + listItem.cachedElement = li; } if (level) { diff --git a/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleBlockGroupChildrenTest.ts b/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleBlockGroupChildrenTest.ts index 9081f216f454..ed79cbfaa9a5 100644 --- a/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleBlockGroupChildrenTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleBlockGroupChildrenTest.ts @@ -489,7 +489,6 @@ describe('handleBlockGroupChildren', () => { group.blocks.push(listItem, paragraph); context.listFormat.nodeStack = nodeStack; - context.allowCacheListItem = true; expect(parent.outerHTML).toBe('

        '); diff --git a/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleListItemTest.ts b/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleListItemTest.ts index a473c59606fb..1d31b5764bf2 100644 --- a/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleListItemTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleListItemTest.ts @@ -465,7 +465,6 @@ describe('handleListItem with cache', () => { listItem: { applierFunction: listItemMetadataApplier }, }, }); - context.allowCacheListItem = true; context.onNodeCreated = onNodeCreatedSpy; spyOn(applyFormat, 'applyFormat').and.callThrough(); }); diff --git a/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleListTest.ts b/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleListTest.ts index 4cf5be44d043..1e82b4a4fcf0 100644 --- a/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleListTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleListTest.ts @@ -740,7 +740,6 @@ describe('handleList with cache', () => { ]); parent.appendChild(cachedUL); - context.allowCacheListItem = true; const newRefNode = handleList(document, parent, listItem, context, cachedUL); @@ -783,7 +782,6 @@ describe('handleList with cache', () => { listItem.levels[0].cachedElement = cachedUL; parent.appendChild(cachedUL); - context.allowCacheListItem = true; const newRefNode = handleList(document, parent, listItem, context, cachedUL); @@ -831,7 +829,6 @@ describe('handleList with cache', () => { listItem.levels[0].cachedElement = cachedUL; parent.appendChild(refNode); - context.allowCacheListItem = true; const newRefNode = handleList(document, parent, listItem, context, refNode); @@ -878,7 +875,6 @@ describe('handleList with cache', () => { listItem.levels[0].cachedElement = cachedUL; parent.appendChild(cachedUL); - context.allowCacheListItem = true; const newRefNode = handleList(document, parent, listItem, context, cachedUL); @@ -929,7 +925,7 @@ describe('handleList with cache', () => { listItem.levels[1].cachedElement = cachedUL; parent.appendChild(cachedOL); cachedOL.appendChild(cachedUL); - context.allowCacheListItem = true; + const newRefNode = handleList(document, parent, listItem, context, cachedUL); expect(parent.outerHTML).toBe('
          '); @@ -984,7 +980,7 @@ describe('handleList with cache', () => { parent.appendChild(refNode); listItem.levels[1].cachedElement = cachedUL; - context.allowCacheListItem = true; + const newRefNode = handleList(document, parent, listItem, context, refNode); expect(parent.outerHTML).toBe('

            '); @@ -1036,7 +1032,6 @@ describe('handleList with cache', () => { parent.appendChild(existingOL1); existingOL1.appendChild(existingLI); existingOL1.appendChild(existingOL2); - context.allowCacheListItem = true; context.listFormat.nodeStack = [ { node: parent, refNode: existingOL1 }, @@ -1090,7 +1085,6 @@ describe('handleList with cache', () => { ]); listItem.levels[0].cachedElement = existingOL1; - context.allowCacheListItem = true; context.listFormat.nodeStack = [ { node: parent, refNode: null }, diff --git a/packages/roosterjs-content-model-plugins/lib/edit/EditOptions.ts b/packages/roosterjs-content-model-plugins/lib/edit/EditOptions.ts index 86f814472a79..e5f85d5ae098 100644 --- a/packages/roosterjs-content-model-plugins/lib/edit/EditOptions.ts +++ b/packages/roosterjs-content-model-plugins/lib/edit/EditOptions.ts @@ -55,6 +55,7 @@ export type EditOptions = { handleExpandedSelectionOnDelete?: boolean; /** + * @deprecated This is always treated as true now * Callback function to determine whether the Rooster should handle the Enter key press. * If the function returns true, the Rooster will handle the Enter key press instead of the browser. * @param editor - The editor instance. diff --git a/packages/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts b/packages/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts index f2b1c8409eb8..6954ed6027c7 100644 --- a/packages/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts +++ b/packages/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts @@ -55,7 +55,6 @@ export class EditPlugin implements EditorPlugin { private disposer: (() => void) | null = null; private shouldHandleNextInputEvent = false; private selectionAfterDelete: DOMSelection | null = null; - private handleNormalEnter: (editor: IEditor) => boolean = () => false; private options: EditOptions & { handleTabKey: Required }; /** @@ -72,23 +71,6 @@ export class EditPlugin implements EditorPlugin { this.options = { ...DefaultOptions, ...options, handleTabKey: tabOptions }; } - private createNormalEnterChecker(result: boolean) { - return result ? () => true : () => false; - } - - private getHandleNormalEnter(editor: IEditor) { - switch (typeof this.options.shouldHandleEnterKey) { - case 'function': - return this.options.shouldHandleEnterKey; - case 'boolean': - return this.createNormalEnterChecker(this.options.shouldHandleEnterKey); - default: - return this.createNormalEnterChecker( - editor.isExperimentalFeatureEnabled('HandleEnterKey') - ); - } - } - /** * Get name of this plugin */ @@ -104,7 +86,6 @@ export class EditPlugin implements EditorPlugin { */ initialize(editor: IEditor) { this.editor = editor; - this.handleNormalEnter = this.getHandleNormalEnter(editor); if (editor.getEnvironment().isAndroid) { this.disposer = this.editor.attachDomEvent({ @@ -224,12 +205,7 @@ export class EditPlugin implements EditorPlugin { !event.rawEvent.isComposing && event.rawEvent.keyCode !== DEAD_KEY ) { - keyboardEnter( - editor, - rawEvent, - this.handleNormalEnter(editor), - this.options.formatsToPreserveOnMerge - ); + keyboardEnter(editor, rawEvent, this.options.formatsToPreserveOnMerge); } break; diff --git a/packages/roosterjs-content-model-plugins/lib/edit/keyboardEnter.ts b/packages/roosterjs-content-model-plugins/lib/edit/keyboardEnter.ts index 08b5c74d5cc0..2ee5581efb6e 100644 --- a/packages/roosterjs-content-model-plugins/lib/edit/keyboardEnter.ts +++ b/packages/roosterjs-content-model-plugins/lib/edit/keyboardEnter.ts @@ -8,7 +8,7 @@ import { normalizeContentModel, runEditSteps, } from 'roosterjs-content-model-dom'; -import type { IEditor, ReadonlyContentModelParagraph } from 'roosterjs-content-model-types'; +import type { IEditor } from 'roosterjs-content-model-types'; /** * @internal @@ -16,7 +16,6 @@ import type { IEditor, ReadonlyContentModelParagraph } from 'roosterjs-content-m export function keyboardEnter( editor: IEditor, rawEvent: KeyboardEvent, - handleNormalEnter: boolean, formatsToPreserveOnMerge: string[] = [] ) { const selection = editor.getDOMSelection(); @@ -36,9 +35,7 @@ export function keyboardEnter( ? [] : [handleAutoLink, handleEnterOnList, deleteEmptyQuote]; - if (handleNormalEnter || handleEnterForEntity(result.insertPoint?.paragraph)) { - steps.push(handleEnterOnParagraph(formatsToPreserveOnMerge)); - } + steps.push(handleEnterOnParagraph(formatsToPreserveOnMerge)); runEditSteps(steps, result); } @@ -64,10 +61,3 @@ export function keyboardEnter( } ); } - -function handleEnterForEntity(paragraph: ReadonlyContentModelParagraph | undefined) { - return ( - paragraph && - (paragraph.isImplicit || paragraph.segments.some(x => x.segmentType == 'Entity')) - ); -} diff --git a/packages/roosterjs-content-model-plugins/test/edit/EditPluginTest.ts b/packages/roosterjs-content-model-plugins/test/edit/EditPluginTest.ts index b687e5969f80..bf88bde843d8 100644 --- a/packages/roosterjs-content-model-plugins/test/edit/EditPluginTest.ts +++ b/packages/roosterjs-content-model-plugins/test/edit/EditPluginTest.ts @@ -20,7 +20,6 @@ describe('EditPlugin', () => { let eventMap: Record; let attachDOMEventSpy: jasmine.Spy; let getEnvironmentSpy: jasmine.Spy; - let isExperimentalFeatureEnabledSpy: jasmine.Spy; beforeEach(() => { attachDOMEventSpy = jasmine @@ -32,9 +31,6 @@ describe('EditPlugin', () => { getEnvironmentSpy = jasmine.createSpy('getEnvironment').and.returnValue({ isAndroid: true, }); - isExperimentalFeatureEnabledSpy = jasmine - .createSpy('isExperimentalFeatureEnabled') - .and.returnValue(false); editor = ({ attachDomEvent: attachDOMEventSpy, @@ -43,7 +39,6 @@ describe('EditPlugin', () => { ({ type: -1, } as any), // Force return invalid range to go through content model code - isExperimentalFeatureEnabled: isExperimentalFeatureEnabledSpy, } as any) as IEditor; }); @@ -122,7 +117,7 @@ describe('EditPlugin', () => { }); it('handleExpandedSelectionOnDelete with options', () => { - plugin = new EditPlugin({ shouldHandleEnterKey: true }); + plugin = new EditPlugin(); const rawEvent = { key: 'Delete' } as any; plugin.initialize(editor); @@ -135,7 +130,6 @@ describe('EditPlugin', () => { expect(keyboardDeleteSpy).toHaveBeenCalledWith(editor, rawEvent, { handleTabKey: DefaultHandleTabOptions, handleExpandedSelectionOnDelete: true, - shouldHandleEnterKey: true, }); }); @@ -191,9 +185,7 @@ describe('EditPlugin', () => { }); it('Tab - custom options with options', () => { - plugin = new EditPlugin({ - shouldHandleEnterKey: true, - }); + plugin = new EditPlugin(); const rawEvent = { key: 'Tab' } as any; plugin.initialize(editor); @@ -248,14 +240,11 @@ describe('EditPlugin', () => { expect(keyboardDeleteSpy).not.toHaveBeenCalled(); expect(keyboardInputSpy).not.toHaveBeenCalled(); - expect(keyboardEnterSpy).toHaveBeenCalledWith(editor, rawEvent, false, undefined); + expect(keyboardEnterSpy).toHaveBeenCalledWith(editor, rawEvent, undefined); expect(keyboardTabSpy).not.toHaveBeenCalled(); }); it('Enter, normal enter enabled with experimental feature', () => { - isExperimentalFeatureEnabledSpy.and.callFake( - (featureName: string) => featureName == 'HandleEnterKey' - ); plugin = new EditPlugin(); const rawEvent = { keyCode: 13, which: 13, key: 'Enter' } as any; const addUndoSnapshotSpy = jasmine.createSpy('addUndoSnapshot'); @@ -271,14 +260,12 @@ describe('EditPlugin', () => { expect(keyboardDeleteSpy).not.toHaveBeenCalled(); expect(keyboardInputSpy).not.toHaveBeenCalled(); - expect(keyboardEnterSpy).toHaveBeenCalledWith(editor, rawEvent, true, undefined); + expect(keyboardEnterSpy).toHaveBeenCalledWith(editor, rawEvent, undefined); expect(keyboardTabSpy).not.toHaveBeenCalled(); }); it('Enter, normal enter enabled', () => { - plugin = new EditPlugin({ - shouldHandleEnterKey: true, - }); + plugin = new EditPlugin(); const rawEvent = { keyCode: 13, which: 13, key: 'Enter' } as any; const addUndoSnapshotSpy = jasmine.createSpy('addUndoSnapshot'); @@ -293,16 +280,12 @@ describe('EditPlugin', () => { expect(keyboardDeleteSpy).not.toHaveBeenCalled(); expect(keyboardInputSpy).not.toHaveBeenCalled(); - expect(keyboardEnterSpy).toHaveBeenCalledWith(editor, rawEvent, true, undefined); + expect(keyboardEnterSpy).toHaveBeenCalledWith(editor, rawEvent, undefined); expect(keyboardTabSpy).not.toHaveBeenCalled(); }); it('Enter, normal enter enabled with callback', () => { - plugin = new EditPlugin({ - shouldHandleEnterKey: _editor => { - return true; - }, - }); + plugin = new EditPlugin(); const rawEvent = { keyCode: 13, which: 13, key: 'Enter' } as any; const addUndoSnapshotSpy = jasmine.createSpy('addUndoSnapshot'); @@ -317,7 +300,7 @@ describe('EditPlugin', () => { expect(keyboardDeleteSpy).not.toHaveBeenCalled(); expect(keyboardInputSpy).not.toHaveBeenCalled(); - expect(keyboardEnterSpy).toHaveBeenCalledWith(editor, rawEvent, true, undefined); + expect(keyboardEnterSpy).toHaveBeenCalledWith(editor, rawEvent, undefined); expect(keyboardTabSpy).not.toHaveBeenCalled(); }); @@ -495,10 +478,6 @@ describe('EditPlugin', () => { beforeEach(() => { keyboardEnterSpy = spyOn(keyboardEnter, 'keyboardEnter'); - // Configure editor to handle Enter key (needed for handleNormalEnter to return true) - isExperimentalFeatureEnabledSpy.and.callFake((feature: string) => { - return feature === 'HandleEnterKey'; - }); }); it('should pass formatsToPreserveOnMerge to keyboardEnter', () => { @@ -523,7 +502,7 @@ describe('EditPlugin', () => { }); expect(keyboardEnterSpy).toHaveBeenCalledTimes(1); - expect(keyboardEnterSpy).toHaveBeenCalledWith(editor, rawEvent, true, [ + expect(keyboardEnterSpy).toHaveBeenCalledWith(editor, rawEvent, [ 'className', 'fontFamily', ]); @@ -550,7 +529,7 @@ describe('EditPlugin', () => { }); expect(keyboardEnterSpy).toHaveBeenCalledTimes(1); - expect(keyboardEnterSpy).toHaveBeenCalledWith(editor, rawEvent, true, undefined); + expect(keyboardEnterSpy).toHaveBeenCalledWith(editor, rawEvent, undefined); }); it('should pass empty formatsToPreserveOnMerge array', () => { @@ -575,7 +554,7 @@ describe('EditPlugin', () => { }); expect(keyboardEnterSpy).toHaveBeenCalledTimes(1); - expect(keyboardEnterSpy).toHaveBeenCalledWith(editor, rawEvent, true, []); + expect(keyboardEnterSpy).toHaveBeenCalledWith(editor, rawEvent, []); }); it('should work with multiple custom format properties', () => { @@ -600,7 +579,7 @@ describe('EditPlugin', () => { }); expect(keyboardEnterSpy).toHaveBeenCalledTimes(1); - expect(keyboardEnterSpy).toHaveBeenCalledWith(editor, rawEvent, true, [ + expect(keyboardEnterSpy).toHaveBeenCalledWith(editor, rawEvent, [ 'className', 'customProp', 'data-testid', diff --git a/packages/roosterjs-content-model-plugins/test/edit/inputSteps/handleEnterOnListTest.ts b/packages/roosterjs-content-model-plugins/test/edit/inputSteps/handleEnterOnListTest.ts index c902d166c354..be234488ffd2 100644 --- a/packages/roosterjs-content-model-plugins/test/edit/inputSteps/handleEnterOnListTest.ts +++ b/packages/roosterjs-content-model-plugins/test/edit/inputSteps/handleEnterOnListTest.ts @@ -1929,7 +1929,7 @@ describe('handleEnterOnList - keyboardEnter', () => { }, }); - keyboardEnter(editor, mockedEvent, true); + keyboardEnter(editor, mockedEvent); }, input, expectedResult, diff --git a/packages/roosterjs-content-model-plugins/test/edit/keyboardEnterTest.ts b/packages/roosterjs-content-model-plugins/test/edit/keyboardEnterTest.ts index 02ced3f372c4..d8a5c9cf8892 100644 --- a/packages/roosterjs-content-model-plugins/test/edit/keyboardEnterTest.ts +++ b/packages/roosterjs-content-model-plugins/test/edit/keyboardEnterTest.ts @@ -61,7 +61,7 @@ describe('keyboardEnter', () => { expect(); }); - keyboardEnter(editor, rawEvent, true); + keyboardEnter(editor, rawEvent); expect(formatContentModelSpy).toHaveBeenCalledTimes(1); expect(input).toEqual(output); @@ -1396,7 +1396,7 @@ describe('keyboardEnter', () => { const runEditStepsSpy = spyOn(runEditSteps, 'runEditSteps'); - keyboardEnter(editor, {} as any, false, []); + keyboardEnter(editor, {} as any, []); expect(runEditStepsSpy).toHaveBeenCalledTimes(2); // Check that the second call to runEditSteps includes steps for handling entity @@ -1434,7 +1434,7 @@ describe('keyboardEnter', () => { const runEditStepsSpy = spyOn(runEditSteps, 'runEditSteps'); - keyboardEnter(editor, {} as any, false, []); + keyboardEnter(editor, {} as any, []); expect(runEditStepsSpy).toHaveBeenCalledTimes(2); expect( @@ -1479,7 +1479,6 @@ describe('keyboardEnter', () => { keyboardEnter( editor, { preventDefault: () => {}, shiftKey: false } as any, - true, formatsToPreserve ); @@ -1515,7 +1514,7 @@ describe('keyboardEnter', () => { callback(model, context); }); - keyboardEnter(editor, { preventDefault: () => {}, shiftKey: false } as any, true, []); + keyboardEnter(editor, { preventDefault: () => {}, shiftKey: false } as any, []); expect(formatContentModelSpy).toHaveBeenCalledTimes(1); }); @@ -1549,7 +1548,7 @@ describe('keyboardEnter', () => { callback(model, context); }); - keyboardEnter(editor, { preventDefault: () => {}, shiftKey: false } as any, true); + keyboardEnter(editor, { preventDefault: () => {}, shiftKey: false } as any); expect(formatContentModelSpy).toHaveBeenCalledTimes(1); }); diff --git a/packages/roosterjs-content-model-types/lib/context/EditorContext.ts b/packages/roosterjs-content-model-types/lib/context/EditorContext.ts index 4f7752faa4bb..ba11488e89bc 100644 --- a/packages/roosterjs-content-model-types/lib/context/EditorContext.ts +++ b/packages/roosterjs-content-model-types/lib/context/EditorContext.ts @@ -50,6 +50,7 @@ export interface EditorContext { allowCacheElement?: boolean; /** + * @deprecated This is now always be treated as true * Whether to allow caching list item elements separately. */ allowCacheListItem?: boolean; diff --git a/packages/roosterjs-content-model-types/lib/editor/ExperimentalFeature.ts b/packages/roosterjs-content-model-types/lib/editor/ExperimentalFeature.ts index 3120182d7ba9..4b4e7f3be7a2 100644 --- a/packages/roosterjs-content-model-types/lib/editor/ExperimentalFeature.ts +++ b/packages/roosterjs-content-model-types/lib/editor/ExperimentalFeature.ts @@ -18,15 +18,13 @@ export type GraduatedExperimentalFeature = * Prevent default browser behavior for copy/cut event, * and set the clipboard data with custom implementation. */ - | 'CustomCopyCut'; + | 'CustomCopyCut' -/** - * Predefined experiment features - * By default these features are not enabled. To enable them, pass the feature name into EditorOptions.experimentalFeatures - * when create editor - */ -export type ExperimentalFeature = - | GraduatedExperimentalFeature + /** + * @deprecated + * Export editor content as HTML using HTMLFast option + */ + | 'ExportHTMLFast' /** * @deprecated Please use the shouldHandleEnterKey option of the EditPlugin Options @@ -35,28 +33,33 @@ export type ExperimentalFeature = | 'HandleEnterKey' /** - * For CJK keyboard input on mobile, if the user toggles bold/italic/underline on an empty div, - * the pending format will be applied on the selection marker. When typing text, the selection moves to the text node and the - * selection marker may be recreated during reconciliation, potentially losing its original formatting. This feature ensures - * the original formatting of the selection marker is kept to match the pending format. - */ - | 'KeepSelectionMarkerWhenEnteringTextNode' - - /** - * Export editor content as HTML using HTMLFast option - */ - | 'ExportHTMLFast' - - /** + * @deprecated * Get cloned root element from an independent HTML document instead of current document. * So any operation to the cloned root won't trigger network request for resources like images */ | 'CloneIndependentRoot' /** + * @deprecated * Allow caching list item elements. */ - | 'CacheList' + | 'CacheList'; + +/** + * Predefined experiment features + * By default these features are not enabled. To enable them, pass the feature name into EditorOptions.experimentalFeatures + * when create editor + */ +export type ExperimentalFeature = + | GraduatedExperimentalFeature + + /** + * For CJK keyboard input on mobile, if the user toggles bold/italic/underline on an empty div, + * the pending format will be applied on the selection marker. When typing text, the selection moves to the text node and the + * selection marker may be recreated during reconciliation, potentially losing its original formatting. This feature ensures + * the original formatting of the selection marker is kept to match the pending format. + */ + | 'KeepSelectionMarkerWhenEnteringTextNode' /** * Transform the table border colors when switching from light to dark mode diff --git a/packages/roosterjs-editor-adapter/lib/editor/EditorAdapter.ts b/packages/roosterjs-editor-adapter/lib/editor/EditorAdapter.ts index 91b64831958a..45b3f2618371 100644 --- a/packages/roosterjs-editor-adapter/lib/editor/EditorAdapter.ts +++ b/packages/roosterjs-editor-adapter/lib/editor/EditorAdapter.ts @@ -354,13 +354,7 @@ export class EditorAdapter extends Editor implements ILegacyEditor { switch (exportMode) { case 'HTML': - return this.isExperimentalFeatureEnabled('ExportHTMLFast') - ? exportContent(this, 'HTMLFast') - : exportContent( - this, - 'HTML', - this.getCore().environment.modelToDomSettings.customized - ); + return exportContent(this, 'HTMLFast'); case 'PlainText': return exportContent(this, 'PlainText'); From b9e2819157b2efadfdbc8946b04f2df75ddb3513 Mon Sep 17 00:00:00 2001 From: Vi Nguyen <36.tuongvi@gmail.com> Date: Fri, 24 Apr 2026 11:28:21 -0700 Subject: [PATCH 15/15] fix version file --- versions.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/versions.json b/versions.json index e170021a53bd..1e20c30ddb70 100644 --- a/versions.json +++ b/versions.json @@ -1,6 +1,6 @@ { "react": "9.0.4", - "main": "9.50.1", - "legacyAdapter": "8.65.3", + "main": "9.51.0", + "legacyAdapter": "8.66.0", "overrides": {} }