diff --git a/packages/ckeditor5-bookmark/src/bookmark.ts b/packages/ckeditor5-bookmark/src/bookmark.ts
index 046f1b02309..f0de3ead3cd 100644
--- a/packages/ckeditor5-bookmark/src/bookmark.ts
+++ b/packages/ckeditor5-bookmark/src/bookmark.ts
@@ -31,4 +31,11 @@ export default class Bookmark extends Plugin {
public static get requires() {
return [ BookmarkEditing, BookmarkUI, Widget ] as const;
}
+
+ /**
+ * @inheritDoc
+ */
+ public static override get isOfficialPlugin(): true {
+ return true;
+ }
}
diff --git a/packages/ckeditor5-bookmark/src/bookmarkediting.ts b/packages/ckeditor5-bookmark/src/bookmarkediting.ts
index 109cff6e5eb..024131e9546 100644
--- a/packages/ckeditor5-bookmark/src/bookmarkediting.ts
+++ b/packages/ckeditor5-bookmark/src/bookmarkediting.ts
@@ -46,6 +46,13 @@ export default class BookmarkEditing extends Plugin {
return 'BookmarkEditing' as const;
}
+ /**
+ * @inheritDoc
+ */
+ public static override get isOfficialPlugin(): true {
+ return true;
+ }
+
/**
* @inheritDoc
*/
diff --git a/packages/ckeditor5-bookmark/src/bookmarkui.ts b/packages/ckeditor5-bookmark/src/bookmarkui.ts
index a787b3bf984..3e853054b46 100644
--- a/packages/ckeditor5-bookmark/src/bookmarkui.ts
+++ b/packages/ckeditor5-bookmark/src/bookmarkui.ts
@@ -74,6 +74,13 @@ export default class BookmarkUI extends Plugin {
return 'BookmarkUI' as const;
}
+ /**
+ * @inheritDoc
+ */
+ public static override get isOfficialPlugin(): true {
+ return true;
+ }
+
/**
* @inheritDoc
*/
diff --git a/packages/ckeditor5-bookmark/tests/bookmark.js b/packages/ckeditor5-bookmark/tests/bookmark.js
index db16af563c0..cff78b24698 100644
--- a/packages/ckeditor5-bookmark/tests/bookmark.js
+++ b/packages/ckeditor5-bookmark/tests/bookmark.js
@@ -21,4 +21,12 @@ describe( 'Bookmark', () => {
Widget
] );
} );
+
+ it( 'should have `isOfficialPlugin` static flag set to `true`', () => {
+ expect( Bookmark.isOfficialPlugin ).to.be.true;
+ } );
+
+ it( 'should have `isPremiumPlugin` static flag set to `false`', () => {
+ expect( Bookmark.isPremiumPlugin ).to.be.false;
+ } );
} );
diff --git a/packages/ckeditor5-bookmark/tests/bookmarkediting.js b/packages/ckeditor5-bookmark/tests/bookmarkediting.js
index 6d57d291e6c..df2f89e9539 100644
--- a/packages/ckeditor5-bookmark/tests/bookmarkediting.js
+++ b/packages/ckeditor5-bookmark/tests/bookmarkediting.js
@@ -65,6 +65,14 @@ describe( 'BookmarkEditing', () => {
expect( BookmarkEditing.pluginName ).to.equal( 'BookmarkEditing' );
} );
+ it( 'should have `isOfficialPlugin` static flag set to `true`', () => {
+ expect( BookmarkEditing.isOfficialPlugin ).to.be.true;
+ } );
+
+ it( 'should have `isPremiumPlugin` static flag set to `false`', () => {
+ expect( BookmarkEditing.isPremiumPlugin ).to.be.false;
+ } );
+
describe( 'init', () => {
it( 'adds an "insertBookmark" command', () => {
expect( editor.commands.get( 'insertBookmark' ) ).to.be.instanceOf( InsertBookmarkCommand );
diff --git a/packages/ckeditor5-bookmark/tests/bookmarkui.js b/packages/ckeditor5-bookmark/tests/bookmarkui.js
index 9e247bf7d07..7a42fca14f7 100644
--- a/packages/ckeditor5-bookmark/tests/bookmarkui.js
+++ b/packages/ckeditor5-bookmark/tests/bookmarkui.js
@@ -59,6 +59,14 @@ describe( 'BookmarkUI', () => {
expect( BookmarkUI.pluginName ).to.equal( 'BookmarkUI' );
} );
+ it( 'should have `isOfficialPlugin` static flag set to `true`', () => {
+ expect( BookmarkUI.isOfficialPlugin ).to.be.true;
+ } );
+
+ it( 'should have `isPremiumPlugin` static flag set to `false`', () => {
+ expect( BookmarkUI.isPremiumPlugin ).to.be.false;
+ } );
+
it( 'should load ContextualBalloon', () => {
expect( editor.plugins.get( ContextualBalloon ) ).to.be.instanceOf( ContextualBalloon );
} );
diff --git a/packages/ckeditor5-bookmark/tests/manual/bookmark-with-output.html b/packages/ckeditor5-bookmark/tests/manual/bookmark-with-output.html
index 3f13a4c44c4..d52b25bd125 100644
--- a/packages/ckeditor5-bookmark/tests/manual/bookmark-with-output.html
+++ b/packages/ckeditor5-bookmark/tests/manual/bookmark-with-output.html
@@ -21,6 +21,10 @@
Link to image bookmark.
+
+ External link.
+
+
Example amount of large text
Lorem ipsum dolor, sit amet consectetur adipisicing elit. Voluptas mollitia laudantium laboriosam, vitae molestiae velit voluptate aliquid autem nisi minima, quis maiores at iste accusamus ipsam odio facilis iusto? Explicabo!
Lorem ipsum dolor, sit amet consectetur adipisicing elit. Voluptas mollitia laudantium laboriosam, vitae molestiae velit voluptate aliquid autem nisi minima, quis maiores at iste accusamus ipsam odio facilis iusto? Explicabo!
diff --git a/packages/ckeditor5-link/lang/contexts.json b/packages/ckeditor5-link/lang/contexts.json
index 4610f4dc7ac..989e5c22248 100644
--- a/packages/ckeditor5-link/lang/contexts.json
+++ b/packages/ckeditor5-link/lang/contexts.json
@@ -8,6 +8,7 @@
"Open link in new tab": "Button opening the link in new browser tab.",
"This link has no URL": "Label explaining that a link has no URL set (the URL is empty).",
"Open in a new tab": "The label of the switch button that controls whether the edited link will open in a new tab.",
+ "Scroll to target": "Button scrolling to the link target.",
"Downloadable": "The label of the switch button that controls whether the edited link refers to downloadable resource.",
"Create link": "Keystroke description for assistive technologies: keystroke for creating a link.",
"Move out of a link": "Keystroke description for assistive technologies: keystroke for moving out of a link."
diff --git a/packages/ckeditor5-link/package.json b/packages/ckeditor5-link/package.json
index 6a619ae4c61..961b37e6518 100644
--- a/packages/ckeditor5-link/package.json
+++ b/packages/ckeditor5-link/package.json
@@ -26,6 +26,7 @@
"devDependencies": {
"@ckeditor/ckeditor5-basic-styles": "43.2.0",
"@ckeditor/ckeditor5-block-quote": "43.2.0",
+ "@ckeditor/ckeditor5-bookmark": "0.0.1",
"@ckeditor/ckeditor5-cloud-services": "43.2.0",
"@ckeditor/ckeditor5-code-block": "43.2.0",
"@ckeditor/ckeditor5-dev-utils": "^44.0.0",
diff --git a/packages/ckeditor5-link/src/linkediting.ts b/packages/ckeditor5-link/src/linkediting.ts
index e038dc10de7..e7c5dcbf403 100644
--- a/packages/ckeditor5-link/src/linkediting.ts
+++ b/packages/ckeditor5-link/src/linkediting.ts
@@ -38,8 +38,9 @@ import {
ensureSafeUrl,
getLocalizedDecorators,
normalizeDecorators,
- openLink,
addLinkProtocolIfApplicable,
+ createBookmarkCallbacks,
+ openLink,
type NormalizedLinkDecoratorAutomaticDefinition,
type NormalizedLinkDecoratorManualDefinition
} from './utils.js';
@@ -260,6 +261,15 @@ export default class LinkEditing extends Plugin {
const editor = this.editor;
const view = editor.editing.view;
const viewDocument = view.document;
+ const bookmarkCallbacks = createBookmarkCallbacks( editor );
+
+ function handleLinkOpening( url: string ): void {
+ if ( bookmarkCallbacks.isScrollableToTarget( url ) ) {
+ bookmarkCallbacks.scrollToTarget( url );
+ } else {
+ openLink( url );
+ }
+ }
this.listenTo( viewDocument, 'click', ( evt, data ) => {
const shouldOpen = env.isMac ? data.domEvent.metaKey : data.domEvent.ctrlKey;
@@ -287,7 +297,7 @@ export default class LinkEditing extends Plugin {
evt.stop();
data.preventDefault();
- openLink( url );
+ handleLinkOpening( url );
}, { context: '$capture' } );
// Open link on Alt+Enter.
@@ -302,7 +312,7 @@ export default class LinkEditing extends Plugin {
evt.stop();
- openLink( url );
+ handleLinkOpening( url );
} );
}
diff --git a/packages/ckeditor5-link/src/linkui.ts b/packages/ckeditor5-link/src/linkui.ts
index 4e7c03fb8cd..8ef2c45f7ba 100644
--- a/packages/ckeditor5-link/src/linkui.ts
+++ b/packages/ckeditor5-link/src/linkui.ts
@@ -30,7 +30,12 @@ import LinkFormView, { type LinkFormValidatorCallback } from './ui/linkformview.
import LinkActionsView from './ui/linkactionsview.js';
import type LinkCommand from './linkcommand.js';
import type UnlinkCommand from './unlinkcommand.js';
-import { addLinkProtocolIfApplicable, isLinkElement, LINK_KEYSTROKE } from './utils.js';
+import {
+ addLinkProtocolIfApplicable,
+ isLinkElement,
+ createBookmarkCallbacks,
+ LINK_KEYSTROKE
+} from './utils.js';
import linkIcon from '../theme/icons/link.svg';
@@ -171,7 +176,11 @@ export default class LinkUI extends Plugin {
*/
private _createActionsView(): LinkActionsView {
const editor = this.editor;
- const actionsView = new LinkActionsView( editor.locale, editor.config.get( 'link' ) );
+ const actionsView = new LinkActionsView(
+ editor.locale,
+ editor.config.get( 'link' ),
+ createBookmarkCallbacks( editor )
+ );
const linkCommand: LinkCommand = editor.commands.get( 'link' )!;
const unlinkCommand: UnlinkCommand = editor.commands.get( 'unlink' )!;
diff --git a/packages/ckeditor5-link/src/ui/linkactionsview.ts b/packages/ckeditor5-link/src/ui/linkactionsview.ts
index 51006fc5b86..6420dc5c5c8 100644
--- a/packages/ckeditor5-link/src/ui/linkactionsview.ts
+++ b/packages/ckeditor5-link/src/ui/linkactionsview.ts
@@ -11,7 +11,7 @@ import { ButtonView, View, ViewCollection, FocusCycler, type FocusableView } fro
import { FocusTracker, KeystrokeHandler, type LocaleTranslate, type Locale } from 'ckeditor5/src/utils.js';
import { icons } from 'ckeditor5/src/core.js';
-import { ensureSafeUrl } from '../utils.js';
+import { ensureSafeUrl, openLink } from '../utils.js';
// See: #8833.
// eslint-disable-next-line ckeditor5-rules/ckeditor-imports
@@ -70,16 +70,19 @@ export default class LinkActionsView extends View {
private readonly _linkConfig: LinkConfig;
+ private readonly _options?: LinkActionsViewOptions;
+
declare public t: LocaleTranslate;
/**
* @inheritDoc
*/
- constructor( locale: Locale, linkConfig: LinkConfig = {} ) {
+ constructor( locale: Locale, linkConfig: LinkConfig = {}, options?: LinkActionsViewOptions ) {
super( locale );
const t = locale.t;
+ this._options = options;
this.previewButtonView = this._createPreviewButton();
this.unlinkButtonView = this._createButton( t( 'Unlink' ), unlinkIcon, 'unlink' );
this.editButtonView = this._createButton( t( 'Edit link' ), icons.pencil, 'edit' );
@@ -197,8 +200,7 @@ export default class LinkActionsView extends View {
const t = this.t;
button.set( {
- withText: true,
- tooltip: t( 'Open link in new tab' )
+ withText: true
} );
button.extendTemplate( {
@@ -210,7 +212,25 @@ export default class LinkActionsView extends View {
href: bind.to( 'href', href => href && ensureSafeUrl( href, this._linkConfig.allowedProtocols ) ),
target: '_blank',
rel: 'noopener noreferrer'
+ },
+ on: {
+ click: bind.to( evt => {
+ if ( this._options && this._options.isScrollableToTarget( this.href ) ) {
+ evt.preventDefault();
+ this._options.scrollToTarget( this.href! );
+ } else {
+ openLink( this.href! );
+ }
+ } )
+ }
+ } );
+
+ button.bind( 'tooltip' ).to( this, 'href', href => {
+ if ( this._options && this._options.isScrollableToTarget( href ) ) {
+ return t( 'Scroll to target' );
}
+
+ return t( 'Open link in new tab' );
} );
button.bind( 'label' ).to( this, 'href', href => {
@@ -220,7 +240,6 @@ export default class LinkActionsView extends View {
button.bind( 'isEnabled' ).to( this, 'href', href => !!href );
button.template!.tag = 'a';
- button.template!.eventListeners = {};
return button;
}
@@ -245,3 +264,19 @@ export type UnlinkEvent = {
name: 'unlink';
args: [];
};
+
+/**
+ * The options that are passed to the {@link LinkActionsView} constructor.
+ */
+export type LinkActionsViewOptions = {
+
+ /**
+ * Returns `true` when bookmark `id` matches the hash from `link`.
+ */
+ isScrollableToTarget: ( href: string | undefined ) => boolean;
+
+ /**
+ * Scrolls the view to the desired bookmark or open a link in new window.
+ */
+ scrollToTarget: ( href: string ) => void;
+};
diff --git a/packages/ckeditor5-link/src/utils.ts b/packages/ckeditor5-link/src/utils.ts
index 42833be3104..c7060ef8e2b 100644
--- a/packages/ckeditor5-link/src/utils.ts
+++ b/packages/ckeditor5-link/src/utils.ts
@@ -17,7 +17,10 @@ import type {
ViewNode,
ViewDocumentFragment
} from 'ckeditor5/src/engine.js';
+
+import type { Editor } from 'ckeditor5/src/core.js';
import type { LocaleTranslate } from 'ckeditor5/src/utils.js';
+import type { BookmarkEditing } from '@ckeditor/ckeditor5-bookmark';
import type {
LinkDecoratorAutomaticDefinition,
@@ -25,6 +28,8 @@ import type {
LinkDecoratorManualDefinition
} from './linkconfig.js';
+import type { LinkActionsViewOptions } from './ui/linkactionsview.js';
+
import { upperFirst } from 'lodash-es';
const ATTRIBUTE_WHITESPACES = /[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205f\u3000]/g; // eslint-disable-line no-control-regex
@@ -194,6 +199,47 @@ export function openLink( link: string ): void {
window.open( link, '_blank', 'noopener' );
}
+/**
+ * Creates the bookmark callbacks for handling link opening experience.
+ */
+export function createBookmarkCallbacks( editor: Editor ): LinkActionsViewOptions {
+ const bookmarkEditing: BookmarkEditing | null = editor.plugins.has( 'BookmarkEditing' ) ?
+ editor.plugins.get( 'BookmarkEditing' ) :
+ null;
+
+ /**
+ * Returns `true` when bookmark `id` matches the hash from `link`.
+ */
+ function isScrollableToTarget( link: string | undefined ): boolean {
+ return !!link &&
+ link.startsWith( '#' ) &&
+ !!bookmarkEditing &&
+ !!bookmarkEditing.getElementForBookmarkId( link.slice( 1 ) );
+ }
+
+ /**
+ * Scrolls the view to the desired bookmark or open a link in new window.
+ */
+ function scrollToTarget( link: string ): void {
+ const bookmarkId = link.slice( 1 );
+ const modelBookmark = bookmarkEditing!.getElementForBookmarkId( bookmarkId );
+
+ editor.model.change( writer => {
+ writer.setSelection( modelBookmark!, 'on' );
+ } );
+
+ editor.editing.view.scrollToTheSelection( {
+ alignToTop: true,
+ forceScroll: true
+ } );
+ }
+
+ return {
+ isScrollableToTarget,
+ scrollToTarget
+ };
+}
+
export type NormalizedLinkDecoratorAutomaticDefinition = LinkDecoratorAutomaticDefinition & { id: string };
export type NormalizedLinkDecoratorManualDefinition = LinkDecoratorManualDefinition & { id: string };
export type NormalizedLinkDecoratorDefinition = NormalizedLinkDecoratorAutomaticDefinition | NormalizedLinkDecoratorManualDefinition;
diff --git a/packages/ckeditor5-link/tests/linkediting.js b/packages/ckeditor5-link/tests/linkediting.js
index e75c55fe126..7731688dec0 100644
--- a/packages/ckeditor5-link/tests/linkediting.js
+++ b/packages/ckeditor5-link/tests/linkediting.js
@@ -20,6 +20,8 @@ import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph.js';
import Input from '@ckeditor/ckeditor5-typing/src/input.js';
import Delete from '@ckeditor/ckeditor5-typing/src/delete.js';
import ImageInline from '@ckeditor/ckeditor5-image/src/imageinline.js';
+import Essentials from '@ckeditor/ckeditor5-essentials/src/essentials.js';
+import { Bookmark } from '@ckeditor/ckeditor5-bookmark';
import { getData as getModelData, setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model.js';
import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view.js';
import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard.js';
@@ -1020,7 +1022,7 @@ describe( 'LinkEditing', () => {
it( 'should follow the link after CMD+click', () => {
setModelData( model, '<$text linkHref="http://www.ckeditor.com">Bar[]$text>' );
- fireClickEvent( { metaKey: true, ctrlKey: false } );
+ fireClickEvent( { metaKey: true, ctrlKey: false }, editor, view );
expect( stub.calledOnce ).to.be.true;
expect( stub.calledOn( window ) ).to.be.true;
@@ -1031,7 +1033,7 @@ describe( 'LinkEditing', () => {
it( 'should not follow the link after CTRL+click', () => {
setModelData( model, '<$text linkHref="http://www.ckeditor.com">Bar[]$text>' );
- fireClickEvent( { metaKey: false, ctrlKey: true } );
+ fireClickEvent( { metaKey: false, ctrlKey: true }, editor, view );
expect( stub.notCalled ).to.be.true;
expect( eventPreventDefault.calledOnce ).to.be.false;
@@ -1040,11 +1042,110 @@ describe( 'LinkEditing', () => {
it( 'should not follow the link after click with neither CMD nor CTRL pressed', () => {
setModelData( model, '<$text linkHref="http://www.ckeditor.com">Bar[]$text>' );
- fireClickEvent( { metaKey: false, ctrlKey: false } );
+ fireClickEvent( { metaKey: false, ctrlKey: false }, editor, view );
expect( stub.notCalled ).to.be.true;
expect( eventPreventDefault.calledOnce ).to.be.false;
} );
+
+ describe( 'when href starts with `#`', () => {
+ describe( 'and Bookmark plugin is loaded', () => {
+ let view, editor, model, element;
+
+ beforeEach( async () => {
+ element = document.createElement( 'div' );
+ document.body.appendChild( element );
+
+ editor = await ClassicTestEditor.create( element, {
+ plugins: [ Paragraph, LinkEditing, Enter, Clipboard, ImageInline, Bookmark ]
+ } );
+
+ model = editor.model;
+ view = editor.editing.view;
+ } );
+
+ afterEach( () => {
+ element.remove();
+
+ return editor.destroy();
+ } );
+
+ it( 'should scroll to bookmark when bookmark `id` matches hash `url`', () => {
+ setModelData( model,
+ '<$text linkHref="#foo">Bar[]$text>' +
+ ''
+ );
+
+ fireClickEvent( { metaKey: true, ctrlKey: false }, editor, view );
+
+ expect( stub.notCalled ).to.be.true;
+ expect( stub.calledOn( window ) ).to.be.false;
+ expect( eventPreventDefault.calledOnce ).to.be.true;
+ } );
+
+ it( 'should open link when bookmark `id` does not matches hash `url`', () => {
+ setModelData( model,
+ '<$text linkHref="#foo">Bar[]$text>' +
+ ''
+ );
+
+ fireClickEvent( { metaKey: true, ctrlKey: false }, editor, view );
+
+ expect( stub.notCalled ).to.be.false;
+ expect( stub.calledOn( window ) ).to.be.true;
+ expect( stub.calledWith( '#foo', '_blank', 'noopener' ) ).to.be.true;
+ expect( eventPreventDefault.calledOnce ).to.be.true;
+ } );
+
+ it( 'should open link when there is none of them', () => {
+ setModelData( model,
+ '<$text linkHref="#foo">Bar[]$text>'
+ );
+
+ fireClickEvent( { metaKey: true, ctrlKey: false }, editor, view );
+
+ expect( stub.notCalled ).to.be.false;
+ expect( stub.calledOn( window ) ).to.be.true;
+ expect( stub.calledWith( '#foo', '_blank', 'noopener' ) ).to.be.true;
+ expect( eventPreventDefault.calledOnce ).to.be.true;
+ } );
+ } );
+
+ describe( 'and Bookmark plugin is not loaded', () => {
+ let view, editor, model, element;
+
+ beforeEach( async () => {
+ element = document.createElement( 'div' );
+ document.body.appendChild( element );
+
+ editor = await ClassicTestEditor.create( element, {
+ plugins: [ Essentials, Paragraph, LinkEditing ]
+ } );
+
+ model = editor.model;
+ view = editor.editing.view;
+ } );
+
+ afterEach( () => {
+ element.remove();
+
+ return editor.destroy();
+ } );
+
+ it( 'should open link', () => {
+ setModelData( model,
+ '<$text linkHref="#foo">Bar[]$text>'
+ );
+
+ fireClickEvent( { metaKey: true, ctrlKey: false }, editor, view );
+
+ expect( stub.notCalled ).to.be.false;
+ expect( stub.calledOn( window ) ).to.be.true;
+ expect( stub.calledWith( '#foo', '_blank', 'noopener' ) ).to.be.true;
+ expect( eventPreventDefault.calledOnce ).to.be.true;
+ } );
+ } );
+ } );
} );
describe( 'on non-Mac', () => {
@@ -1055,7 +1156,7 @@ describe( 'LinkEditing', () => {
it( 'should follow the link after CTRL+click', () => {
setModelData( model, '<$text linkHref="http://www.ckeditor.com">Bar[]$text>' );
- fireClickEvent( { metaKey: false, ctrlKey: true } );
+ fireClickEvent( { metaKey: false, ctrlKey: true }, editor, view );
expect( stub.calledOnce ).to.be.true;
expect( stub.calledOn( window ) ).to.be.true;
@@ -1065,7 +1166,7 @@ describe( 'LinkEditing', () => {
it( 'should not follow the link after CMD+click', () => {
setModelData( model, '<$text linkHref="http://www.ckeditor.com">Bar[]$text>' );
- fireClickEvent( { metaKey: true, ctrlKey: false } );
+ fireClickEvent( { metaKey: true, ctrlKey: false }, editor, view );
expect( stub.notCalled ).to.be.true;
} );
@@ -1073,16 +1174,115 @@ describe( 'LinkEditing', () => {
it( 'should not follow the link after click with neither CMD nor CTRL pressed', () => {
setModelData( model, '<$text linkHref="http://www.ckeditor.com">Bar[]$text>' );
- fireClickEvent( { metaKey: false, ctrlKey: false } );
+ fireClickEvent( { metaKey: false, ctrlKey: false }, editor, view );
expect( stub.notCalled ).to.be.true;
} );
+
+ describe( 'href starts with `#`', () => {
+ describe( 'and Bookmark plugin is loaded', () => {
+ let view, editor, model, element;
+
+ beforeEach( async () => {
+ element = document.createElement( 'div' );
+ document.body.appendChild( element );
+
+ editor = await ClassicTestEditor.create( element, {
+ plugins: [ Paragraph, LinkEditing, Enter, Clipboard, ImageInline, Bookmark ]
+ } );
+
+ model = editor.model;
+ view = editor.editing.view;
+ } );
+
+ afterEach( () => {
+ element.remove();
+
+ return editor.destroy();
+ } );
+
+ it( 'should scroll to bookmark when bookmark `id` matches hash `url`', () => {
+ setModelData( model,
+ '<$text linkHref="#foo">Bar[]$text>' +
+ ''
+ );
+
+ fireClickEvent( { metaKey: false, ctrlKey: true }, editor, view );
+
+ expect( stub.notCalled ).to.be.true;
+ expect( stub.calledOn( window ) ).to.be.false;
+ expect( eventPreventDefault.calledOnce ).to.be.true;
+ } );
+
+ it( 'should open link when bookmark `id` does not matches hash `url`', () => {
+ setModelData( model,
+ '<$text linkHref="#foo">Bar[]$text>' +
+ ''
+ );
+
+ fireClickEvent( { metaKey: false, ctrlKey: true }, editor, view );
+
+ expect( stub.notCalled ).to.be.false;
+ expect( stub.calledOn( window ) ).to.be.true;
+ expect( stub.calledWith( '#foo', '_blank', 'noopener' ) ).to.be.true;
+ expect( eventPreventDefault.calledOnce ).to.be.true;
+ } );
+
+ it( 'should open link when there is none of them', () => {
+ setModelData( model,
+ '<$text linkHref="#foo">Bar[]$text>'
+ );
+
+ fireClickEvent( { metaKey: false, ctrlKey: true }, editor, view );
+
+ expect( stub.notCalled ).to.be.false;
+ expect( stub.calledOn( window ) ).to.be.true;
+ expect( stub.calledWith( '#foo', '_blank', 'noopener' ) ).to.be.true;
+ expect( eventPreventDefault.calledOnce ).to.be.true;
+ } );
+ } );
+
+ describe( 'and Bookmark plugin is not loaded', () => {
+ let view, editor, model, element;
+
+ beforeEach( async () => {
+ element = document.createElement( 'div' );
+ document.body.appendChild( element );
+
+ editor = await ClassicTestEditor.create( element, {
+ plugins: [ Essentials, Paragraph, LinkEditing ]
+ } );
+
+ model = editor.model;
+ view = editor.editing.view;
+ } );
+
+ afterEach( () => {
+ element.remove();
+
+ return editor.destroy();
+ } );
+
+ it( 'should open link', () => {
+ setModelData( model,
+ '<$text linkHref="#foo">Bar[]$text>'
+ );
+
+ fireClickEvent( { metaKey: false, ctrlKey: true }, editor, view );
+
+ expect( stub.notCalled ).to.be.false;
+ expect( stub.calledOn( window ) ).to.be.true;
+ expect( stub.calledWith( '#foo', '_blank', 'noopener' ) ).to.be.true;
+ expect( eventPreventDefault.calledOnce ).to.be.true;
+ } );
+ } );
+ } );
} );
it( 'should follow the inline image link', () => {
setModelData( model, '[]' );
- fireClickEvent( { metaKey: env.isMac, ctrlKey: !env.isMac }, 'img' );
+ fireClickEvent( { metaKey: env.isMac, ctrlKey: !env.isMac }, editor, view, 'img' );
expect( stub.calledOnce ).to.be.true;
expect( stub.calledOn( window ) ).to.be.true;
@@ -1098,7 +1298,7 @@ describe( 'LinkEditing', () => {
setModelData( model, '<$text customLink="">Bar[]$text>' );
- fireClickEvent( { metaKey: env.isMac, ctrlKey: !env.isMac } );
+ fireClickEvent( { metaKey: env.isMac, ctrlKey: !env.isMac }, editor, view );
expect( stub.notCalled ).to.be.true;
expect( eventPreventDefault.calledOnce ).to.be.false;
@@ -1112,13 +1312,13 @@ describe( 'LinkEditing', () => {
setModelData( model, '<$text customLink="">Bar[]$text>' );
- fireClickEvent( { metaKey: env.isMac, ctrlKey: !env.isMac }, 'span' );
+ fireClickEvent( { metaKey: env.isMac, ctrlKey: !env.isMac }, editor, view, 'span' );
expect( stub.notCalled ).to.be.true;
expect( eventPreventDefault.calledOnce ).to.be.false;
} );
- function fireClickEvent( options, tagName = 'a' ) {
+ function fireClickEvent( options, editor, view, tagName = 'a' ) {
const linkElement = editor.ui.getEditableElement().getElementsByTagName( tagName )[ 0 ];
eventPreventDefault = sinon.spy();
@@ -1163,7 +1363,7 @@ describe( 'LinkEditing', () => {
it( `should open link after pressing ALT+ENTER if ${ condition }`, () => {
setModelData( model, modelData );
- fireEnterPressedEvent( { altKey: true } );
+ fireEnterPressedEvent( { altKey: true }, view );
expect( stub.calledOnce ).to.be.true;
expect( stub.calledOn( window ) ).to.be.true;
@@ -1174,7 +1374,7 @@ describe( 'LinkEditing', () => {
it( 'should not open link after pressing ENTER without ALT', () => {
setModelData( model, '<$text linkHref="http://www.ckeditor.com">Ba[]r$text>' );
- fireEnterPressedEvent( { altKey: false } );
+ fireEnterPressedEvent( { altKey: false }, view );
expect( stub.notCalled ).to.be.true;
} );
@@ -1182,12 +1382,111 @@ describe( 'LinkEditing', () => {
it( 'should not open link after pressing ALT+ENTER if not inside a link', () => {
setModelData( model, '<$text linkHref="http://www.ckeditor.com">Bar$text>Baz[]' );
- fireEnterPressedEvent( { altKey: true } );
+ fireEnterPressedEvent( { altKey: true }, view );
expect( stub.notCalled ).to.be.true;
} );
- function fireEnterPressedEvent( options ) {
+ describe( 'when href starts with `#`', () => {
+ describe( 'and Bookmark plugin is loaded', () => {
+ let view, editor, model, element;
+
+ beforeEach( async () => {
+ element = document.createElement( 'div' );
+ document.body.appendChild( element );
+
+ editor = await ClassicTestEditor.create( element, {
+ plugins: [ Paragraph, LinkEditing, Enter, Clipboard, ImageInline, Bookmark ]
+ } );
+
+ model = editor.model;
+ view = editor.editing.view;
+ } );
+
+ afterEach( () => {
+ element.remove();
+
+ return editor.destroy();
+ } );
+
+ it( 'should scroll to bookmark when bookmark `id` matches hash `url`', () => {
+ setModelData( model,
+ '<$text linkHref="#foo">Bar[]$text>' +
+ ''
+ );
+
+ fireEnterPressedEvent( { altKey: true }, view );
+
+ expect( stub.notCalled ).to.be.true;
+ expect( stub.calledOn( window ) ).to.be.false;
+ expect( eventPreventDefault.calledOnce ).to.be.true;
+ } );
+
+ it( 'should open link when bookmark `id` does not matches hash `url`', () => {
+ setModelData( model,
+ '<$text linkHref="#foo">Bar[]$text>' +
+ ''
+ );
+
+ fireEnterPressedEvent( { altKey: true }, view );
+
+ expect( stub.notCalled ).to.be.false;
+ expect( stub.calledOn( window ) ).to.be.true;
+ expect( stub.calledWith( '#foo', '_blank', 'noopener' ) ).to.be.true;
+ expect( eventPreventDefault.calledOnce ).to.be.true;
+ } );
+
+ it( 'should open link when there is none of them', () => {
+ setModelData( model,
+ '<$text linkHref="#foo">Bar[]$text>'
+ );
+
+ fireEnterPressedEvent( { altKey: true }, view );
+
+ expect( stub.notCalled ).to.be.false;
+ expect( stub.calledOn( window ) ).to.be.true;
+ expect( stub.calledWith( '#foo', '_blank', 'noopener' ) ).to.be.true;
+ expect( eventPreventDefault.calledOnce ).to.be.true;
+ } );
+ } );
+
+ describe( 'and Bookmark plugin is not loaded', () => {
+ let view, editor, model, element;
+
+ beforeEach( async () => {
+ element = document.createElement( 'div' );
+ document.body.appendChild( element );
+
+ editor = await ClassicTestEditor.create( element, {
+ plugins: [ Essentials, Paragraph, LinkEditing ]
+ } );
+
+ model = editor.model;
+ view = editor.editing.view;
+ } );
+
+ afterEach( () => {
+ element.remove();
+
+ return editor.destroy();
+ } );
+
+ it( 'should open link', () => {
+ setModelData( model,
+ '<$text linkHref="#foo">Bar[]$text>'
+ );
+
+ fireEnterPressedEvent( { altKey: true }, view );
+
+ expect( stub.notCalled ).to.be.false;
+ expect( stub.calledOn( window ) ).to.be.true;
+ expect( stub.calledWith( '#foo', '_blank', 'noopener' ) ).to.be.true;
+ expect( eventPreventDefault.calledOnce ).to.be.true;
+ } );
+ } );
+ } );
+
+ function fireEnterPressedEvent( options, view ) {
view.document.fire( 'keydown', {
keyCode: keyCodes.enter,
domEvent: {
diff --git a/packages/ckeditor5-link/tests/ui/linkactionsview.js b/packages/ckeditor5-link/tests/ui/linkactionsview.js
index 9634faa3e5e..c497e723162 100644
--- a/packages/ckeditor5-link/tests/ui/linkactionsview.js
+++ b/packages/ckeditor5-link/tests/ui/linkactionsview.js
@@ -3,7 +3,7 @@
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
*/
-/* globals document */
+/* globals window, document, Event */
import LinkActionsView from '../../src/ui/linkactionsview.js';
import View from '@ckeditor/ckeditor5-ui/src/view.js';
@@ -15,12 +15,23 @@ import ViewCollection from '@ckeditor/ckeditor5-ui/src/viewcollection.js';
import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils.js';
describe( 'LinkActionsView', () => {
- let view;
+ let view, editorElement, isScrollableToTarget, scrollToTarget;
testUtils.createSinonSandbox();
- beforeEach( () => {
- view = new LinkActionsView( { t: val => val } );
+ beforeEach( async () => {
+ editorElement = document.createElement( 'div' );
+ document.body.appendChild( editorElement );
+
+ isScrollableToTarget = sinon.stub();
+ scrollToTarget = sinon.stub();
+
+ const createBookmarkCallbacks = {
+ isScrollableToTarget,
+ scrollToTarget
+ };
+
+ view = new LinkActionsView( { t: () => {} }, undefined, createBookmarkCallbacks );
view.render();
document.body.appendChild( view.element );
} );
@@ -28,6 +39,7 @@ describe( 'LinkActionsView', () => {
afterEach( () => {
view.element.remove();
view.destroy();
+ editorElement.remove();
} );
describe( 'constructor()', () => {
@@ -71,7 +83,7 @@ describe( 'LinkActionsView', () => {
it( 'should create #_linkConfig containing config object passed as argument', () => {
const customConfig = { allowedProtocols: [ 'https', 'ftps', 'tel', 'sms' ] };
- const view = new LinkActionsView( { t: () => { } }, customConfig );
+ const view = new LinkActionsView( { t: () => {} }, customConfig );
view.render();
expect( view._linkConfig ).to.equal( customConfig );
@@ -138,6 +150,157 @@ describe( 'LinkActionsView', () => {
expect( view.previewButtonView.isEnabled ).to.be.true;
} );
+
+ describe( 'when href starts with `#`', () => {
+ describe( 'and Bookmark plugin is loaded', () => {
+ it( 'should scroll to bookmark when bookmark `id` matches hash `url`', () => {
+ isScrollableToTarget.returns( true );
+
+ view.href = '#foo';
+
+ expect( view.previewButtonView.element.getAttribute( 'href' ) ).to.equal( '#foo' );
+
+ const spy = sinon.spy();
+ const windowOpenStub = sinon.stub( window, 'open' );
+
+ view.previewButtonView.on( 'execute', spy );
+ view.previewButtonView.element.dispatchEvent( new Event( 'click' ) );
+ sinon.assert.callCount( spy, 1 );
+ sinon.assert.callCount( scrollToTarget, 1 );
+ sinon.assert.callCount( windowOpenStub, 0 );
+ } );
+
+ it( 'should open link when bookmark `id` does not matches hash `url`', () => {
+ isScrollableToTarget.returns( false );
+
+ view.href = '#foo';
+
+ expect( view.previewButtonView.element.getAttribute( 'href' ) ).to.equal( '#foo' );
+
+ const spy = sinon.spy();
+ const windowOpenStub = sinon.stub( window, 'open' );
+
+ view.previewButtonView.on( 'execute', spy );
+ view.previewButtonView.element.dispatchEvent( new Event( 'click' ) );
+ sinon.assert.callCount( spy, 1 );
+ sinon.assert.callCount( scrollToTarget, 0 );
+ sinon.assert.callCount( windowOpenStub, 1 );
+ } );
+ } );
+
+ describe( 'and Bookmark plugin is not loaded', () => {
+ let view, editorElement, isScrollableToTarget, scrollToTarget;
+
+ testUtils.createSinonSandbox();
+
+ beforeEach( async () => {
+ editorElement = document.createElement( 'div' );
+ document.body.appendChild( editorElement );
+
+ isScrollableToTarget = sinon.stub();
+ scrollToTarget = sinon.stub();
+
+ const createBookmarkCallbacks = {
+ isScrollableToTarget,
+ scrollToTarget
+ };
+
+ view = new LinkActionsView( { t: () => {} }, undefined, createBookmarkCallbacks );
+ view.render();
+ document.body.appendChild( view.element );
+ } );
+
+ afterEach( () => {
+ view.element.remove();
+ view.destroy();
+ editorElement.remove();
+ } );
+
+ it( 'should open link', () => {
+ isScrollableToTarget.returns( false );
+
+ view.href = '#foo';
+
+ expect( view.previewButtonView.element.getAttribute( 'href' ) ).to.equal( '#foo' );
+
+ const spy = sinon.spy();
+ const windowOpenStub = sinon.stub( window, 'open' );
+
+ view.previewButtonView.on( 'execute', spy );
+ view.previewButtonView.element.dispatchEvent( new Event( 'click' ) );
+ sinon.assert.callCount( spy, 1 );
+ sinon.assert.callCount( scrollToTarget, 0 );
+ sinon.assert.callCount( windowOpenStub, 1 );
+ } );
+ } );
+ } );
+
+ describe( 'when href not starts with `#`', () => {
+ describe( 'and Bookmark plugin is loaded', () => {
+ it( 'should open link', () => {
+ isScrollableToTarget.returns( false );
+
+ view.href = 'foo';
+
+ expect( view.previewButtonView.element.getAttribute( 'href' ) ).to.equal( 'foo' );
+
+ const spy = sinon.spy();
+ const windowOpenStub = sinon.stub( window, 'open' );
+
+ view.previewButtonView.on( 'execute', spy );
+ view.previewButtonView.element.dispatchEvent( new Event( 'click' ) );
+ sinon.assert.callCount( spy, 1 );
+ sinon.assert.callCount( scrollToTarget, 0 );
+ sinon.assert.callCount( windowOpenStub, 1 );
+ } );
+ } );
+
+ describe( 'and Bookmark plugin is not loaded', () => {
+ let view, editorElement, isScrollableToTarget, scrollToTarget;
+
+ testUtils.createSinonSandbox();
+
+ beforeEach( async () => {
+ editorElement = document.createElement( 'div' );
+ document.body.appendChild( editorElement );
+
+ isScrollableToTarget = sinon.stub();
+ scrollToTarget = sinon.stub();
+
+ const createBookmarkCallbacks = {
+ isScrollableToTarget,
+ scrollToTarget
+ };
+
+ view = new LinkActionsView( { t: () => {} }, undefined, createBookmarkCallbacks );
+ view.render();
+ document.body.appendChild( view.element );
+ } );
+
+ afterEach( () => {
+ view.element.remove();
+ view.destroy();
+ editorElement.remove();
+ } );
+
+ it( 'should open link', () => {
+ isScrollableToTarget.returns( false );
+
+ view.href = 'foo';
+
+ expect( view.previewButtonView.element.getAttribute( 'href' ) ).to.equal( 'foo' );
+
+ const spy = sinon.spy();
+ const windowOpenStub = sinon.stub( window, 'open' );
+
+ view.previewButtonView.on( 'execute', spy );
+ view.previewButtonView.element.dispatchEvent( new Event( 'click' ) );
+ sinon.assert.callCount( spy, 1 );
+ sinon.assert.callCount( scrollToTarget, 0 );
+ sinon.assert.callCount( windowOpenStub, 1 );
+ } );
+ } );
+ } );
} );
} );