From 887b134638db8d19d7c14ea74bcd3421a6b36106 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Mon, 22 Jan 2024 12:10:20 -0800 Subject: [PATCH] Standalone Editor: Remove dependency to old code from api and plugins package --- .../controls/ContentModelEditorMainPane.tsx | 9 +- .../lib/modelApi/link/matchLink.ts | 114 ++++++++ .../lib/publicApi/entity/insertEntity.ts | 18 +- .../lib/publicApi/link/insertLink.ts | 15 +- .../roosterjs-content-model-api/package.json | 2 - .../test/modelApi/link/matchLinkTest.ts | 260 ++++++++++++++++++ .../test/publicApi/link/insertLinkTest.ts | 43 +++ .../publicApi/segment/segmentTestCommon.ts | 2 - .../lib/corePlugins/BridgePlugin.ts | 3 + .../lib/corePlugins}/EntityDelimiterPlugin.ts | 13 +- .../test/corePlugins/BridgePluginTest.ts | 5 +- .../lib/index.ts | 1 - .../package.json | 2 - ...processPastedContentFromWordDesktopTest.ts | 38 ++- .../lib/createContentModelEditor.ts | 8 +- 15 files changed, 463 insertions(+), 70 deletions(-) create mode 100644 packages-content-model/roosterjs-content-model-api/lib/modelApi/link/matchLink.ts create mode 100644 packages-content-model/roosterjs-content-model-api/test/modelApi/link/matchLinkTest.ts rename packages-content-model/{roosterjs-content-model-plugins/lib/entityDelimiter => roosterjs-content-model-editor/lib/corePlugins}/EntityDelimiterPlugin.ts (97%) diff --git a/demo/scripts/controls/ContentModelEditorMainPane.tsx b/demo/scripts/controls/ContentModelEditorMainPane.tsx index 7ddadb797fc..86717594ce3 100644 --- a/demo/scripts/controls/ContentModelEditorMainPane.tsx +++ b/demo/scripts/controls/ContentModelEditorMainPane.tsx @@ -16,6 +16,7 @@ import SampleEntityPlugin from './sampleEntity/SampleEntityPlugin'; import SidePane from './sidePane/SidePane'; import TitleBar from './titleBar/TitleBar'; import { arrayPush } from 'roosterjs-editor-dom'; +import { ContentModelEditPlugin, ContentModelPastePlugin } from 'roosterjs-content-model-plugins'; import { ContentModelRibbonPlugin } from './ribbonButtons/contentModel/ContentModelRibbonPlugin'; import { ContentModelSegmentFormat, Snapshots } from 'roosterjs-content-model-types'; import { createEmojiPlugin, createPasteOptionPlugin } from 'roosterjs-react'; @@ -23,11 +24,6 @@ import { EditorPlugin } from 'roosterjs-editor-types'; import { getDarkColor } from 'roosterjs-color-utils'; import { PartialTheme } from '@fluentui/react/lib/Theme'; import { trustedHTMLHandler } from '../utils/trustedHTMLHandler'; -import { - ContentModelEditPlugin, - ContentModelPastePlugin, - EntityDelimiterPlugin, -} from 'roosterjs-content-model-plugins'; import { ContentModelEditor, ContentModelEditorOptions, @@ -105,7 +101,6 @@ class ContentModelEditorMainPane extends MainPaneBase private pasteOptionPlugin: EditorPlugin; private emojiPlugin: EditorPlugin; private snapshotPlugin: ContentModelSnapshotPlugin; - private entityDelimiterPlugin: EntityDelimiterPlugin; private toggleablePlugins: EditorPlugin[] | null = null; private formatPainterPlugin: ContentModelFormatPainterPlugin; private pastePlugin: ContentModelPastePlugin; @@ -133,7 +128,6 @@ class ContentModelEditorMainPane extends MainPaneBase this.contentModelRibbonPlugin = new ContentModelRibbonPlugin(); this.pasteOptionPlugin = createPasteOptionPlugin(); this.emojiPlugin = createEmojiPlugin(); - this.entityDelimiterPlugin = new EntityDelimiterPlugin(); this.formatPainterPlugin = new ContentModelFormatPainterPlugin(); this.pastePlugin = new ContentModelPastePlugin(); this.sampleEntityPlugin = new SampleEntityPlugin(); @@ -195,7 +189,6 @@ class ContentModelEditorMainPane extends MainPaneBase this.contentModelPanePlugin.getInnerRibbonPlugin(), this.pasteOptionPlugin, this.emojiPlugin, - this.entityDelimiterPlugin, this.sampleEntityPlugin, ]; diff --git a/packages-content-model/roosterjs-content-model-api/lib/modelApi/link/matchLink.ts b/packages-content-model/roosterjs-content-model-api/lib/modelApi/link/matchLink.ts new file mode 100644 index 00000000000..b2238fe08e9 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-api/lib/modelApi/link/matchLink.ts @@ -0,0 +1,114 @@ +import { getObjectKeys } from 'roosterjs-content-model-dom'; + +/** + * @internal + */ +export interface LinkData { + /** + * Schema of a hyperlink + */ + scheme: string; + + /** + * Original url of a hyperlink + */ + originalUrl: string; + + /** + * Normalized url of a hyperlink + */ + normalizedUrl: string; +} + +interface LinkMatchRule { + match: RegExp; + except?: RegExp; + normalizeUrl?: (url: string) => string; +} + +// http exclude matching regex +// invalid URL example (in particular on IE and Edge): +// - http://www.bing.com%00, %00 before ? (question mark) is considered invalid. IE/Edge throws invalid argument exception +// - http://www.bing.com%1, %1 is invalid +// - http://www.bing.com%g, %g is invalid (IE and Edge expects a two hex value after a %) +// - http://www.bing.com%, % as ending is invalid (IE and Edge expects a two hex value after a %) +// All above % cases if they're after ? (question mark) is then considered valid again +// Similar for @, it needs to be after / (forward slash), or ? (question mark). Otherwise IE/Edge will throw security exception +// - http://www.bing.com@name, @name before ? (question mark) is considered invalid +// - http://www.bing.com/@name, is valid sine it is after / (forward slash) +// - http://www.bing.com?@name, is also valid since it is after ? (question mark) +// The regex below is essentially a break down of: +// ^[^?]+%[^0-9a-f]+ => to exclude URL like www.bing.com%% +// ^[^?]+%[0-9a-f][^0-9a-f]+ => to exclude URL like www.bing.com%1 +// ^[^?]+%00 => to exclude URL like www.bing.com%00 +// ^[^?]+%$ => to exclude URL like www.bing.com% +// ^https?:\/\/[^?\/]+@ => to exclude URL like http://www.bing.com@name +// ^www\.[^?\/]+@ => to exclude URL like www.bing.com@name +// , => to exclude url like www.bing,,com +const httpExcludeRegEx = /^[^?]+%[^0-9a-f]+|^[^?]+%[0-9a-f][^0-9a-f]+|^[^?]+%00|^[^?]+%$|^https?:\/\/[^?\/]+@|^www\.[^?\/]+@/i; + +// via https://tools.ietf.org/html/rfc1035 Page 7 +const labelRegEx = '[a-z0-9](?:[a-z0-9-]*[a-z0-9])?'; // We're using case insensitive regexps below so don't bother including A-Z +const domainNameRegEx = `(?:${labelRegEx}\\.)*${labelRegEx}`; +const domainPortRegEx = `${domainNameRegEx}(?:\\:[0-9]+)?`; +const domainPortWithUrlRegEx = `${domainPortRegEx}(?:[\\/\\?]\\S*)?`; + +const linkMatchRules: Record = { + http: { + match: new RegExp( + `^(?:microsoft-edge:)?http:\\/\\/${domainPortWithUrlRegEx}|www\\.${domainPortWithUrlRegEx}`, + 'i' + ), + except: httpExcludeRegEx, + normalizeUrl: url => + new RegExp('^(?:microsoft-edge:)?http:\\/\\/', 'i').test(url) ? url : 'http://' + url, + }, + https: { + match: new RegExp(`^(?:microsoft-edge:)?https:\\/\\/${domainPortWithUrlRegEx}`, 'i'), + except: httpExcludeRegEx, + }, + mailto: { match: new RegExp('^mailto:\\S+@\\S+\\.\\S+', 'i') }, + notes: { match: new RegExp('^notes:\\/\\/\\S+', 'i') }, + file: { match: new RegExp('^file:\\/\\/\\/?\\S+', 'i') }, + unc: { match: new RegExp('^\\\\\\\\\\S+', 'i') }, + ftp: { + match: new RegExp( + `^ftp:\\/\\/${domainPortWithUrlRegEx}|ftp\\.${domainPortWithUrlRegEx}`, + 'i' + ), + normalizeUrl: url => (new RegExp('^ftp:\\/\\/', 'i').test(url) ? url : 'ftp://' + url), + }, + news: { match: new RegExp(`^news:(\\/\\/)?${domainPortWithUrlRegEx}`, 'i') }, + telnet: { match: new RegExp(`^telnet:(\\/\\/)?${domainPortWithUrlRegEx}`, 'i') }, + gopher: { match: new RegExp(`^gopher:\\/\\/${domainPortWithUrlRegEx}`, 'i') }, + wais: { match: new RegExp(`^wais:(\\/\\/)?${domainPortWithUrlRegEx}`, 'i') }, +}; + +/** + * @internal + * Try to match a given string with link match rules, return matched link + * @param url Input url to match + * @param option Link match option, exact or partial. If it is exact match, we need + * to check the length of matched link and url + * @param rules Optional link match rules, if not passed, only the default link match + * rules will be applied + * @returns The matched link data, or null if no match found. + * The link data includes an original url and a normalized url + */ +export function matchLink(url: string): LinkData | null { + if (url) { + for (const schema of getObjectKeys(linkMatchRules)) { + const rule = linkMatchRules[schema]; + const matches = url.match(rule.match); + if (matches && matches[0] == url && (!rule.except || !rule.except.test(url))) { + return { + scheme: schema, + originalUrl: url, + normalizedUrl: rule.normalizeUrl ? rule.normalizeUrl(url) : url, + }; + } + } + } + + return null; +} diff --git a/packages-content-model/roosterjs-content-model-api/lib/publicApi/entity/insertEntity.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/entity/insertEntity.ts index 7b7b4701d5d..2cefb62eb9b 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/publicApi/entity/insertEntity.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/entity/insertEntity.ts @@ -8,7 +8,6 @@ import type { InsertEntityOptions, IStandaloneEditor, } from 'roosterjs-content-model-types'; -import type { Entity } from 'roosterjs-editor-types'; const BlockEntityTag = 'div'; const InlineEntityTag = 'span'; @@ -91,17 +90,12 @@ export default function insertEntity( { selectionOverride: typeof position === 'object' ? position : undefined, changeSource: ChangeSource.InsertEntity, - getChangeData: () => { - // TODO: Remove this entity when we have standalone editor - const entity: Entity = { - wrapper, - type, - id: '', - isReadonly: true, - }; - - return entity; - }, + getChangeData: () => ({ + wrapper, + type, + id: '', + isReadonly: true, + }), apiName: 'insertEntity', } ); diff --git a/packages-content-model/roosterjs-content-model-api/lib/publicApi/link/insertLink.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/link/insertLink.ts index 64b68247a7b..90b16943afe 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/publicApi/link/insertLink.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/link/insertLink.ts @@ -1,6 +1,6 @@ import { adjustTrailingSpaceSelection } from '../../modelApi/selection/adjustTrailingSpaceSelection'; import { ChangeSource, getSelectedSegments, mergeModel } from 'roosterjs-content-model-core'; -import { HtmlSanitizer, matchLink } from 'roosterjs-editor-dom'; +import { matchLink } from '../../modelApi/link/matchLink'; import type { ContentModelLink, IStandaloneEditor } from 'roosterjs-content-model-types'; import { addLink, @@ -126,7 +126,6 @@ const createLink = ( }; }; -// TODO: This is copied from original code. We may need to integrate this logic into matchLink() later. function applyLinkPrefix(url: string): string { if (!url) { return url; @@ -152,16 +151,6 @@ function applyLinkPrefix(url: string): string { return prefix + url; } -// TODO: This is copied from original code. However, ContentModel should be able to filter out malicious -// attributes later, so no need to use HtmlSanitizer here function checkXss(link: string): string { - const sanitizer = new HtmlSanitizer(); - const a = document.createElement('a'); - - a.href = link || ''; - - sanitizer.sanitize(a); - // We use getAttribute because some browsers will try to make the href property a valid link. - // This has unintended side effects when the link lacks a protocol. - return a.getAttribute('href') || ''; + return link.match(/s\n*c\n*r\n*i\n*p\n*t\n*:/i) ? '' : link; } diff --git a/packages-content-model/roosterjs-content-model-api/package.json b/packages-content-model/roosterjs-content-model-api/package.json index d4f7558e6ae..9981d13b011 100644 --- a/packages-content-model/roosterjs-content-model-api/package.json +++ b/packages-content-model/roosterjs-content-model-api/package.json @@ -3,8 +3,6 @@ "description": "Content Model for roosterjs (Under development)", "dependencies": { "tslib": "^2.3.1", - "roosterjs-editor-types": "", - "roosterjs-editor-dom": "", "roosterjs-content-model-core": "", "roosterjs-content-model-dom": "", "roosterjs-content-model-types": "" diff --git a/packages-content-model/roosterjs-content-model-api/test/modelApi/link/matchLinkTest.ts b/packages-content-model/roosterjs-content-model-api/test/modelApi/link/matchLinkTest.ts new file mode 100644 index 00000000000..ad25b487f07 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-api/test/modelApi/link/matchLinkTest.ts @@ -0,0 +1,260 @@ +import { LinkData, matchLink } from '../../../lib/modelApi/link/matchLink'; + +function runMatchTestWithValidLink(link: string, expected: LinkData): void { + let resultData = matchLink(link); + expect(resultData).not.toBe(null); + expect(resultData.scheme).toBe(expected.scheme); + expect(resultData.originalUrl).toBe(expected.originalUrl); + expect(resultData.normalizedUrl).toBe(expected.normalizedUrl); +} + +function runMatchTestWithBadLink(link: string): void { + let linkData = matchLink(link); + expect(linkData).toBeNull(); +} + +describe('defaultLinkMatchRules regular http links with extact match', () => { + it('http://www.bing.com', () => { + let link = 'http://www.bing.com'; + runMatchTestWithValidLink(link, { scheme: 'http', originalUrl: link, normalizedUrl: link }); + }); + + it('http://www.bing.com/', () => { + let link = 'http://www.bing.com/'; + runMatchTestWithValidLink(link, { scheme: 'http', originalUrl: link, normalizedUrl: link }); + }); + + it('http://1drv.com/test', () => { + let link = 'http://1drv.com/test'; + runMatchTestWithValidLink(link, { scheme: 'http', originalUrl: link, normalizedUrl: link }); + }); + + it('www.1234.com/test', () => { + let link = 'www.1234.com/test'; + runMatchTestWithValidLink(link, { + scheme: 'http', + originalUrl: link, + normalizedUrl: 'http://' + link, + }); + }); + + it('http://www.lifewire.com/how-torrent-downloading-works-2483513', () => { + let link = 'http://www.lifewire.com/how-torrent-downloading-works-2483513'; + runMatchTestWithValidLink(link, { scheme: 'http', originalUrl: link, normalizedUrl: link }); + }); +}); + +describe('defaultLinkMatchRules regular www links with extact match', () => { + it('www.eartheasy.com/grow_compost.html', () => { + let link = 'www.eartheasy.com/grow_compost.html'; + runMatchTestWithValidLink(link, { + scheme: 'http', + originalUrl: link, + normalizedUrl: 'http://' + link, + }); + }); +}); + +describe('defaultLinkMatchRules regular https links with extact match', () => { + it('https://en.wikipedia.org/wiki/Compost', () => { + let link = 'https://en.wikipedia.org/wiki/Compost'; + runMatchTestWithValidLink(link, { + scheme: 'https', + originalUrl: link, + normalizedUrl: link, + }); + }); + + it('https://www.youtube.com/watch?v=e3Nl_TCQXuw', () => { + let link = 'https://www.youtube.com/watch?v=e3Nl_TCQXuw'; + runMatchTestWithValidLink(link, { + scheme: 'https', + originalUrl: link, + normalizedUrl: link, + }); + }); + + it('https://www.bing.com/news/search?q=MSFT&qpvt=msft&FORM=EWRE', () => { + let link = 'https://www.bing.com/news/search?q=MSFT&qpvt=msft&FORM=EWRE'; + runMatchTestWithValidLink(link, { + scheme: 'https', + originalUrl: link, + normalizedUrl: link, + }); + }); + + it('https://microsoft.sharepoint.com/teams/peopleposse/Shared%20Documents/Feedback%20Plan.pptx?web=1', () => { + let link = + 'https://microsoft.sharepoint.com/teams/peopleposse/Shared%20Documents/Feedback%20Plan.pptx?web=1'; + runMatchTestWithValidLink(link, { + scheme: 'https', + originalUrl: link, + normalizedUrl: link, + }); + }); +}); + +describe('defaultLinkMatchRules special http links that has % and @, but is valid', () => { + it('www.test.com/?test=test%00it', () => { + // URL: www.test.com/?test=test%00it %00 => %00 is invalid percent encoding but URL is valid since it is after ? + let link = 'www.test.com/?test=test%00it'; + runMatchTestWithValidLink(link, { + scheme: 'http', + originalUrl: link, + normalizedUrl: 'http://' + link, + }); + }); + + it('http://www.test.com/?test=test%hhit', () => { + // URL: http://www.test.com/?test=test%hhit => %h is invalid encoding, but URL is valid since it is after ? + let link = 'http://www.test.com/?test=test%hhit'; + runMatchTestWithValidLink(link, { scheme: 'http', originalUrl: link, normalizedUrl: link }); + }); + + it('www.test.com/kitty@supercute.com', () => { + // URL: www.test.com/kitty@supercute.com => @ is valid when it is after / + let link = 'www.test.com/kitty@supercute.com'; + runMatchTestWithValidLink(link, { + scheme: 'http', + originalUrl: link, + normalizedUrl: 'http://' + link, + }); + }); + + it('www.test.com?kitty@supercute.com', () => { + // URL: www.test.com?kitty@supercute.com => @ is valid when it is after ? + let link = 'www.test.com?kitty@supercute.com'; + runMatchTestWithValidLink(link, { + scheme: 'http', + originalUrl: link, + normalizedUrl: 'http://' + link, + }); + }); + + it('http://www.test.com/kitty@supercute.com', () => { + // URL: http://www.test.com/kitty@supercute.com @ is valid when it is after / for URL that has http:// prefix + let link = 'http://www.test.com/kitty@supercute.com'; + runMatchTestWithValidLink(link, { scheme: 'http', originalUrl: link, normalizedUrl: link }); + }); +}); + +describe('defaultLinkMatchRules invalid http links with % and @ in URL', () => { + it('http://www.test%00it.com/', () => { + // %00 is invalid percent encoding + runMatchTestWithBadLink('http://www.test%00it.com/'); + }); + + it('http://www.test%hhit.com/', () => { + // %h is invalid percent encoding + runMatchTestWithBadLink('http://www.test%hhit.com/'); + }); + + it('www.test%0hit.com/', () => { + // %0 is invalid percent encoding + runMatchTestWithBadLink('www.test%0hit.com/'); + }); + + it('www.kitty@supercute.com.com/test', () => { + // @ is invalid when it apperas before / + runMatchTestWithBadLink('www.kitty@supercute.com.com/test'); + }); + + it('www.kitty@supercute.com.com?test', () => { + // @ is invalid when it apperas before ? + runMatchTestWithBadLink('www.kitty@supercute.com.com?test'); + }); + + it('https' + '://www.kitty@supercute.com.com/test', () => { + // @ is invalid when it apperas before /. Note we're testing @ after http:// and before first / + runMatchTestWithBadLink('https' + '://www.kitty@supercute.com.com/test'); + }); +}); + +describe('defaultLinkMatchRules exact match with extra space and text', () => { + it('www.bing.com more', () => { + // exact match should not match since there is some space and extra text after the url + runMatchTestWithBadLink('www.bing.com more'); + }); +}); + +describe('defaultLinkmatchRules does not match invalid urls', () => { + it('www.bing,com', () => { + runMatchTestWithBadLink('www.bing,com'); + }); + + it('www.b,,au', () => { + runMatchTestWithBadLink('www.b,,au'); + }); +}); + +describe('defaultLinkMatchRules other protocols, mailto, notes, file etc.', () => { + it('mailto:someone@example.com', () => { + let link = 'mailto:someone@example.com'; + runMatchTestWithValidLink(link, { + scheme: 'mailto', + originalUrl: link, + normalizedUrl: link, + }); + }); + + it('notes://Garth/86256EDF005310E2/A4D87D90E1B19842852564FF006DED4E/', () => { + let link = 'notes://Garth/86256EDF005310E2/A4D87D90E1B19842852564FF006DED4E/'; + runMatchTestWithValidLink(link, { + scheme: 'notes', + originalUrl: link, + normalizedUrl: link, + }); + }); + + it('file://hostname/path/to/the%20file.txt', () => { + let link = 'file://hostname/path/to/the%20file.txt'; + runMatchTestWithValidLink(link, { scheme: 'file', originalUrl: link, normalizedUrl: link }); + }); + + it('\\\\fileserver\\SharedFolder\\Resource', () => { + let link = '\\\\fileserver\\SharedFolder\\Resource'; + runMatchTestWithValidLink(link, { scheme: 'unc', originalUrl: link, normalizedUrl: link }); + }); + + it('ftp://test.com/share', () => { + let link = 'ftp://test.com/share'; + runMatchTestWithValidLink(link, { scheme: 'ftp', originalUrl: link, normalizedUrl: link }); + }); + + it('ftp.test.com/share', () => { + let link = 'ftp.test.com/share'; + runMatchTestWithValidLink(link, { + scheme: 'ftp', + originalUrl: link, + normalizedUrl: 'ftp://' + link, + }); + }); + + it('news://news.server.example/example', () => { + let link = 'news://news.server.example/example'; + runMatchTestWithValidLink(link, { scheme: 'news', originalUrl: link, normalizedUrl: link }); + }); + + it('telnet://test.com:25', () => { + let link = 'telnet://test.com:25'; + runMatchTestWithValidLink(link, { + scheme: 'telnet', + originalUrl: link, + normalizedUrl: link, + }); + }); + + it('gopher://test.com/share', () => { + let link = 'gopher://test.com/share'; + runMatchTestWithValidLink(link, { + scheme: 'gopher', + originalUrl: link, + normalizedUrl: link, + }); + }); + + it('wais://testserver:2000/DB1', () => { + let link = 'wais://testserver:2000/DB1'; + runMatchTestWithValidLink(link, { scheme: 'wais', originalUrl: link, normalizedUrl: link }); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-api/test/publicApi/link/insertLinkTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/link/insertLinkTest.ts index 176da2eee1d..96e9ed0ac03 100644 --- a/packages-content-model/roosterjs-content-model-api/test/publicApi/link/insertLinkTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/link/insertLinkTest.ts @@ -403,4 +403,47 @@ describe('insertLink', () => { ], }); }); + + it('Invalid url', () => { + const doc = createContentModelDocument(); + addSegment(doc, createSelectionMarker()); + + const url = 'javasc\nript:onC\nlick()'; + let formatResult: boolean | undefined; + const formatContentModel = jasmine + .createSpy('formatContentModel') + .and.callFake( + (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { + formatResult = callback(doc, { + newEntities: [], + deletedEntities: [], + newImages: [], + }); + } + ); + + editor.formatContentModel = formatContentModel; + + insertLink(editor, url); + + expect(formatContentModel).toHaveBeenCalledTimes(0); + expect(formatResult).toBeFalsy(); + expect(doc).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + isImplicit: true, + segments: [ + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + ], + }, + ], + }); + }); }); diff --git a/packages-content-model/roosterjs-content-model-api/test/publicApi/segment/segmentTestCommon.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/segment/segmentTestCommon.ts index 4ed22a5ea0c..99205b8e38b 100644 --- a/packages-content-model/roosterjs-content-model-api/test/publicApi/segment/segmentTestCommon.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/segment/segmentTestCommon.ts @@ -1,5 +1,4 @@ import { IStandaloneEditor } from 'roosterjs-content-model-types'; -import { NodePosition } from 'roosterjs-editor-types'; import { ContentModelDocument, ContentModelFormatter, @@ -26,7 +25,6 @@ export function segmentTestCommon( }); const editor = ({ focus: jasmine.createSpy(), - getFocusedPosition: () => null as NodePosition, getPendingFormat: () => null as any, formatContentModel, } as any) as IStandaloneEditor; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/BridgePlugin.ts b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/BridgePlugin.ts index 9f1d76ae781..cefe2cbe012 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/BridgePlugin.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/BridgePlugin.ts @@ -1,5 +1,6 @@ import { createContextMenuPlugin } from './ContextMenuPlugin'; import { createEditPlugin } from './EditPlugin'; +import { createEntityDelimiterPlugin } from './EntityDelimiterPlugin'; import { createNormalizeTablePlugin } from './NormalizeTablePlugin'; import { newEventToOldEvent, oldEventToNewEvent } from '../editor/utils/eventConverter'; import { PluginEventType } from 'roosterjs-editor-types'; @@ -30,10 +31,12 @@ export class BridgePlugin implements EditorPlugin { const editPlugin = createEditPlugin(); const contextMenuPlugin = createContextMenuPlugin(options); const normalizeTablePlugin = createNormalizeTablePlugin(); + const entityDelimiterPlugin = createEntityDelimiterPlugin(); this.legacyPlugins = [ editPlugin, ...(options.legacyPlugins ?? []).filter(x => !!x), + entityDelimiterPlugin, contextMenuPlugin, normalizeTablePlugin, ]; diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/entityDelimiter/EntityDelimiterPlugin.ts b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/EntityDelimiterPlugin.ts similarity index 97% rename from packages-content-model/roosterjs-content-model-plugins/lib/entityDelimiter/EntityDelimiterPlugin.ts rename to packages-content-model/roosterjs-content-model-editor/lib/corePlugins/EntityDelimiterPlugin.ts index 6d0bb852b17..95e8b9c6ffe 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/entityDelimiter/EntityDelimiterPlugin.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/EntityDelimiterPlugin.ts @@ -1,4 +1,5 @@ import { isCharacterValue } from 'roosterjs-content-model-core'; +import type { IContentModelEditor } from '../publicTypes/IContentModelEditor'; import { addDelimiters, isBlockElement, @@ -28,7 +29,6 @@ import type { PluginEvent, PluginKeyDownEvent, } from 'roosterjs-editor-types'; -import type { IContentModelEditor } from 'roosterjs-content-model-editor'; const DELIMITER_SELECTOR = '.' + DelimiterClasses.DELIMITER_AFTER + ',.' + DelimiterClasses.DELIMITER_BEFORE; @@ -36,9 +36,10 @@ const ZERO_WIDTH_SPACE = '\u200B'; const INLINE_ENTITY_SELECTOR = 'span' + getEntitySelector(); /** + * @internal * Entity delimiter plugin helps maintain delimiter elements around an entity so that user can put focus before/after an entity */ -export class EntityDelimiterPlugin implements EditorPlugin { +class EntityDelimiterPlugin implements EditorPlugin { private editor: IContentModelEditor | null = null; /** @@ -314,3 +315,11 @@ function handleKeyDownEvent(editor: IEditor, event: PluginKeyDownEvent) { handleSelectionNotCollapsed(editor, currentRange, rawEvent); } } + +/** + * @internal + * Create a new instance of EntityDelimiterPlugin. + */ +export function createEntityDelimiterPlugin(): EditorPlugin { + return new EntityDelimiterPlugin(); +} diff --git a/packages-content-model/roosterjs-content-model-editor/test/corePlugins/BridgePluginTest.ts b/packages-content-model/roosterjs-content-model-editor/test/corePlugins/BridgePluginTest.ts index 6c42aeef97a..981c9afbde2 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/corePlugins/BridgePluginTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/corePlugins/BridgePluginTest.ts @@ -28,6 +28,7 @@ describe('BridgePlugin', () => { const onPluginEventSpy1 = jasmine.createSpy('onPluginEvent1'); const onPluginEventSpy2 = jasmine.createSpy('onPluginEvent2'); const disposeSpy = jasmine.createSpy('dispose'); + const queryElementsSpy = jasmine.createSpy('queryElement').and.returnValue([]); const mockedPlugin1 = { initialize: initializeSpy, @@ -39,7 +40,9 @@ describe('BridgePlugin', () => { onPluginEvent: onPluginEventSpy2, dispose: disposeSpy, } as any; - const mockedEditor = 'EDITOR' as any; + const mockedEditor = { + queryElements: queryElementsSpy, + } as any; const plugin = new BridgePlugin({ legacyPlugins: [mockedPlugin1, mockedPlugin2], diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/index.ts b/packages-content-model/roosterjs-content-model-plugins/lib/index.ts index d479dec6ddf..373fd4da9eb 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/index.ts @@ -1,3 +1,2 @@ export { ContentModelPastePlugin } from './paste/ContentModelPastePlugin'; export { ContentModelEditPlugin } from './edit/ContentModelEditPlugin'; -export { EntityDelimiterPlugin } from './entityDelimiter/EntityDelimiterPlugin'; diff --git a/packages-content-model/roosterjs-content-model-plugins/package.json b/packages-content-model/roosterjs-content-model-plugins/package.json index e33c18cf24d..405b4241a0f 100644 --- a/packages-content-model/roosterjs-content-model-plugins/package.json +++ b/packages-content-model/roosterjs-content-model-plugins/package.json @@ -3,8 +3,6 @@ "description": "Content Model for roosterjs (Under development)", "dependencies": { "tslib": "^2.3.1", - "roosterjs-editor-types": "", - "roosterjs-editor-dom": "", "roosterjs-content-model-core": "", "roosterjs-content-model-editor": "", "roosterjs-content-model-dom": "", diff --git a/packages-content-model/roosterjs-content-model-plugins/test/paste/processPastedContentFromWordDesktopTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/paste/processPastedContentFromWordDesktopTest.ts index 47fd3f62e40..e818e12b1ae 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/paste/processPastedContentFromWordDesktopTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/paste/processPastedContentFromWordDesktopTest.ts @@ -1,9 +1,8 @@ import * as getStyleMetadata from '../../lib/paste/WordDesktop/getStyleMetadata'; +import { BeforePasteEvent, ClipboardData } from 'roosterjs-content-model-types'; import { expectEqual } from './e2e/testUtils'; -import { PluginEventType } from 'roosterjs-editor-types'; import { processPastedContentFromWordDesktop } from '../../lib/paste/WordDesktop/processPastedContentFromWordDesktop'; import { WordMetadata } from '../../lib/paste/WordDesktop/WordMetadata'; -import { BeforePasteEvent, ClipboardData } from 'roosterjs-content-model-types'; import { createDomToModelContext, domToContentModel, @@ -5127,29 +5126,28 @@ describe('processPastedContentFromWordDesktopTest', () => { }); }); -export function createBeforePasteEventMock(fragment: DocumentFragment, htmlBefore: string = '') { - return ({ - eventType: PluginEventType.BeforePaste, +export function createBeforePasteEventMock( + fragment: DocumentFragment, + htmlBefore: string = '' +): BeforePasteEvent { + return { + eventType: 'beforePaste', clipboardData: {}, fragment: fragment, - sanitizingOption: { - elementCallbacks: {}, - attributeCallbacks: {}, - cssStyleCallbacks: {}, - additionalTagReplacements: {}, - additionalAllowedAttributes: [], - additionalAllowedCssClasses: [], - additionalDefaultStyleValues: {}, - additionalGlobalStyleNodes: [], - additionalPredefinedCssForElement: {}, - preserveHtmlComments: false, - unknownTagReplacement: null, - }, htmlBefore, htmlAfter: '', htmlAttributes: {}, - domToModelOption: { additionalAllowedTags: [], additionalDisallowedTags: [] }, - } as any) as BeforePasteEvent; + pasteType: 'normal', + domToModelOption: { + additionalAllowedTags: [], + additionalDisallowedTags: [], + additionalFormatParsers: {}, + attributeSanitizers: {}, + formatParserOverride: {}, + processorOverride: {}, + styleSanitizers: {}, + }, + }; } function createListElementFromWord( diff --git a/packages-content-model/roosterjs-content-model/lib/createContentModelEditor.ts b/packages-content-model/roosterjs-content-model/lib/createContentModelEditor.ts index ffad56e762c..089b79b1809 100644 --- a/packages-content-model/roosterjs-content-model/lib/createContentModelEditor.ts +++ b/packages-content-model/roosterjs-content-model/lib/createContentModelEditor.ts @@ -1,9 +1,5 @@ import { ContentModelEditor } from 'roosterjs-content-model-editor'; -import { - ContentModelEditPlugin, - ContentModelPastePlugin, - EntityDelimiterPlugin, -} from 'roosterjs-content-model-plugins'; +import { ContentModelEditPlugin, ContentModelPastePlugin } from 'roosterjs-content-model-plugins'; import type { ContentModelEditorOptions, IContentModelEditor, @@ -23,7 +19,6 @@ export function createContentModelEditor( additionalPlugins?: EditorPlugin[], initialContent?: string ): IContentModelEditor { - const legacyPlugins = [new EntityDelimiterPlugin()]; const plugins = [ new ContentModelPastePlugin(), new ContentModelEditPlugin(), @@ -31,7 +26,6 @@ export function createContentModelEditor( ]; const options: ContentModelEditorOptions = { - legacyPlugins: legacyPlugins, plugins: plugins, initialContent: initialContent, defaultSegmentFormat: {