From 6ab15e401fec30c367b521c228c2849ac6bf80e7 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Thu, 4 Apr 2024 15:49:00 -0700 Subject: [PATCH] Port Hyperlink plugin --- demo/scripts/controlsV2/mainPane/MainPane.tsx | 10 +- .../controlsV2/plugins/createLegacyPlugins.ts | 25 +- .../editorOptions/EditorOptionsPlugin.ts | 2 +- .../sidePane/editorOptions/OptionState.ts | 2 +- .../sidePane/editorOptions/Plugins.tsx | 28 +- .../editorOptions/codes/HyperLinkCode.ts | 2 +- .../editorOptions/codes/PluginsCode.ts | 2 +- .../lib/hyperlink/HyperlinkPlugin.ts | 161 +++++++ .../lib/hyperlink/HyperlinkToolTip.ts | 7 + .../lib/index.ts | 2 + .../test/hyperlink/HyperlinkPluginTest.ts | 442 ++++++++++++++++++ 11 files changed, 642 insertions(+), 41 deletions(-) create mode 100644 packages/roosterjs-content-model-plugins/lib/hyperlink/HyperlinkPlugin.ts create mode 100644 packages/roosterjs-content-model-plugins/lib/hyperlink/HyperlinkToolTip.ts create mode 100644 packages/roosterjs-content-model-plugins/test/hyperlink/HyperlinkPluginTest.ts diff --git a/demo/scripts/controlsV2/mainPane/MainPane.tsx b/demo/scripts/controlsV2/mainPane/MainPane.tsx index a4e8715b74e..0f70a391e7f 100644 --- a/demo/scripts/controlsV2/mainPane/MainPane.tsx +++ b/demo/scripts/controlsV2/mainPane/MainPane.tsx @@ -24,7 +24,7 @@ import { getDarkColor } from 'roosterjs-color-utils'; import { getPresetModelById } from '../sidePane/presets/allPresets/allPresets'; import { getTabs, tabNames } from '../tabs/getTabs'; import { getTheme } from '../theme/themes'; -import { OptionState } from '../sidePane/editorOptions/OptionState'; +import { OptionState, UrlPlaceholder } from '../sidePane/editorOptions/OptionState'; import { popoutButton } from '../demoButtons/popoutButton'; import { PresetPlugin } from '../sidePane/presets/PresetPlugin'; import { redoButton } from '../roosterjsReact/ribbon/buttons/redoButton'; @@ -47,6 +47,7 @@ import { import { AutoFormatPlugin, EditPlugin, + HyperlinkPlugin, MarkdownPlugin, PastePlugin, ShortcutPlugin, @@ -476,6 +477,7 @@ export class MainPane extends React.Component<{}, MainPaneState> { watermarkText, markdownOptions, autoFormatOptions, + linkTitle, } = this.state.initState; return [ pluginList.autoFormat && new AutoFormatPlugin(autoFormatOptions), @@ -492,6 +494,12 @@ export class MainPane extends React.Component<{}, MainPaneState> { pluginList.contextMenu && listMenu && createListEditMenuProvider(), pluginList.contextMenu && tableMenu && createTableEditMenuProvider(), pluginList.contextMenu && imageMenu && createImageEditMenuProvider(), + pluginList.hyperlink && + new HyperlinkPlugin( + linkTitle?.indexOf(UrlPlaceholder) >= 0 + ? url => linkTitle.replace(UrlPlaceholder, url) + : linkTitle + ), ].filter(x => !!x); } } diff --git a/demo/scripts/controlsV2/plugins/createLegacyPlugins.ts b/demo/scripts/controlsV2/plugins/createLegacyPlugins.ts index 0e6e1c53229..a49a3d43032 100644 --- a/demo/scripts/controlsV2/plugins/createLegacyPlugins.ts +++ b/demo/scripts/controlsV2/plugins/createLegacyPlugins.ts @@ -1,31 +1,12 @@ +import { Announce, ContentEdit, CustomReplace, ImageEdit } from 'roosterjs-editor-plugins'; import { EditorPlugin as LegacyEditorPlugin, KnownAnnounceStrings } from 'roosterjs-editor-types'; -import { - Announce, - ContentEdit, - CustomReplace, - HyperLink, - ImageEdit, -} from 'roosterjs-editor-plugins'; -import { - LegacyPluginList, - OptionState, - UrlPlaceholder, -} from '../sidePane/editorOptions/OptionState'; +import { LegacyPluginList, OptionState } from '../sidePane/editorOptions/OptionState'; export function createLegacyPlugins(initState: OptionState): LegacyEditorPlugin[] { - const { pluginList, linkTitle } = initState; + const { pluginList } = initState; const plugins: Record = { contentEdit: pluginList.contentEdit ? new ContentEdit(initState.contentEditFeatures) : null, - hyperlink: pluginList.hyperlink - ? new HyperLink( - linkTitle?.indexOf(UrlPlaceholder) >= 0 - ? url => linkTitle.replace(UrlPlaceholder, url) - : linkTitle - ? () => linkTitle - : null - ) - : null, imageEdit: pluginList.imageEdit ? new ImageEdit({ preserveRatio: initState.forcePreserveRatio, diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts b/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts index 45d5348c455..2c2116c6e96 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts +++ b/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts @@ -17,10 +17,10 @@ const initialState: OptionState = { pasteOption: true, sampleEntity: true, markdown: true, + hyperlink: true, // Legacy plugins contentEdit: false, - hyperlink: false, imageEdit: false, customReplace: false, announce: false, diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/OptionState.ts b/demo/scripts/controlsV2/sidePane/editorOptions/OptionState.ts index 679e17f0e99..f54b8833622 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/OptionState.ts +++ b/demo/scripts/controlsV2/sidePane/editorOptions/OptionState.ts @@ -5,7 +5,6 @@ import type { ContentModelSegmentFormat } from 'roosterjs-content-model-types'; export interface LegacyPluginList { contentEdit: boolean; - hyperlink: boolean; imageEdit: boolean; customReplace: boolean; announce: boolean; @@ -23,6 +22,7 @@ export interface NewPluginList { pasteOption: boolean; sampleEntity: boolean; markdown: boolean; + hyperlink: boolean; } export interface BuildInPluginList extends LegacyPluginList, NewPluginList {} diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/Plugins.tsx b/demo/scripts/controlsV2/sidePane/editorOptions/Plugins.tsx index edf89de3182..e1ed0e4712a 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/Plugins.tsx +++ b/demo/scripts/controlsV2/sidePane/editorOptions/Plugins.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import ContentEditFeatures from './ContentEditFeatures'; -import { UrlPlaceholder } from './OptionState'; -import type { +import { + UrlPlaceholder, BuildInPluginList, LegacyPluginList, NewPluginList, @@ -103,7 +103,6 @@ abstract class PluginsBase extends Re } export class LegacyPlugins extends PluginsBase { - private linkTitle = React.createRef(); private forcePreserveRatio = React.createRef(); render() { @@ -118,17 +117,6 @@ export class LegacyPlugins extends PluginsBase { resetState={this.props.resetState} /> )} - {this.renderPluginItem( - 'hyperlink', - 'Hyperlink Plugin', - this.renderInputBox( - 'Label title: ', - this.linkTitle, - this.props.state.linkTitle, - 'Use "' + UrlPlaceholder + '" for the url string', - (state, value) => (state.linkTitle = value) - ) - )} {this.renderPluginItem( 'imageEdit', 'Image Edit Plugin', @@ -153,6 +141,7 @@ export class Plugins extends PluginsBase { private tableMenu = React.createRef(); private imageMenu = React.createRef(); private watermarkText = React.createRef(); + private linkTitle = React.createRef(); render(): JSX.Element { return ( @@ -210,6 +199,17 @@ export class Plugins extends PluginsBase { {this.renderPluginItem('emoji', 'Emoji')} {this.renderPluginItem('pasteOption', 'PasteOptions')} {this.renderPluginItem('sampleEntity', 'SampleEntity')} + {this.renderPluginItem( + 'hyperlink', + 'Hyperlink Plugin', + this.renderInputBox( + 'Label title: ', + this.linkTitle, + this.props.state.linkTitle, + 'Use "' + UrlPlaceholder + '" for the url string', + (state, value) => (state.linkTitle = value) + ) + )} ); diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/codes/HyperLinkCode.ts b/demo/scripts/controlsV2/sidePane/editorOptions/codes/HyperLinkCode.ts index 8f36f804cc6..f4ad8991bd5 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/codes/HyperLinkCode.ts +++ b/demo/scripts/controlsV2/sidePane/editorOptions/codes/HyperLinkCode.ts @@ -7,7 +7,7 @@ export class HyperLinkCode extends CodeElement { } getCode() { - return 'new roosterjsLegacy.HyperLink(' + this.getLinkCallback() + ')'; + return 'new roosterjs.HyperlinkPlugin(' + this.getLinkCallback() + ')'; } private getLinkCallback() { diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/codes/PluginsCode.ts b/demo/scripts/controlsV2/sidePane/editorOptions/codes/PluginsCode.ts index d65018ea6f0..699418734ef 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/codes/PluginsCode.ts +++ b/demo/scripts/controlsV2/sidePane/editorOptions/codes/PluginsCode.ts @@ -46,6 +46,7 @@ export class PluginsCode extends PluginsCodeBase { pluginList.shortcut && new ShortcutPluginCode(), pluginList.watermark && new WatermarkCode(state.watermarkText), pluginList.markdown && new MarkdownCode(state.markdownOptions), + pluginList.hyperlink && new HyperLinkCode(state.linkTitle), ]); } } @@ -56,7 +57,6 @@ export class LegacyPluginCode extends PluginsCodeBase { const plugins: CodeElement[] = [ pluginList.contentEdit && new ContentEditCode(state.contentEditFeatures), - pluginList.hyperlink && new HyperLinkCode(state.linkTitle), pluginList.imageEdit && new ImageEditCode(), pluginList.customReplace && new CustomReplaceCode(), ]; diff --git a/packages/roosterjs-content-model-plugins/lib/hyperlink/HyperlinkPlugin.ts b/packages/roosterjs-content-model-plugins/lib/hyperlink/HyperlinkPlugin.ts new file mode 100644 index 00000000000..e68a2d8cad9 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/lib/hyperlink/HyperlinkPlugin.ts @@ -0,0 +1,161 @@ +import { matchLink } from 'roosterjs-content-model-api'; +import type { HyperlinkToolTip } from './HyperlinkToolTip'; +import type { + DOMHelper, + EditorPlugin, + IEditor, + PluginEvent, + LinkData, +} from 'roosterjs-content-model-types'; + +const defaultToolTipCallback: HyperlinkToolTip = (url: string) => url; + +/** + * Hyperlink plugin does the following jobs for a hyperlink in editor: + * 1. When hover on a link, show a tool tip + * 2. When Ctrl+Click on a link, open a new window with the link + * 3. When type directly on a link whose text matches its link url, update the link url with the link text + */ +export class HyperlinkPlugin implements EditorPlugin { + private editor: IEditor | null = null; + private domHelper: DOMHelper | null = null; + private isMac: boolean = false; + private disposer: (() => void) | null = null; + + private currentNode: Node | null = null; + private currentLink: HTMLAnchorElement | null = null; + + /** + * Create a new instance of HyperLink class + * @param tooltip Tooltip to show when mouse hover over a link + * Default value is to return the href itself. If null, there will be no tooltip text. + * @param target (Optional) Target window name for hyperlink. If null, will use "_blank" + * @param onLinkClick (Optional) Open link callback (return false to use default behavior) + */ + constructor( + private tooltip: HyperlinkToolTip = defaultToolTipCallback, + private target?: string, + private onLinkClick?: (anchor: HTMLAnchorElement, mouseEvent: MouseEvent) => boolean | void + ) {} + + /** + * Get a friendly name of this plugin + */ + getName() { + return 'Hyperlink'; + } + + /** + * Initialize this plugin + * @param editor The editor instance + */ + public initialize(editor: IEditor): void { + this.editor = editor; + this.domHelper = editor.getDOMHelper(); + this.isMac = !!editor.getEnvironment().isMac; + this.disposer = editor.attachDomEvent({ + mouseover: { beforeDispatch: this.onMouse }, + mouseout: { beforeDispatch: this.onMouse }, + }); + } + + /** + * Dispose this plugin + */ + public dispose(): void { + if (this.disposer) { + this.disposer(); + this.disposer = null; + } + this.editor = null; + } + + /** + * Handle events triggered from editor + * @param event PluginEvent object + */ + public onPluginEvent(event: PluginEvent): void { + let matchedLink: LinkData | null; + + if (event.eventType == 'keyDown') { + const selection = this.editor?.getDOMSelection(); + const node = + selection?.type == 'range' ? selection.range.commonAncestorContainer : null; + + if (node && node != this.currentNode) { + this.currentNode = node; + this.currentLink = null; + + this.runWithHyperlink(node, (href, a) => { + if ( + node.textContent && + (matchedLink = matchLink(node.textContent)) && + matchedLink.normalizedUrl == href + ) { + this.currentLink = a; + } + }); + } + } else if (event.eventType == 'keyUp') { + const selection = this.editor?.getDOMSelection(); + const node = + selection?.type == 'range' ? selection.range.commonAncestorContainer : null; + + if ( + node && + node == this.currentNode && + this.currentLink && + this.currentLink.contains(node) && + node.textContent && + (matchedLink = matchLink(node.textContent)) + ) { + this.currentLink.setAttribute('href', matchedLink.normalizedUrl); + } + } else if (event.eventType == 'mouseUp' && event.isClicking) { + this.runWithHyperlink(event.rawEvent.target as Node, (href, anchor) => { + if ( + !this.onLinkClick?.(anchor, event.rawEvent) && + this.isCtrlOrMetaPressed(event.rawEvent) && + event.rawEvent.button === 0 + ) { + event.rawEvent.preventDefault(); + + const target = this.target || '_blank'; + const window = this.editor?.getDocument().defaultView; + + try { + window?.open(href, target); + } catch {} + } + }); + } + } + + protected onMouse = (e: Event) => { + this.runWithHyperlink(e.target as Node, (href, a) => { + const tooltip = + e.type == 'mouseover' + ? typeof this.tooltip == 'function' + ? this.tooltip(href, a) + : this.tooltip + : null; + this.domHelper?.setDomAttribute('title', tooltip); + }); + }; + + private runWithHyperlink(node: Node, callback: (href: string, a: HTMLAnchorElement) => void) { + const a = this.domHelper?.findClosestElementAncestor( + node, + 'a[href]' + ) as HTMLAnchorElement | null; + const href = a?.getAttribute('href'); + + if (href && a) { + callback(href, a); + } + } + + private isCtrlOrMetaPressed(event: KeyboardEvent | MouseEvent): boolean { + return this.isMac ? event.metaKey : event.ctrlKey; + } +} diff --git a/packages/roosterjs-content-model-plugins/lib/hyperlink/HyperlinkToolTip.ts b/packages/roosterjs-content-model-plugins/lib/hyperlink/HyperlinkToolTip.ts new file mode 100644 index 00000000000..2d1b0055a60 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/lib/hyperlink/HyperlinkToolTip.ts @@ -0,0 +1,7 @@ +/** + * A type to specify how to get a tool tip of hyperlink in editor + * string: Use this string as tooltip + * null: No tooltip + * function: Call this function to get a tooltip + */ +export type HyperlinkToolTip = string | null | ((url: string, anchor: HTMLAnchorElement) => string); diff --git a/packages/roosterjs-content-model-plugins/lib/index.ts b/packages/roosterjs-content-model-plugins/lib/index.ts index e691f97a3e0..b0de73404cc 100644 --- a/packages/roosterjs-content-model-plugins/lib/index.ts +++ b/packages/roosterjs-content-model-plugins/lib/index.ts @@ -25,3 +25,5 @@ export { ContextMenuPluginBase, ContextMenuOptions } from './contextMenuBase/Con export { WatermarkPlugin } from './watermark/WatermarkPlugin'; export { WatermarkFormat } from './watermark/WatermarkFormat'; export { MarkdownPlugin, MarkdownOptions } from './markdown/MarkdownPlugin'; +export { HyperlinkPlugin } from './hyperlink/HyperlinkPlugin'; +export { HyperlinkToolTip } from './hyperlink/HyperlinkToolTip'; diff --git a/packages/roosterjs-content-model-plugins/test/hyperlink/HyperlinkPluginTest.ts b/packages/roosterjs-content-model-plugins/test/hyperlink/HyperlinkPluginTest.ts new file mode 100644 index 00000000000..3167493376f --- /dev/null +++ b/packages/roosterjs-content-model-plugins/test/hyperlink/HyperlinkPluginTest.ts @@ -0,0 +1,442 @@ +import * as matchLink from 'roosterjs-content-model-api/lib/modelApi/link/matchLink'; +import { HyperlinkPlugin } from '../../lib/hyperlink/HyperlinkPlugin'; +import { + DOMEventHandlerFunction, + DOMEventRecord, + DOMHelper, + EditorEnvironment, + IEditor, +} from 'roosterjs-content-model-types'; + +describe('HyperlinkPlugin', () => { + const MockedTooltip = 'Tooltip'; + + let editor: IEditor; + let mockedDomHelper: DOMHelper; + let mockedEnvironment: EditorEnvironment; + let mockedWindow: Window; + + let attachDomEventSpy: jasmine.Spy; + let findClosestElementAncestorSpy: jasmine.Spy; + let setDomAttributeSpy: jasmine.Spy; + let openSpy: jasmine.Spy; + let getDOMSelectionSpy: jasmine.Spy; + let matchLinkSpy: jasmine.Spy; + + beforeEach(() => { + findClosestElementAncestorSpy = jasmine.createSpy('findClosestElementAncestor'); + attachDomEventSpy = jasmine.createSpy('attachDomEvent'); + setDomAttributeSpy = jasmine.createSpy('setDomAttribute'); + openSpy = jasmine.createSpy('open'); + getDOMSelectionSpy = jasmine.createSpy('getDOMSelection'); + matchLinkSpy = spyOn(matchLink, 'matchLink'); + + mockedDomHelper = { + findClosestElementAncestor: findClosestElementAncestorSpy, + setDomAttribute: setDomAttributeSpy, + } as any; + mockedEnvironment = {} as any; + mockedWindow = { + open: openSpy, + } as any; + + editor = { + getDOMHelper: () => mockedDomHelper, + getEnvironment: () => mockedEnvironment, + attachDomEvent: attachDomEventSpy, + getDocument: () => ({ + defaultView: mockedWindow, + }), + getDOMSelection: getDOMSelectionSpy, + } as any; + }); + + it('MouseOver', () => { + const tooltipSpy = jasmine.createSpy('tooltip').and.returnValue(MockedTooltip); + const plugin = new HyperlinkPlugin(tooltipSpy); + const mockedNode = 'NODE' as any; + const mockedNode2 = 'NODE2' as any; + + let mouseOver: DOMEventHandlerFunction | undefined; + + attachDomEventSpy.and.callFake((eventMap: Record) => { + mouseOver = eventMap.mouseover.beforeDispatch!; + }); + + plugin.initialize(editor); + + expect(mouseOver).toBeDefined(); + + const mockedUrl = 'Url'; + const getAttributeSpy = jasmine.createSpy('getAttribute').and.returnValue(mockedUrl); + const mockedLink = { + getAttribute: getAttributeSpy, + } as any; + + findClosestElementAncestorSpy.and.callFake((node: Node) => { + return node == mockedNode ? mockedLink : null; + }); + + mouseOver!({ + type: 'mouseover', + target: mockedNode2, + } as any); + + expect(findClosestElementAncestorSpy).toHaveBeenCalledWith(mockedNode2, 'a[href]'); + expect(getAttributeSpy).not.toHaveBeenCalled(); + expect(tooltipSpy).not.toHaveBeenCalled(); + expect(setDomAttributeSpy).not.toHaveBeenCalled(); + + mouseOver!({ + type: 'mouseover', + target: mockedNode, + } as any); + + expect(findClosestElementAncestorSpy).toHaveBeenCalledWith(mockedNode, 'a[href]'); + expect(getAttributeSpy).toHaveBeenCalledWith('href'); + expect(tooltipSpy).toHaveBeenCalledWith(mockedUrl, mockedLink); + expect(setDomAttributeSpy).toHaveBeenCalledWith('title', MockedTooltip); + }); + + it('MouseOut', () => { + const tooltipSpy = jasmine.createSpy('tooltip').and.returnValue(MockedTooltip); + const plugin = new HyperlinkPlugin(tooltipSpy); + const mockedNode = 'NODE' as any; + + let mouseOut: DOMEventHandlerFunction | undefined; + + attachDomEventSpy.and.callFake((eventMap: Record) => { + mouseOut = eventMap.mouseout.beforeDispatch!; + }); + + plugin.initialize(editor); + + expect(mouseOut).toBeDefined(); + + const mockedUrl = 'Url'; + const getAttributeSpy = jasmine.createSpy('getAttribute').and.returnValue(mockedUrl); + const mockedLink = { + getAttribute: getAttributeSpy, + } as any; + + findClosestElementAncestorSpy.and.callFake((node: Node) => { + return node == mockedNode ? mockedLink : null; + }); + + mouseOut!({ + type: 'mouseout', + target: mockedNode, + } as any); + + expect(findClosestElementAncestorSpy).toHaveBeenCalledWith(mockedNode, 'a[href]'); + expect(getAttributeSpy).toHaveBeenCalledWith('href'); + expect(tooltipSpy).not.toHaveBeenCalled(); + expect(setDomAttributeSpy).toHaveBeenCalledWith('title', null); + }); + + it('mouseUp', () => { + const plugin = new HyperlinkPlugin(); + const mockedUrl = 'Url'; + const getAttributeSpy = jasmine.createSpy('getAttribute').and.returnValue(mockedUrl); + const mockedNode = 'NODE' as any; + const mockedLink = { + getAttribute: getAttributeSpy, + } as any; + const preventDefaultSpy = jasmine.createSpy('preventDefault'); + + findClosestElementAncestorSpy.and.returnValue(mockedLink); + + plugin.initialize(editor); + + plugin.onPluginEvent({ + eventType: 'mouseUp', + isClicking: true, + rawEvent: { + target: mockedNode, + ctrlKey: false, + button: 0, + preventDefault: preventDefaultSpy, + }, + } as any); + plugin.onPluginEvent({ + eventType: 'mouseUp', + isClicking: true, + rawEvent: { + target: mockedNode, + ctrlKey: true, + button: 1, + preventDefault: preventDefaultSpy, + }, + } as any); + plugin.onPluginEvent({ + eventType: 'mouseUp', + isClicking: false, + rawEvent: { + target: mockedNode, + ctrlKey: true, + button: 0, + preventDefault: preventDefaultSpy, + }, + } as any); + + expect(preventDefaultSpy).not.toHaveBeenCalled(); + expect(openSpy).not.toHaveBeenCalled(); + + plugin.onPluginEvent({ + eventType: 'mouseUp', + isClicking: true, + rawEvent: { + target: mockedNode, + ctrlKey: true, + button: 0, + preventDefault: preventDefaultSpy, + }, + } as any); + + expect(preventDefaultSpy).toHaveBeenCalled(); + expect(openSpy).toHaveBeenCalledWith(mockedUrl, '_blank'); + }); + + it('mouseUp with target', () => { + const mockedTarget = 'target'; + const plugin = new HyperlinkPlugin(undefined, mockedTarget); + const mockedUrl = 'Url'; + const getAttributeSpy = jasmine.createSpy('getAttribute').and.returnValue(mockedUrl); + const mockedNode = 'NODE' as any; + const mockedLink = { + getAttribute: getAttributeSpy, + } as any; + const preventDefaultSpy = jasmine.createSpy('preventDefault'); + + findClosestElementAncestorSpy.and.returnValue(mockedLink); + + plugin.initialize(editor); + plugin.onPluginEvent({ + eventType: 'mouseUp', + isClicking: true, + rawEvent: { + target: mockedNode, + ctrlKey: true, + button: 0, + preventDefault: preventDefaultSpy, + }, + } as any); + + expect(preventDefaultSpy).toHaveBeenCalled(); + expect(openSpy).toHaveBeenCalledWith(mockedUrl, mockedTarget); + }); + + it('mouseUp with onLinkClick parameter', () => { + const onLinkClickSpy = jasmine.createSpy('onLinkClick'); + const plugin = new HyperlinkPlugin(undefined, undefined, onLinkClickSpy); + const mockedUrl = 'Url'; + const getAttributeSpy = jasmine.createSpy('getAttribute').and.returnValue(mockedUrl); + const mockedNode = 'NODE' as any; + const mockedLink = { + getAttribute: getAttributeSpy, + } as any; + const preventDefaultSpy = jasmine.createSpy('preventDefault'); + + findClosestElementAncestorSpy.and.returnValue(mockedLink); + + plugin.initialize(editor); + + onLinkClickSpy.and.returnValue(true); + + plugin.onPluginEvent({ + eventType: 'mouseUp', + isClicking: true, + rawEvent: { + target: mockedNode, + ctrlKey: true, + button: 0, + preventDefault: preventDefaultSpy, + }, + } as any); + + expect(findClosestElementAncestorSpy).toHaveBeenCalledWith(mockedNode, 'a[href]'); + expect(preventDefaultSpy).not.toHaveBeenCalled(); + expect(openSpy).not.toHaveBeenCalled(); + + onLinkClickSpy.and.returnValue(undefined); + + plugin.onPluginEvent({ + eventType: 'mouseUp', + isClicking: true, + rawEvent: { + target: mockedNode, + ctrlKey: true, + button: 0, + preventDefault: preventDefaultSpy, + }, + } as any); + + expect(onLinkClickSpy).toHaveBeenCalledWith(mockedLink, { + target: mockedNode, + ctrlKey: true, + button: 0, + preventDefault: preventDefaultSpy, + }); + expect(preventDefaultSpy).toHaveBeenCalled(); + expect(openSpy).toHaveBeenCalledWith(mockedUrl, '_blank'); + }); + + it('keyDown and keyUp', () => { + const plugin = new HyperlinkPlugin(); + const mockedUrl = 'Url'; + const mockedUrl2 = 'Url2'; + const mockedTextContent = 'textContent'; + const mockedTextContent2 = 'textContent2'; + const mockedNode = { + textContent: mockedTextContent, + } as any; + const getAttributeSpy = jasmine.createSpy('getAttribute').and.returnValue(mockedUrl); + const setAttributeSpy = jasmine.createSpy('setAttribute'); + const containsSpy = jasmine.createSpy('contains').and.returnValue(true); + const mockedLink = { + getAttribute: getAttributeSpy, + setAttribute: setAttributeSpy, + contains: containsSpy, + } as any; + + findClosestElementAncestorSpy.and.returnValue(mockedLink); + + plugin.initialize(editor); + + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + commonAncestorContainer: mockedNode, + }, + }); + + plugin.onPluginEvent({ + eventType: 'keyUp', + } as any); + + expect(setAttributeSpy).not.toHaveBeenCalled(); + + matchLinkSpy.and.returnValue({ + normalizedUrl: mockedUrl, + }); + + plugin.onPluginEvent({ + eventType: 'keyDown', + } as any); + + expect(matchLinkSpy).toHaveBeenCalledWith(mockedTextContent); + + mockedNode.textContent = mockedTextContent2; + matchLinkSpy.and.returnValue({ + normalizedUrl: mockedUrl2, + }); + + plugin.onPluginEvent({ + eventType: 'keyUp', + } as any); + + expect(containsSpy).toHaveBeenCalledWith(mockedNode); + expect(setAttributeSpy).toHaveBeenCalledWith('href', mockedUrl2); + }); + + it('keyDown and keyUp, not contain', () => { + const plugin = new HyperlinkPlugin(); + const mockedUrl = 'Url'; + const mockedUrl2 = 'Url2'; + const mockedTextContent = 'textContent'; + const mockedTextContent2 = 'textContent2'; + const mockedNode = { + textContent: mockedTextContent, + } as any; + const getAttributeSpy = jasmine.createSpy('getAttribute').and.returnValue(mockedUrl); + const setAttributeSpy = jasmine.createSpy('setAttribute'); + const containsSpy = jasmine.createSpy('contains').and.returnValue(false); + const mockedLink = { + getAttribute: getAttributeSpy, + setAttribute: setAttributeSpy, + contains: containsSpy, + } as any; + + findClosestElementAncestorSpy.and.returnValue(mockedLink); + + plugin.initialize(editor); + + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + commonAncestorContainer: mockedNode, + }, + }); + + matchLinkSpy.and.returnValue({ + normalizedUrl: mockedUrl, + }); + + plugin.onPluginEvent({ + eventType: 'keyDown', + } as any); + + expect(matchLinkSpy).toHaveBeenCalledWith(mockedTextContent); + + mockedNode.textContent = mockedTextContent2; + matchLinkSpy.and.returnValue({ + normalizedUrl: mockedUrl2, + }); + + plugin.onPluginEvent({ + eventType: 'keyUp', + } as any); + + expect(containsSpy).toHaveBeenCalledWith(mockedNode); + expect(setAttributeSpy).not.toHaveBeenCalled(); + }); + + it('keyDown and keyUp, url not match', () => { + const plugin = new HyperlinkPlugin(); + const mockedUrl = 'Url'; + const mockedUrl2 = 'Url2'; + const mockedTextContent = 'textContent'; + const mockedTextContent2 = 'textContent2'; + const mockedNode = { + textContent: mockedTextContent, + } as any; + const getAttributeSpy = jasmine.createSpy('getAttribute').and.returnValue(mockedUrl); + const setAttributeSpy = jasmine.createSpy('setAttribute'); + const containsSpy = jasmine.createSpy('contains').and.returnValue(true); + const mockedLink = { + getAttribute: getAttributeSpy, + setAttribute: setAttributeSpy, + contains: containsSpy, + } as any; + + findClosestElementAncestorSpy.and.returnValue(mockedLink); + + plugin.initialize(editor); + + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + commonAncestorContainer: mockedNode, + }, + }); + + matchLinkSpy.and.returnValue({ + normalizedUrl: mockedUrl2, + }); + + plugin.onPluginEvent({ + eventType: 'keyDown', + } as any); + + expect(matchLinkSpy).toHaveBeenCalledWith(mockedTextContent); + + mockedNode.textContent = mockedTextContent2; + + plugin.onPluginEvent({ + eventType: 'keyUp', + } as any); + + expect(containsSpy).not.toHaveBeenCalled(); + expect(setAttributeSpy).not.toHaveBeenCalled(); + }); +});