diff --git a/docs/assets/img/bookmarks-panel.png b/docs/assets/img/bookmarks-panel.png new file mode 100644 index 00000000000..5f664839e25 Binary files /dev/null and b/docs/assets/img/bookmarks-panel.png differ diff --git a/packages/ckeditor5-bookmark/docs/features/bookmarks.md b/packages/ckeditor5-bookmark/docs/features/bookmarks.md index 3393bd56b30..4968b9c1268 100644 --- a/packages/ckeditor5-bookmark/docs/features/bookmarks.md +++ b/packages/ckeditor5-bookmark/docs/features/bookmarks.md @@ -18,7 +18,7 @@ To use the bookmark as an anchor in the content, add a link {@icon @ckeditor/cke The Bookmarks feature is production-ready but does not include integration with the linking experience yet (see [#17230](https://github.com/ckeditor/ckeditor5/issues/17230)). - + Integrators should guide their users on linking to bookmarks using the `#` anchors. @@ -33,12 +33,19 @@ To use the bookmark as an anchor in the content, add a link {@icon @ckeditor/cke Do not worry about setting a bookmark inside an empty paragraph. The block with the `a` tag will not be rendered in the final content (for example for printing). The feature converts anchors into bookmarks during the {@link getting-started/setup/getting-and-setting-data#initializing-the-editor-with-data initialization of the editor} or while {@link getting-started/setup/getting-and-setting-data#replacing-the-editor-data-with-setdata replacing the editor data with `setData()`}. The notation based on the `id` attribute in an `a` HTML element without a `href` attribute is converted. Similar notations meet the conditions, too: + * an `a` HTML element with a `name` attribute, * an `a` HTML element with the same `name` and `id` attributes, * an `a` HTML element with different `name` and `id` attributes. By default, all bookmarks created in the editor only have the `id="..."` attribute in the {@link getting-started/setup/getting-and-setting-data#getting-the-editor-data-with-getdata editor data}. +## Integration with the link feature + +Bookmarks integrate with {@link features/link links}, providing a smooth linking experience. If you have any bookmarks in your content, you can access the "Bookmarks" panel available during link creation. It will display all bookmarks available in the edited content. Choose one of the anchors from the list and use it as a link target. + +{@img assets/img/bookmarks-panel.png 600 The bookmarks panel displayed during link creation} + ## Installation @@ -107,6 +114,7 @@ Here are some other CKEditor 5 features that you can use similarly to the b ## Common API The {@link module:bookmark/bookmark~Bookmark} plugin registers the `'bookmark'` UI button component implemented by the {@link module:bookmark/bookmarkui~BookmarkUI bookmark UI feature}, and the following commands: + * the `'insertBookmark'` command implemented by the {@link module:bookmark/insertbookmarkcommand~InsertBookmarkCommand editing feature}. * the `'updateBookmark'` command implemented by the {@link module:bookmark/updatebookmarkcommand~UpdateBookmarkCommand editing feature}. diff --git a/packages/ckeditor5-bookmark/lang/contexts.json b/packages/ckeditor5-bookmark/lang/contexts.json index 3cf525dea95..26d0720b504 100644 --- a/packages/ckeditor5-bookmark/lang/contexts.json +++ b/packages/ckeditor5-bookmark/lang/contexts.json @@ -1,7 +1,5 @@ { "Bookmark": "The label of the bookmark toolbar button. Also, a bookmark form header.", - "Insert": "The button in the bookmark insert form.", - "Update": "The button in the bookmark update form.", "Edit bookmark": "Button opening the Bookmark editing balloon.", "Remove bookmark": "Toolbar button tooltip for bookmark remove button.", "Bookmark name": "The label of the input in the bookmark insert and update form. Also, the tooltip for the bookmark name in the bookmark preview.", @@ -9,5 +7,9 @@ "Bookmark must not be empty.": "The error message. Displayed when the bookmark name is empty.", "Bookmark name cannot contain space characters.": "The error message. Displayed when provided name includes spaces.", "Bookmark name already exists.": "The error message. Displayed when provided name already exists.", - "bookmark widget": "The label for the bookmark widget." + "bookmark widget": "The label for the bookmark widget.", + "Bookmark toolbar": "The label used by assistive technologies describing an bookmark toolbar attached to a bookmark widget.", + "Bookmarks": "Title for a feature displaying a list of bookmarks.", + "No bookmarks available.": "A message displayed instead of a list of bookmarks if it is empty.", + "Scroll to bookmark": "Tooltip shown after hovering the bookmark link preview." } diff --git a/packages/ckeditor5-bookmark/package.json b/packages/ckeditor5-bookmark/package.json index 8046056bb3f..9ddddb2ae32 100644 --- a/packages/ckeditor5-bookmark/package.json +++ b/packages/ckeditor5-bookmark/package.json @@ -15,7 +15,6 @@ "dependencies": { "ckeditor5": "44.1.0", "@ckeditor/ckeditor5-core": "44.1.0", - "@ckeditor/ckeditor5-engine": "44.1.0", "@ckeditor/ckeditor5-widget": "44.1.0", "@ckeditor/ckeditor5-utils": "44.1.0", "@ckeditor/ckeditor5-ui": "44.1.0" @@ -39,6 +38,7 @@ "@ckeditor/ckeditor5-paste-from-office": "44.1.0", "@ckeditor/ckeditor5-table": "44.1.0", "@ckeditor/ckeditor5-theme-lark": "44.1.0", + "@ckeditor/ckeditor5-engine": "44.1.0", "typescript": "5.0.4", "webpack": "^5.94.0", "webpack-cli": "^5.1.4" diff --git a/packages/ckeditor5-bookmark/src/bookmarkconfig.ts b/packages/ckeditor5-bookmark/src/bookmarkconfig.ts index 69e45b88ad9..b32f3c60a2d 100644 --- a/packages/ckeditor5-bookmark/src/bookmarkconfig.ts +++ b/packages/ckeditor5-bookmark/src/bookmarkconfig.ts @@ -48,4 +48,29 @@ export interface BookmarkConfig { * @default true */ enableNonEmptyAnchorConversion?: boolean; + + /** + * Items to be placed in the bookmark contextual toolbar. + * + * Assuming that you use the {@link module:bookmark/bookmarkui~BookmarkUI} feature, the following toolbar items will be available + * in {@link module:ui/componentfactory~ComponentFactory}: + * + * * `'bookmarkPreview'`, + * * `'editBookmark'`, + * * `'removeBookmark'`. + * + * The default configuration for bookmark toolbar is: + * + * ```ts + * const bookmarkConfig = { + * toolbar: [ 'bookmarkPreview', '|', 'editBookmark', 'removeBookmark' ] + * }; + * ``` + * + * Of course, the same buttons can also be used in the + * {@link module:core/editor/editorconfig~EditorConfig#toolbar main editor toolbar}. + * + * Read more about configuring the toolbar in {@link module:core/editor/editorconfig~EditorConfig#toolbar}. + */ + toolbar?: Array; } diff --git a/packages/ckeditor5-bookmark/src/bookmarkediting.ts b/packages/ckeditor5-bookmark/src/bookmarkediting.ts index 61d7aad0e45..8bc276bf11f 100644 --- a/packages/ckeditor5-bookmark/src/bookmarkediting.ts +++ b/packages/ckeditor5-bookmark/src/bookmarkediting.ts @@ -7,7 +7,7 @@ * @module bookmark/bookmarkediting */ -import { type Editor, Plugin, icons } from 'ckeditor5/src/core.js'; +import { Plugin, icons, type Editor } from 'ckeditor5/src/core.js'; import { toWidget } from 'ckeditor5/src/widget.js'; import { IconView } from 'ckeditor5/src/ui.js'; import type { EventInfo } from 'ckeditor5/src/utils.js'; @@ -51,6 +51,17 @@ export default class BookmarkEditing extends Plugin { return true; } + /** + * @inheritDoc + */ + constructor( editor: Editor ) { + super( editor ); + + editor.config.define( 'bookmark', { + toolbar: [ 'bookmarkPreview', '|', 'editBookmark', 'removeBookmark' ] + } ); + } + /** * @inheritDoc */ @@ -81,6 +92,13 @@ export default class BookmarkEditing extends Plugin { return null; } + /** + * Returns all unique bookmark names existing in the content. + */ + public getAllBookmarkNames(): Set { + return new Set( this._bookmarkElements.values() ); + } + /** * Defines the schema for the bookmark feature. */ @@ -135,6 +153,7 @@ export default class BookmarkEditing extends Plugin { class: 'ck-bookmark' }, [ this._createBookmarkUIElement( writer ) ] ); + writer.setCustomProperty( 'bookmark', true, containerElement ); this._bookmarkElements.set( modelElement, id ); // `getFillerOffset` is not needed to set here, because `toWidget` has already covered it. diff --git a/packages/ckeditor5-bookmark/src/bookmarkui.ts b/packages/ckeditor5-bookmark/src/bookmarkui.ts index 4d2601cbd7f..bcc12ca7d5f 100644 --- a/packages/ckeditor5-bookmark/src/bookmarkui.ts +++ b/packages/ckeditor5-bookmark/src/bookmarkui.ts @@ -8,32 +8,36 @@ */ import { Plugin, type Editor, icons } from 'ckeditor5/src/core.js'; +import type { LinksProviderDetailedItem, LinksProviderListItem } from '@ckeditor/ckeditor5-link'; import { ButtonView, ContextualBalloon, CssTransitionDisablerMixin, MenuBarMenuListItemButtonView, clickOutsideHandler, + LabelView, + BalloonPanelView, type ViewWithCssTransitionDisabler } from 'ckeditor5/src/ui.js'; import { - ClickObserver, - type ViewDocumentClickEvent, type Element, + type ViewDocumentSelection, type ViewElement } from 'ckeditor5/src/engine.js'; import type { PositionOptions } from 'ckeditor5/src/utils.js'; import type { DeleteCommand } from 'ckeditor5/src/typing.js'; +import { isWidget, WidgetToolbarRepository } from 'ckeditor5/src/widget.js'; -import BookmarkFormView, { type BookmarkFormValidatorCallback } from './ui/bookmarkformview.js'; -import BookmarkActionsView from './ui/bookmarkactionsview.js'; +import BookmarkFormView, { type BookmarkFormViewCancelEvent, type BookmarkFormValidatorCallback } from './ui/bookmarkformview.js'; import type UpdateBookmarkCommand from './updatebookmarkcommand.js'; import type InsertBookmarkCommand from './insertbookmarkcommand.js'; import BookmarkEditing from './bookmarkediting.js'; +import '../theme/bookmarktoolbar.css'; + const VISUAL_SELECTION_MARKER_NAME = 'bookmark-ui'; /** @@ -43,11 +47,6 @@ const VISUAL_SELECTION_MARKER_NAME = 'bookmark-ui'; * which inserts the `bookmark` element upon selection. */ export default class BookmarkUI extends Plugin { - /** - * The actions view displayed inside of the balloon. - */ - public actionsView: BookmarkActionsView | null = null; - /** * The form view displayed inside the balloon. */ @@ -62,7 +61,7 @@ export default class BookmarkUI extends Plugin { * @inheritDoc */ public static get requires() { - return [ BookmarkEditing, ContextualBalloon ] as const; + return [ BookmarkEditing, ContextualBalloon, WidgetToolbarRepository ] as const; } /** @@ -85,13 +84,15 @@ export default class BookmarkUI extends Plugin { public init(): void { const editor = this.editor; - editor.editing.view.addObserver( ClickObserver ); - this._balloon = editor.plugins.get( ContextualBalloon ); + // Register the link provider in link plugin to display the link form. + if ( editor.plugins.has( 'LinkUI' ) ) { + this._registerLinkProvider(); + } + // Create toolbar buttons. - this._createToolbarBookmarkButton(); - this._enableBalloonActivators(); + this._registerComponents(); // Renders a fake visual selection marker on an expanded selection. editor.conversion.for( 'editingDowncast' ).markerToHighlight( { @@ -121,6 +122,39 @@ export default class BookmarkUI extends Plugin { } ); } + /** + * @inheritDoc + */ + public afterInit(): void { + const editor = this.editor; + const t = editor.locale.t; + const widgetToolbarRepository = this.editor.plugins.get( WidgetToolbarRepository ); + const defaultPositions = BalloonPanelView.defaultPositions; + + widgetToolbarRepository.register( 'bookmark', { + ariaLabel: t( 'Bookmark toolbar' ), + items: editor.config.get( 'bookmark.toolbar' )!, + + getRelatedElement: getSelectedBookmarkWidget, + + // Override positions to the same list as for balloon panel default + // so widget toolbar will try to use same position as form view. + positions: [ + defaultPositions.southArrowNorth, + defaultPositions.southArrowNorthMiddleWest, + defaultPositions.southArrowNorthMiddleEast, + defaultPositions.southArrowNorthWest, + defaultPositions.southArrowNorthEast, + defaultPositions.northArrowSouth, + defaultPositions.northArrowSouthMiddleWest, + defaultPositions.northArrowSouthMiddleEast, + defaultPositions.northArrowSouthWest, + defaultPositions.northArrowSouthEast, + defaultPositions.viewportStickyNorth + ] + } ); + } + /** * @inheritDoc */ @@ -131,62 +165,25 @@ export default class BookmarkUI extends Plugin { if ( this.formView ) { this.formView.destroy(); } - - if ( this.actionsView ) { - this.actionsView.destroy(); - } } /** * Creates views. */ private _createViews() { - this.actionsView = this._createActionsView(); this.formView = this._createFormView(); // Attach lifecycle actions to the the balloon. this._enableUserBalloonInteractions(); } - /** - * Creates the {@link module:bookmark/ui/bookmarkactionsview~BookmarkActionsView} instance. - */ - private _createActionsView(): BookmarkActionsView { - const editor = this.editor; - const actionsView = new BookmarkActionsView( editor.locale ); - const updateBookmarkCommand: UpdateBookmarkCommand = editor.commands.get( 'updateBookmark' )!; - const deleteCommand: DeleteCommand = editor.commands.get( 'delete' )!; - - actionsView.bind( 'id' ).to( updateBookmarkCommand, 'value' ); - actionsView.editButtonView.bind( 'isEnabled' ).to( updateBookmarkCommand ); - actionsView.removeButtonView.bind( 'isEnabled' ).to( deleteCommand ); - - // Display edit form view after clicking on the "Edit" button. - this.listenTo( actionsView, 'edit', () => { - this._addFormView(); - } ); - - // Execute remove command after clicking on the "Remove" button. - this.listenTo( actionsView, 'remove', () => { - this._hideUI(); - editor.execute( 'delete' ); - } ); - - // Close the panel on esc key press when the **actions have focus**. - actionsView.keystrokes.set( 'Esc', ( data, cancel ) => { - this._hideUI(); - cancel(); - } ); - - return actionsView; - } - /** * Creates the {@link module:bookmark/ui/bookmarkformview~BookmarkFormView} instance. */ private _createFormView(): BookmarkFormView & ViewWithCssTransitionDisabler { const editor = this.editor; const locale = editor.locale; + const t = locale.t; const insertBookmarkCommand: InsertBookmarkCommand = editor.commands.get( 'insertBookmark' )!; const updateBookmarkCommand: UpdateBookmarkCommand = editor.commands.get( 'updateBookmark' )!; const commands = [ insertBookmarkCommand, updateBookmarkCommand ]; @@ -194,6 +191,7 @@ export default class BookmarkUI extends Plugin { const formView = new ( CssTransitionDisablerMixin( BookmarkFormView ) )( locale, getFormValidators( editor ) ); formView.idInputView.fieldView.bind( 'value' ).to( updateBookmarkCommand, 'value' ); + formView.saveButtonView.bind( 'label' ).to( updateBookmarkCommand, 'value', value => value ? t( 'Save' ) : t( 'Insert' ) ); // Form elements should be read-only when corresponding commands are disabled. formView.idInputView.bind( 'isEnabled' ).toMany( @@ -203,12 +201,17 @@ export default class BookmarkUI extends Plugin { ); // Disable the "save" button if the command is disabled. - formView.buttonView.bind( 'isEnabled' ).toMany( + formView.saveButtonView.bind( 'isEnabled' ).toMany( commands, 'isEnabled', ( ...areEnabled ) => areEnabled.some( isEnabled => isEnabled ) ); + // Close the panel on form after clicking back button. + this.listenTo( formView, 'cancel', () => { + this._hideFormView(); + } ); + // Execute link command after clicking the "Save" button. this.listenTo( formView, 'submit', () => { if ( formView.isValid() ) { @@ -220,7 +223,7 @@ export default class BookmarkUI extends Plugin { editor.execute( 'insertBookmark', { bookmarkId: value } ); } - this._closeFormView(); + this._hideFormView(); } } ); @@ -229,24 +232,84 @@ export default class BookmarkUI extends Plugin { editor.ui.update(); } ); - // Close the panel on esc key press when the **form has focus**. - formView.keystrokes.set( 'Esc', ( data, cancel ) => { - this._closeFormView(); - cancel(); + return formView; + } + + /** + * Creates link form menu list entry, so it'll be possible to access + * the list of the bookmarks from the link form. + */ + private _registerLinkProvider() { + const t = this.editor.locale.t; + const linksUI = this.editor.plugins.get( 'LinkUI' )!; + const bookmarkEditing = this.editor.plugins.get( BookmarkEditing ); + + const getListItems = () => Array + .from( bookmarkEditing.getAllBookmarkNames() ) + .sort( ( a, b ) => a.localeCompare( b ) ) + .map( ( bookmarkId ): LinksProviderListItem => ( { + id: bookmarkId, + href: `#${ bookmarkId }`, + label: bookmarkId, + icon: icons.bookmarkMedium + } ) ); + + const getItem = ( href: string ): LinksProviderDetailedItem | null => { + const bookmark = [ ...bookmarkEditing.getAllBookmarkNames() ].find( item => `#${ item }` === href ); + + if ( !bookmark ) { + return null; + } + + return { + href, + label: bookmark, + icon: icons.bookmarkSmall, + tooltip: t( 'Scroll to bookmark' ) + }; + }; + + linksUI.registerLinksListProvider( { + label: t( 'Bookmarks' ), + emptyListPlaceholder: t( 'No bookmarks available.' ), + navigate: ( { href }: LinksProviderDetailedItem ) => this._scrollToBookmark( href ), + getListItems, + getItem + } ); + } + + /** + * Scrolls the editor to the bookmark with the given id. + */ + private _scrollToBookmark( href: string ) { + const bookmarkEditing = this.editor.plugins.get( BookmarkEditing ); + const bookmarkElement = bookmarkEditing.getElementForBookmarkId( href.slice( 1 ) ); + + if ( !bookmarkElement ) { + return false; + } + + this.editor.model.change( writer => { + writer.setSelection( bookmarkElement!, 'on' ); } ); - return formView; + this.editor.editing.view.scrollToTheSelection( { + alignToTop: true, + forceScroll: true + } ); + + return true; } /** * Creates a toolbar Bookmark button. Clicking this button will show * a {@link #_balloon} attached to the selection. */ - private _createToolbarBookmarkButton() { + private _registerComponents() { const editor = this.editor; editor.ui.componentFactory.add( 'bookmark', () => { - const buttonView = this._createButton( ButtonView ); + const buttonView = this._createBookmarkButton( ButtonView ); buttonView.set( { tooltip: true @@ -256,14 +319,72 @@ export default class BookmarkUI extends Plugin { } ); editor.ui.componentFactory.add( 'menuBar:bookmark', () => { - return this._createButton( MenuBarMenuListItemButtonView ); + return this._createBookmarkButton( MenuBarMenuListItemButtonView ); + } ); + + // Bookmark toolbar buttons. + + editor.ui.componentFactory.add( 'bookmarkPreview', locale => { + const updateBookmarkCommand: UpdateBookmarkCommand = editor.commands.get( 'updateBookmark' )!; + const label = new LabelView( locale ); + + label.extendTemplate( { + attributes: { + class: [ 'ck-bookmark-toolbar__preview' ] + } + } ); + + label.bind( 'text' ).to( updateBookmarkCommand, 'value' ); + + return label; + } ); + + editor.ui.componentFactory.add( 'editBookmark', locale => { + const updateBookmarkCommand: UpdateBookmarkCommand = editor.commands.get( 'updateBookmark' )!; + const button = new ButtonView( locale ); + const t = locale.t; + + button.set( { + label: t( 'Edit bookmark' ), + icon: icons.pencil, + tooltip: true + } ); + + button.bind( 'isEnabled' ).to( updateBookmarkCommand ); + + this.listenTo( button, 'execute', () => { + this._showFormView(); + } ); + + return button; + } ); + + editor.ui.componentFactory.add( 'removeBookmark', locale => { + const deleteCommand: DeleteCommand = editor.commands.get( 'delete' )!; + const button = new ButtonView( locale ); + const t = locale.t; + + button.set( { + label: t( 'Remove bookmark' ), + icon: icons.remove, + tooltip: true + } ); + + button.bind( 'isEnabled' ).to( deleteCommand ); + + this.listenTo( button, 'execute', () => { + editor.execute( 'delete' ); + editor.editing.view.focus(); + } ); + + return button; } ); } /** * Creates a button for `bookmark` command to use either in toolbar or in menu bar. */ - private _createButton( ButtonClass: T ): InstanceType { + private _createBookmarkButton( ButtonClass: T ): InstanceType { const editor = this.editor; const locale = editor.locale; const view = new ButtonClass( locale ) as InstanceType; @@ -277,7 +398,7 @@ export default class BookmarkUI extends Plugin { } ); // Execute the command. - this.listenTo( view, 'execute', () => this._showUI( true ) ); + this.listenTo( view, 'execute', () => this._showFormView() ); view.bind( 'isEnabled' ).toMany( [ insertCommand, updateCommand ], @@ -290,47 +411,15 @@ export default class BookmarkUI extends Plugin { return view; } - /** - * Attaches actions that control whether the balloon panel containing the - * {@link #formView} should be displayed. - */ - private _enableBalloonActivators(): void { - const editor = this.editor; - const viewDocument = editor.editing.view.document; - - // Handle click on view document and show panel when selection is placed inside the bookmark element. - // Keep panel open until selection will be inside the same bookmark element. - this.listenTo( viewDocument, 'click', () => { - const bookmark = this._getSelectedBookmarkElement(); - - if ( bookmark ) { - // Then show panel but keep focus inside editor editable. - this._showUI(); - } - } ); - } - /** * Attaches actions that control whether the balloon panel containing the * {@link #formView} is visible or not. */ private _enableUserBalloonInteractions(): void { - // Focus the form if the balloon is visible and the Tab key has been pressed. - this.editor.keystrokes.set( 'Tab', ( data, cancel ) => { - if ( this._areActionsVisible && !this.actionsView!.focusTracker.isFocused ) { - this.actionsView!.focus(); - cancel(); - } - }, { - // Use the high priority because the bookmark UI navigation is more important - // than other feature's actions, e.g. list indentation. - priority: 'high' - } ); - // Close the panel on the Esc key press when the editable has focus and the balloon is visible. this.editor.keystrokes.set( 'Esc', ( data, cancel ) => { - if ( this._isUIVisible ) { - this._hideUI(); + if ( this._isFormVisible ) { + this._hideFormView(); cancel(); } } ); @@ -338,39 +427,9 @@ export default class BookmarkUI extends Plugin { // Close on click outside of balloon panel element. clickOutsideHandler( { emitter: this.formView!, - activator: () => this._isUIInPanel, + activator: () => this._isFormInPanel, contextElements: () => [ this._balloon.view.element! ], - callback: () => this._hideUI() - } ); - } - - /** - * Updates the button label. If bookmark is selected label is set to 'Update' otherwise - * it is 'Insert'. - */ - private _updateFormButtonLabel( isBookmarkSelected: boolean ) { - const t = this.editor.locale.t; - - this.formView!.buttonView.label = isBookmarkSelected ? t( 'Update' ) : t( 'Insert' ); - } - - /** - * Adds the {@link #actionsView} to the {@link #_balloon}. - * - * @internal - */ - public _addActionsView(): void { - if ( !this.actionsView ) { - this._createViews(); - } - - if ( this._areActionsInPanel ) { - return; - } - - this._balloon.add( { - view: this.actionsView!, - position: this._getBalloonPositionData() + callback: () => this._hideFormView() } ); } @@ -386,8 +445,7 @@ export default class BookmarkUI extends Plugin { return; } - const editor = this.editor; - const updateBookmarkCommand: UpdateBookmarkCommand = editor.commands.get( 'updateBookmark' )!; + const updateBookmarkCommand: UpdateBookmarkCommand = this.editor.commands.get( 'updateBookmark' )!; this.formView!.disableCssTransitions(); this.formView!.resetFormStatus(); @@ -397,6 +455,7 @@ export default class BookmarkUI extends Plugin { position: this._getBalloonPositionData() } ); + this.formView!.backButtonView.isVisible = updateBookmarkCommand.isEnabled; this.formView!.idInputView.fieldView.value = updateBookmarkCommand.value || ''; // Select input when form view is currently visible. @@ -407,79 +466,42 @@ export default class BookmarkUI extends Plugin { this.formView!.enableCssTransitions(); } - /** - * Closes the form view. Decides whether the balloon should be hidden completely. - */ - private _closeFormView(): void { - const updateBookmarkCommand: UpdateBookmarkCommand = this.editor.commands.get( 'updateBookmark' )!; - - if ( updateBookmarkCommand.value !== undefined ) { - this._removeFormView(); - } else { - this._hideUI(); - } - } - /** * Removes the {@link #formView} from the {@link #_balloon}. */ private _removeFormView(): void { - if ( this._isFormInPanel ) { - // Blur the input element before removing it from DOM to prevent issues in some browsers. - // See https://github.com/ckeditor/ckeditor5/issues/1501. - this.formView!.buttonView.focus(); + // Blur the input element before removing it from DOM to prevent issues in some browsers. + // See https://github.com/ckeditor/ckeditor5/issues/1501. + this.formView!.saveButtonView.focus(); - // Reset the ID field to update the state of the submit button. - this.formView!.idInputView.fieldView.reset(); + // Reset the ID field to update the state of the submit button. + this.formView!.idInputView.fieldView.reset(); - this._balloon.remove( this.formView! ); + this._balloon.remove( this.formView! ); - // Because the form has an input which has focus, the focus must be brought back - // to the editor. Otherwise, it would be lost. - this.editor.editing.view.focus(); + // Because the form has an input which has focus, the focus must be brought back + // to the editor. Otherwise, it would be lost. + this.editor.editing.view.focus(); - this._hideFakeVisualSelection(); - } + this._hideFakeVisualSelection(); } /** - * Shows the correct UI type. It is either {@link #formView} or {@link #actionsView}. + * Shows the {@link #formView}. */ - private _showUI( forceVisible: boolean = false ): void { + private _showFormView(): void { if ( !this.formView ) { this._createViews(); } - // When there's no bookmark under the selection, go straight to the editing UI. if ( !this._getSelectedBookmarkElement() ) { - // Show visual selection on a text without a bookmark when the contextual balloon is displayed. this._showFakeVisualSelection(); - - this._addActionsView(); - - // Be sure panel with bookmark is visible. - if ( forceVisible ) { - this._balloon.showStack( 'main' ); - } - - this._addFormView(); } - // If there's a bookmark under the selection... - else { - // Go to the editing UI if actions are already visible. - if ( this._areActionsVisible ) { - this._addFormView(); - } - // Otherwise display just the actions UI. - else { - this._addActionsView(); - } - // Be sure panel with bookmark is visible. - if ( forceVisible ) { - this._balloon.showStack( 'main' ); - } - } + this._addFormView(); + + // Be sure panel with bookmark is visible. + this._balloon.showStack( 'main' ); // Begin responding to ui#update once the UI is added. this._startUpdatingUI(); @@ -487,11 +509,9 @@ export default class BookmarkUI extends Plugin { /** * Removes the {@link #formView} from the {@link #_balloon}. - * - * See {@link #_addFormView}, {@link #_addActionsView}. */ - private _hideUI(): void { - if ( !this._isUIInPanel ) { + private _hideFormView(): void { + if ( !this._isFormInPanel ) { return; } @@ -507,9 +527,6 @@ export default class BookmarkUI extends Plugin { // Remove form first because it's on top of the stack. this._removeFormView(); - // Then remove the actions view because it's beneath the form. - this._balloon.remove( this.actionsView! ); - this._hideFakeVisualSelection(); } @@ -517,7 +534,7 @@ export default class BookmarkUI extends Plugin { * Makes the UI react to the {@link module:ui/editorui/editorui~EditorUI#event:update} event to * reposition itself when the editor UI should be refreshed. * - * See: {@link #_hideUI} to learn when the UI stops reacting to the `update` event. + * See: {@link #_hideFormView} to learn when the UI stops reacting to the `update` event. */ private _startUpdatingUI(): void { const editor = this.editor; @@ -526,8 +543,6 @@ export default class BookmarkUI extends Plugin { let prevSelectedBookmark = this._getSelectedBookmarkElement(); let prevSelectionParent = getSelectionParent(); - this._updateFormButtonLabel( !!prevSelectedBookmark ); - const update = () => { const selectedBookmark = this._getSelectedBookmarkElement(); const selectionParent = getSelectionParent(); @@ -541,24 +556,22 @@ export default class BookmarkUI extends Plugin { // * the selection has expanded (e.g. displaying bookmark actions then pressing SHIFT+Right arrow). // if ( - ( prevSelectedBookmark && !selectedBookmark ) || - ( !prevSelectedBookmark && selectionParent !== prevSelectionParent ) + prevSelectedBookmark && !selectedBookmark || + !prevSelectedBookmark && selectionParent !== prevSelectionParent ) { - this._hideUI(); + this._hideFormView(); } // Update the position of the panel when: // * bookmark panel is in the visible stack // * the selection remains on the original bookmark element, // * there was no bookmark element in the first place, i.e. creating a new bookmark - else if ( this._isUIVisible ) { + else if ( this._isFormVisible ) { // If still in a bookmark element, simply update the position of the balloon. // If there was no bookmark (e.g. inserting one), the balloon must be moved // to the new position in the editing view (a new native DOM range). this._balloon.updatePosition( this._getBalloonPositionData() ); } - this._updateFormButtonLabel( !!prevSelectedBookmark ); - prevSelectedBookmark = selectedBookmark; prevSelectionParent = selectionParent; }; @@ -581,35 +594,10 @@ export default class BookmarkUI extends Plugin { } /** - * Returns `true` when {@link #actionsView} is in the {@link #_balloon}. - */ - private get _areActionsInPanel(): boolean { - return !!this.actionsView && this._balloon.hasView( this.actionsView ); - } - - /** - * Returns `true` when {@link #actionsView} is in the {@link #_balloon} and it is - * currently visible. + * Returns `true` when {@link #formView} is in the {@link #_balloon} and it is currently visible. */ - private get _areActionsVisible(): boolean { - return !!this.actionsView && this._balloon.visibleView === this.actionsView; - } - - /** - * Returns `true` when {@link #actionsView} or {@link #formView} is in the {@link #_balloon}. - */ - private get _isUIInPanel(): boolean { - return this._isFormInPanel || this._areActionsInPanel; - } - - /** - * Returns `true` when {@link #actionsView} or {@link #formView} is in the {@link #_balloon} and it is - * currently visible. - */ - private get _isUIVisible(): boolean { - const visibleView = this._balloon.visibleView; - - return !!this.formView && visibleView == this.formView || this._areActionsVisible; + private get _isFormVisible(): boolean { + return !!this.formView && this._balloon.visibleView == this.formView; } /** @@ -643,7 +631,13 @@ export default class BookmarkUI extends Plugin { }; } - return target && { target }; + if ( !target ) { + return; + } + + return { + target + }; } /** @@ -745,3 +739,16 @@ function getFormValidators( editor: Editor ): Array(); - - /** - * Helps cycling over {@link #_focusables} in the view. - */ - private readonly _focusCycler: FocusCycler; - - declare public t: LocaleTranslate; - - /** - * @inheritDoc - */ - constructor( locale: Locale ) { - super( locale ); - - const t = locale.t; - - this.bookmarkPreviewView = this._createBookmarkPreviewView(); - this.removeButtonView = this._createButton( t( 'Remove bookmark' ), icons.remove, 'remove', this.bookmarkPreviewView ); - this.editButtonView = this._createButton( t( 'Edit bookmark' ), icons.pencil, 'edit', this.bookmarkPreviewView ); - - this.set( 'id', undefined ); - - this._focusCycler = new FocusCycler( { - focusables: this._focusables, - focusTracker: this.focusTracker, - keystrokeHandler: this.keystrokes, - actions: { - // Navigate fields backwards using the Shift + Tab keystroke. - focusPrevious: 'shift + tab', - - // Navigate fields forwards using the Tab key. - focusNext: 'tab' - } - } ); - - this.setTemplate( { - tag: 'div', - - attributes: { - class: [ - 'ck', - 'ck-bookmark-actions', - 'ck-responsive-form' - ], - - // https://github.com/ckeditor/ckeditor5-link/issues/90 - tabindex: '-1' - }, - - children: [ - this.bookmarkPreviewView, - this.editButtonView, - this.removeButtonView - ] - } ); - } - - /** - * @inheritDoc - */ - public override render(): void { - super.render(); - - const childViews = [ - this.editButtonView, - this.removeButtonView - ]; - - childViews.forEach( v => { - // Register the view as focusable. - this._focusables.add( v ); - - // Register the view in the focus tracker. - this.focusTracker.add( v.element! ); - } ); - - // Start listening for the keystrokes coming from #element. - this.keystrokes.listenTo( this.element! ); - } - - /** - * @inheritDoc - */ - public override destroy(): void { - super.destroy(); - - this.focusTracker.destroy(); - this.keystrokes.destroy(); - } - - /** - * Focuses the fist {@link #_focusables} in the actions. - */ - public focus(): void { - this._focusCycler.focusFirst(); - } - - /** - * Creates a button view. - * - * @param label The button label. - * @param icon The button icon. - * @param eventName An event name that the `ButtonView#execute` event will be delegated to. - * @param additionalLabel An additional label outside the button. - * @returns The button view instance. - */ - private _createButton( label: string, icon: string, eventName: string, additionalLabel: LabelView ): ButtonView { - const button = new ButtonView( this.locale ); - - button.set( { - label, - icon, - tooltip: true - } ); - - button.delegate( 'execute' ).to( this, eventName ); - - // Since button label `id` is bound to the `ariaLabelledBy` property - // we need to modify this binding to include only the first ID token - // as this button will be labeled by multiple labels. - button.labelView.unbind( 'id' ); - - button.labelView.bind( 'id' ).to( button, 'ariaLabelledBy', ariaLabelledBy => { - return getFirstToken( ariaLabelledBy! ); - } ); - - button.ariaLabelledBy = `${ button.ariaLabelledBy } ${ additionalLabel.id }`; - - return button; - } - - /** - * Creates a bookmark name preview label. - * - * @returns The label view instance. - */ - private _createBookmarkPreviewView(): LabelView { - const label = new LabelView( this.locale ); - - label.extendTemplate( { - attributes: { - class: [ - 'ck', - 'ck-bookmark-actions__preview' - ] - } - } ); - - // Bind label text with the bookmark ID. - label.bind( 'text' ).to( this, 'id' ); - - return label; - } -} - -/** - * Fired when the {@link ~BookmarkActionsView#editButtonView} is clicked. - * - * @eventName ~BookmarkActionsView#edit - */ -export type EditEvent = { - name: 'edit'; - args: []; -}; - -/** - * Fired when the {@link ~BookmarkActionsView#removeButtonView} is clicked. - * - * @eventName ~BookmarkActionsView#remove - */ -export type RemoveEvent = { - name: 'remove'; - args: []; -}; - -/** - * Returns the first token from space separated token list. - */ -function getFirstToken( tokenList: string ): string { - return tokenList.split( ' ' )[ 0 ]; -} diff --git a/packages/ckeditor5-bookmark/src/ui/bookmarkformview.ts b/packages/ckeditor5-bookmark/src/ui/bookmarkformview.ts index cbbcb6bc437..bd952bd12c1 100644 --- a/packages/ckeditor5-bookmark/src/ui/bookmarkformview.ts +++ b/packages/ckeditor5-bookmark/src/ui/bookmarkformview.ts @@ -10,6 +10,7 @@ import { ButtonView, FocusCycler, + FormRowView, LabeledFieldView, View, ViewCollection, @@ -24,10 +25,14 @@ import { KeystrokeHandler, type Locale } from 'ckeditor5/src/utils.js'; +import { icons } from 'ckeditor5/src/core.js'; // See: #8833. // eslint-disable-next-line ckeditor5-rules/ckeditor-imports import '@ckeditor/ckeditor5-ui/theme/components/responsive-form/responsiveform.css'; +// eslint-disable-next-line ckeditor5-rules/ckeditor-imports +import '@ckeditor/ckeditor5-ui/theme/components/form/form.css'; + import '../../theme/bookmarkform.css'; /** @@ -52,9 +57,14 @@ export default class BookmarkFormView extends View { public idInputView: LabeledFieldView; /** - * The Submit button view. + * The Back button view displayed in the header. + */ + public backButtonView: ButtonView; + + /** + * A button used to submit the form. */ - public buttonView: ButtonView; + public saveButtonView: ButtonView; /** * A collection of form child views in the form. @@ -87,16 +97,32 @@ export default class BookmarkFormView extends View { constructor( locale: Locale, validators: Array ) { super( locale ); - const t = locale.t; - this._validators = validators; - this.idInputView = this._createIdInput(); + // Create buttons. + this.backButtonView = this._createBackButton(); + this.saveButtonView = this._createSaveButton(); - this.buttonView = this._createButton( t( 'Insert' ), 'ck-button-action ck-button-bold' ); - this.buttonView.type = 'submit'; + // Create input fields. + this.idInputView = this._createIdInput(); - this.children = this._createViewChildren(); + this.children = this.createCollection( [ this._createHeaderView() ] ); + this.children.add( new FormRowView( locale, { + children: [ + this.idInputView, + this.saveButtonView + ], + class: [ + 'ck-form__row_with-submit', + 'ck-form__row_large-top-padding' + ] + } ) ); + + // Close the panel on esc key press when the **form has focus**. + this.keystrokes.set( 'Esc', ( data, cancel ) => { + this.fire( 'cancel' ); + cancel(); + } ); this._focusCycler = new FocusCycler( { focusables: this._focusables, @@ -111,13 +137,16 @@ export default class BookmarkFormView extends View { } } ); - const classList = [ 'ck', 'ck-bookmark-view' ]; - this.setTemplate( { tag: 'form', attributes: { - class: classList, + class: [ + 'ck', + 'ck-form', + 'ck-bookmark-form', + 'ck-responsive-form' + ], // https://github.com/ckeditor/ckeditor5-link/issues/90 tabindex: '-1' @@ -138,8 +167,9 @@ export default class BookmarkFormView extends View { } ); const childViews = [ + this.backButtonView, this.idInputView, - this.buttonView + this.saveButtonView ]; childViews.forEach( v => { @@ -168,7 +198,7 @@ export default class BookmarkFormView extends View { * Focuses the fist {@link #_focusables} in the form. */ public focus(): void { - this._focusCycler.focusFirst(); + this.idInputView!.focus(); } /** @@ -203,41 +233,54 @@ export default class BookmarkFormView extends View { } /** - * Creates header and form view. + * Creates a back button view that cancels the form. */ - private _createViewChildren() { - const children = this.createCollection(); - const t = this.t!; + private _createBackButton(): ButtonView { + const t = this.locale!.t; + const backButton = new ButtonView( this.locale ); + + backButton.set( { + class: 'ck-button-back', + label: t( 'Back' ), + icon: icons.previousArrow, + tooltip: true + } ); - children.add( new FormHeaderView( this.locale, { label: t( 'Bookmark' ) } ) ); - children.add( this._createFormContentView() ); + backButton.delegate( 'execute' ).to( this, 'cancel' ); - return children; + return backButton; } /** - * Creates form content view with input and button. + * Creates a save button view that saves the bookmark. */ - private _createFormContentView() { - const view = new View( this.locale ); - - const children = this.createCollection(); - const classList = [ 'ck', 'ck-bookmark-form', 'ck-responsive-form' ]; + private _createSaveButton(): ButtonView { + const t = this.locale!.t; + const saveButton = new ButtonView( this.locale ); - children.add( this.idInputView ); - children.add( this.buttonView ); + saveButton.set( { + label: t( 'Save' ), + withText: true, + type: 'submit', + class: 'ck-button-action ck-button-bold' + } ); - view.setTemplate( { - tag: 'div', + return saveButton; + } - attributes: { - class: classList - }, + /** + * Creates a header view for the form. + */ + private _createHeaderView(): FormHeaderView { + const t = this.locale!.t; - children + const header = new FormHeaderView( this.locale, { + label: t( 'Bookmark' ) } ); - return view; + header.children.add( this.backButtonView, 0 ); + + return header; } /** @@ -251,34 +294,11 @@ export default class BookmarkFormView extends View { labeledInput.label = t( 'Bookmark name' ); labeledInput.infoText = t( 'Enter the bookmark name without spaces.' ); + labeledInput.class = 'ck-labeled-field-view_full-width'; return labeledInput; } - /** - * Creates a button view. - * - * @param label The button label. - * @param className The additional button CSS class name. - * @returns The button view instance. - */ - private _createButton( label: string, className: string ): ButtonView { - const button = new ButtonView( this.locale ); - - button.set( { - label, - withText: true - } ); - - button.extendTemplate( { - attributes: { - class: className - } - } ); - - return button; - } - /** * The native DOM `value` of the {@link #idInputView} element. * @@ -303,3 +323,13 @@ export default class BookmarkFormView extends View { * If string is returned, it is assumed that the form value is incorrect and the returned string is displayed in the error label */ export type BookmarkFormValidatorCallback = ( form: BookmarkFormView ) => string | undefined; + +/** + * Fired when the form view is canceled. + * + * @eventName ~BookmarkFormView#cancel + */ +export type BookmarkFormViewCancelEvent = { + name: 'cancel'; + args: []; +}; diff --git a/packages/ckeditor5-bookmark/tests/bookmarkediting.js b/packages/ckeditor5-bookmark/tests/bookmarkediting.js index 7073c39d3fd..0621b498c40 100644 --- a/packages/ckeditor5-bookmark/tests/bookmarkediting.js +++ b/packages/ckeditor5-bookmark/tests/bookmarkediting.js @@ -73,6 +73,10 @@ describe( 'BookmarkEditing', () => { expect( BookmarkEditing.isPremiumPlugin ).to.be.false; } ); + it( 'should register default bookmark toolbar config', () => { + expect( editor.config.get( 'bookmark.toolbar' ) ).to.deep.equal( [ 'bookmarkPreview', '|', 'editBookmark', 'removeBookmark' ] ); + } ); + describe( 'init', () => { it( 'adds an "insertBookmark" command', () => { expect( editor.commands.get( 'insertBookmark' ) ).to.be.instanceOf( InsertBookmarkCommand ); @@ -387,6 +391,8 @@ describe( 'BookmarkEditing', () => { expect( bookmarkWidget.getFillerOffset ).is.a( 'function' ); expect( bookmarkWidget.getFillerOffset() ).to.equal( null ); + + expect( bookmarkWidget.getCustomProperty( 'bookmark' ) ).to.be.true; } ); it( 'should not add any filler', () => { @@ -1156,6 +1162,45 @@ describe( 'BookmarkEditing', () => { } ); } ); + describe( 'getAllBookmarkNames', () => { + it( 'should return all bookmark names', () => { + const bookmarkEditing = editor.plugins.get( 'BookmarkEditing' ); + + editor.setData( + '

' + + '' + + '

' + + '

' + + '' + + '

' + + '

' + + '' + + '

' + ); + + expect( bookmarkEditing.getAllBookmarkNames() ).is.instanceof( Set ); + expect( bookmarkEditing.getAllBookmarkNames() ).is.deep.equal( new Set( [ 'foo', 'bar', 'baz' ] ) ); + } ); + + it( 'should return all unique bookmark names', () => { + const bookmarkEditing = editor.plugins.get( 'BookmarkEditing' ); + + editor.setData( + '

' + + '' + + '

' + + '

' + + '' + + '

' + + '

' + + '' + + '

' + ); + + expect( bookmarkEditing.getAllBookmarkNames() ).is.deep.equal( new Set( [ 'foo', 'bar' ] ) ); + } ); + } ); + describe( 'clipboard', () => { let clipboardPlugin, viewDocument; diff --git a/packages/ckeditor5-bookmark/tests/bookmarkui.js b/packages/ckeditor5-bookmark/tests/bookmarkui.js index b594a19a488..8b6bc85f2be 100644 --- a/packages/ckeditor5-bookmark/tests/bookmarkui.js +++ b/packages/ckeditor5-bookmark/tests/bookmarkui.js @@ -8,25 +8,26 @@ import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor.js'; import { Essentials } from '@ckeditor/ckeditor5-essentials'; import { Paragraph } from '@ckeditor/ckeditor5-paragraph'; +import { Link } from '@ckeditor/ckeditor5-link'; import { BlockQuote } from '@ckeditor/ckeditor5-block-quote'; -import { View, ButtonView, ContextualBalloon, MenuBarMenuListItemButtonView } from '@ckeditor/ckeditor5-ui'; +import { View, ButtonView, ContextualBalloon, MenuBarMenuListItemButtonView, BalloonPanelView, LabelView } from '@ckeditor/ckeditor5-ui'; import { icons } from '@ckeditor/ckeditor5-core'; -import { ClickObserver } from '@ckeditor/ckeditor5-engine'; import { indexOf, isRange, keyCodes } from '@ckeditor/ckeditor5-utils'; import { 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 testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils.js'; import BookmarkFormView from '../src/ui/bookmarkformview.js'; -import BookmarkActionsView from '../src/ui/bookmarkactionsview.js'; import BookmarkEditing from '../src/bookmarkediting.js'; import BookmarkUI from '../src/bookmarkui.js'; +import { WidgetToolbarRepository } from '@ckeditor/ckeditor5-widget'; + const bookmarkIcon = icons.bookmark; describe( 'BookmarkUI', () => { - let editor, element, button, balloon, bookmarkUIFeature, formView, actionsView; + let editor, element, button, balloon, bookmarkUIFeature, formView, widgetToolbarRepository, toolbarView; testUtils.createSinonSandbox(); @@ -35,11 +36,13 @@ describe( 'BookmarkUI', () => { document.body.appendChild( element ); editor = await ClassicTestEditor.create( element, { - plugins: [ BookmarkUI, BookmarkEditing, Essentials, Paragraph, BlockQuote ] + plugins: [ BookmarkUI, BookmarkEditing, Essentials, Paragraph, BlockQuote, Link ] } ); bookmarkUIFeature = editor.plugins.get( BookmarkUI ); balloon = editor.plugins.get( ContextualBalloon ); + widgetToolbarRepository = editor.plugins.get( 'WidgetToolbarRepository' ); + toolbarView = widgetToolbarRepository._toolbarDefinitions.get( 'bookmark' ).view; // There is no point to execute BalloonPanelView attachTo and pin methods so lets override it. testUtils.sinon.stub( balloon.view, 'attachTo' ).returns( {} ); @@ -53,7 +56,7 @@ describe( 'BookmarkUI', () => { } ); it( 'should have proper "requires" value', () => { - expect( BookmarkUI.requires ).to.deep.equal( [ BookmarkEditing, ContextualBalloon ] ); + expect( BookmarkUI.requires ).to.deep.equal( [ BookmarkEditing, ContextualBalloon, WidgetToolbarRepository ] ); } ); it( 'should be correctly named', () => { @@ -77,7 +80,7 @@ describe( 'BookmarkUI', () => { } ); it( 'should not create #actionsView', () => { - expect( bookmarkUIFeature.actionsView ).to.be.null; + expect( bookmarkUIFeature.actionsView ).to.be.undefined; } ); describe( 'the "bookmark" toolbar button', () => { @@ -138,15 +141,446 @@ describe( 'BookmarkUI', () => { } ); it( `should execute ${ featureName } command on model execute event and focus the view`, () => { - const spy = testUtils.sinon.spy( bookmarkUIFeature, '_showUI' ); + const spy = testUtils.sinon.spy( bookmarkUIFeature, '_showFormView' ); button.fire( 'execute' ); sinon.assert.calledOnce( spy ); } ); + + it( 'should toggle the balloon UI with hidden back button (if not updating)', () => { + const updateBookmark = editor.commands.get( 'updateBookmark' ); + + sinon.stub( updateBookmark, 'isEnabled' ).get( () => false ); + button.fire( 'execute' ); + + expect( bookmarkUIFeature.formView.backButtonView.isVisible ).to.be.false; + } ); + + it( 'should toggle the balloon UI with visible back button (if updating)', () => { + const updateBookmark = editor.commands.get( 'updateBookmark' ); + + sinon.stub( updateBookmark, 'isEnabled' ).get( () => true ); + button.fire( 'execute' ); + + expect( bookmarkUIFeature.formView.backButtonView.isVisible ).to.be.true; + } ); } - describe( '_showUI()', () => { + describe( 'bookmark toolbar components', () => { + describe( 'bookmark preview label', () => { + let label; + + beforeEach( () => { + label = editor.ui.componentFactory.create( 'bookmarkPreview' ); + } ); + + it( 'should be a LabelView', () => { + expect( label ).to.be.instanceOf( LabelView ); + } ); + + it( 'should have bookmark preview css classes set', () => { + label.render(); + + expect( label.element.classList.contains( 'ck-bookmark-toolbar__preview' ) ).to.be.true; + } ); + + it( 'should bind text to the UpdateBookmarkCommand value', () => { + const updateBookmarkCommand = editor.commands.get( 'updateBookmark' ); + + updateBookmarkCommand.value = 'foo'; + expect( label.text ).to.equal( 'foo' ); + + updateBookmarkCommand.value = 'bar'; + expect( label.text ).to.equal( 'bar' ); + } ); + } ); + + describe( 'edit bookmark button', () => { + beforeEach( () => { + button = editor.ui.componentFactory.create( 'editBookmark' ); + } ); + + it( 'should be a ButtonView', () => { + expect( button ).to.be.instanceOf( ButtonView ); + } ); + + it( 'should have a label', () => { + expect( button.label ).to.equal( 'Edit bookmark' ); + } ); + + it( 'should have a tooltip', () => { + expect( button.tooltip ).to.be.true; + } ); + + it( 'should have an icon', () => { + expect( button.icon ).to.equal( icons.pencil ); + } ); + + it( 'should bind #isEnabled to the UpdateBookmarkCommand', () => { + const updateBookmarkCommand = editor.commands.get( 'updateBookmark' ); + + updateBookmarkCommand.isEnabled = false; + expect( button.isEnabled ).to.equal( false ); + + updateBookmarkCommand.isEnabled = true; + expect( button.isEnabled ).to.equal( true ); + + updateBookmarkCommand.isEnabled = false; + expect( button.isEnabled ).to.equal( false ); + } ); + + it( 'should toggle the balloon UI with visible back button', () => { + const updateBookmarkCommand = editor.commands.get( 'updateBookmark' ); + + sinon.stub( updateBookmarkCommand, 'isEnabled' ).get( () => true ); + button.fire( 'execute' ); + + expect( bookmarkUIFeature.formView.backButtonView.isVisible ).to.be.true; + } ); + + it( 'should trigger #_showFormView() on execute', () => { + const spy = sinon.stub( bookmarkUIFeature, '_showFormView' ); + + button.fire( 'execute' ); + + sinon.assert.calledOnce( spy ); + } ); + } ); + + describe( 'remove bookmark button', () => { + beforeEach( () => { + button = editor.ui.componentFactory.create( 'removeBookmark' ); + } ); + + it( 'should be a ButtonView', () => { + expect( button ).to.be.instanceOf( ButtonView ); + } ); + + it( 'should have a label', () => { + expect( button.label ).to.equal( 'Remove bookmark' ); + } ); + + it( 'should have a tooltip', () => { + expect( button.tooltip ).to.be.true; + } ); + + it( 'should have an icon', () => { + expect( button.icon ).to.equal( icons.remove ); + } ); + + it( 'should bind #isEnabled to the DeleteCommand', () => { + const deleteCommand = editor.commands.get( 'delete' ); + + deleteCommand.isEnabled = false; + expect( button.isEnabled ).to.equal( false ); + + deleteCommand.isEnabled = true; + expect( button.isEnabled ).to.equal( true ); + + deleteCommand.isEnabled = false; + expect( button.isEnabled ).to.equal( false ); + } ); + + it( 'should trigger DeleteCommand on execute', () => { + const deleteCommand = editor.commands.get( 'delete' ); + const spy = sinon.spy( deleteCommand, 'execute' ); + + button.fire( 'execute' ); + + sinon.assert.calledOnce( spy ); + } ); + + it( 'should return focus to editable after executing a command', () => { + const spy = sinon.spy( editor.editing.view, 'focus' ); + + button.fire( 'execute' ); + + sinon.assert.calledOnce( spy ); + } ); + } ); + } ); + + describe( 'toolbar', () => { + it( 'should use the config.bookmark.toolbar to create items', () => { + // Make sure that toolbar is empty before first show. + expect( toolbarView.items.length ).to.equal( 0 ); + + editor.ui.focusTracker.isFocused = true; + + setModelData( editor.model, '[]' ); + + expect( toolbarView.items ).to.have.length( 4 ); + expect( toolbarView.items.get( 0 ).text ).to.equal( 'foo' ); + expect( toolbarView.items.get( 2 ).label ).to.equal( 'Edit bookmark' ); + expect( toolbarView.items.get( 3 ).label ).to.equal( 'Remove bookmark' ); + } ); + + it( 'should set proper CSS classes', () => { + const spy = sinon.spy( balloon, 'add' ); + + editor.ui.focusTracker.isFocused = true; + + setModelData( editor.model, '[]' ); + + sinon.assert.calledWithMatch( spy, sinon.match( ( { balloonClassName, view } ) => { + return view === toolbarView && balloonClassName === 'ck-toolbar-container'; + } ) ); + } ); + + it( 'should set aria-label attribute', () => { + toolbarView.render(); + + expect( toolbarView.element.getAttribute( 'aria-label' ) ).to.equal( 'Bookmark toolbar' ); + + toolbarView.destroy(); + } ); + + it( 'should override the default balloon position to match the form view positions', () => { + const spy = sinon.spy( balloon, 'add' ); + editor.ui.focusTracker.isFocused = true; + + setModelData( editor.model, '[]' ); + + const bookmarkElement = editor.editing.view.getDomRoot().querySelector( 'a' ); + const defaultPositions = BalloonPanelView.defaultPositions; + + sinon.assert.calledOnce( spy ); + + sinon.assert.calledWithExactly( spy, { + view: toolbarView, + position: { + target: bookmarkElement, + positions: [ + defaultPositions.southArrowNorth, + defaultPositions.southArrowNorthMiddleWest, + defaultPositions.southArrowNorthMiddleEast, + defaultPositions.southArrowNorthWest, + defaultPositions.southArrowNorthEast, + defaultPositions.northArrowSouth, + defaultPositions.northArrowSouthMiddleWest, + defaultPositions.northArrowSouthMiddleEast, + defaultPositions.northArrowSouthWest, + defaultPositions.northArrowSouthEast, + defaultPositions.viewportStickyNorth + ] + }, + balloonClassName: 'ck-toolbar-container' + } ); + } ); + + describe( 'integration with the editor selection', () => { + beforeEach( () => { + editor.ui.focusTracker.isFocused = true; + } ); + + it( 'should show the toolbar on ui#update when the bookmark is selected', () => { + setModelData( editor.model, '[]' ); + + expect( balloon.visibleView ).to.be.null; + + editor.ui.fire( 'update' ); + + expect( balloon.visibleView ).to.be.null; + + editor.model.change( writer => { + writer.setSelection( + writer.createRangeOn( editor.model.document.getRoot().getChild( 0 ).getChild( 0 ) ) + ); + } ); + + expect( balloon.visibleView ).to.equal( toolbarView ); + + // Make sure successive change does not throw, e.g. attempting + // to insert the toolbar twice. + editor.ui.fire( 'update' ); + expect( balloon.visibleView ).to.equal( toolbarView ); + } ); + + it( 'should hide the toolbar on ui#update if the bookmark is de–selected', () => { + setModelData( editor.model, '[]' ); + + expect( balloon.visibleView ).to.equal( toolbarView ); + + editor.model.change( writer => { + writer.setSelection( + writer.createPositionAt( editor.model.document.getRoot().getChild( 0 ), 0 ) + ); + } ); + + expect( balloon.visibleView ).to.be.null; + + // Make sure successive change does not throw, e.g. attempting + // to remove the toolbar twice. + editor.ui.fire( 'update' ); + expect( balloon.visibleView ).to.be.null; + } ); + } ); + } ); + + describe( 'link ui integration', () => { + let linkUI, t, linkCommand; + + beforeEach( () => { + linkUI = editor.plugins.get( 'LinkUI' ); + linkCommand = editor.commands.get( 'link' ); + + t = editor.locale.t; + } ); + + it( 'should register proper link provider', () => { + const found = linkUI._linksProviders.find( provider => provider.label === t( 'Bookmarks' ) ); + + expect( found.emptyListPlaceholder ).to.equal( t( 'No bookmarks available.' ) ); + expect( found.navigate ).to.be.a( 'function' ); + expect( found.getItem ).to.be.instanceOf( Function ); + expect( found.getListItems ).to.be.instanceOf( Function ); + } ); + + it( 'should be able to open "Bookmark" tab in the link panel even if the list is empty', () => { + linkUI._showUI(); + + const button = clickNthLinksProvider( 0 ); + + expect( button ).not.to.be.undefined; + expect( button.label ).to.equal( t( 'Bookmarks' ) ); + + expectedShownItems( [] ); + expectShownEmptyPlaceholder( t( 'No bookmarks available.' ) ); + } ); + + it( 'should be able to open "Bookmark" tab in the link panel with single item', () => { + setModelData( editor.model, '[]' ); + + linkUI._showUI(); + clickNthLinksProvider( 0 ); + + expectedShownItems( [ + { label: 'foo', icon: icons.bookmarkMedium } + ] ); + } ); + + it( 'should show bookmark items that are ordered alphabetically', () => { + setModelData( editor.model, + 'f[o]o' + + '' + + '' + + '' + + '' + ); + + linkUI._showUI(); + clickNthLinksProvider( 0 ); + + expectedShownItems( [ + { label: 'aaa', icon: icons.bookmarkMedium }, + { label: 'ccc', icon: icons.bookmarkMedium }, + { label: 'zzz', icon: icons.bookmarkMedium } + ] ); + } ); + + it( 'should show proper icon and tooltip in link preview button', () => { + setModelData( editor.model, + 'f[o]o' + + '' + + '' + ); + + const button = editor.ui.componentFactory.create( 'linkPreview' ); + + linkCommand.value = '#zzz'; + + expect( button.icon ).to.equal( icons.bookmarkSmall ); + expect( button.tooltip ).to.equal( t( 'Scroll to bookmark' ) ); + + button.destroy(); + + linkCommand.value = '#other_non_bookmark'; + + expect( button.icon ).not.to.be.equal( icons.bookmarkSmall ); + expect( button.tooltip ).not.to.be.equal( t( 'Scroll to bookmark' ) ); + } ); + + it( 'should scroll to the bookmark when the link preview button is clicked', () => { + setModelData( editor.model, + 'f[o]o' + + '' + + '' + ); + + const button = editor.ui.componentFactory.create( 'linkPreview' ); + const scrollStub = sinon.stub( editor.editing.view, 'scrollToTheSelection' ); + + linkCommand.value = '#zzz'; + button.render(); + button.element.dispatchEvent( new Event( 'click' ) ); + + const selectedElement = editor.model.document.selection.getSelectedElement(); + + expect( selectedElement.is( 'element', 'bookmark' ) ).to.be.true; + expect( selectedElement.getAttribute( 'bookmarkId' ) ).to.equal( 'zzz' ); + expect( scrollStub.calledOnce ).to.be.true; + } ); + + it( 'should perform default browser action if tried to scroll to non-existing bookmark', () => { + setModelData( editor.model, + 'f[o]o' + + '' + + '' + ); + + const bookmarkEditing = editor.plugins.get( 'BookmarkEditing' ); + const button = editor.ui.componentFactory.create( 'linkPreview' ); + const scrollStub = sinon.stub( editor.editing.view, 'scrollToTheSelection' ); + + // Let's assume that command somehow managed to be set to non-existing bookmark. + linkCommand.value = '#zzz'; + sinon + .stub( bookmarkEditing, 'getElementForBookmarkId' ) + .returns( null ); + + button.render(); + button.element.dispatchEvent( new Event( 'click' ) ); + + expect( scrollStub.calledOnce ).to.be.false; + } ); + + function clickNthLinksProvider( nth ) { + const providersList = linkUI.formView + .template.children[ 0 ] + .find( child => child.template.attributes.class.includes( 'ck-link-form__providers-list' ) ); + + expect( providersList ).not.to.be.undefined; + + const button = providersList + .template.children[ 0 ] + .get( nth ) // li + .template.children[ 0 ] + .get( 0 ); // button + + button.fire( 'execute' ); + return button; + } + + function expectShownEmptyPlaceholder( placeholder ) { + const emptyListInformation = linkUI.linkProviderItemsView.emptyListInformation; + + expect( emptyListInformation.element.innerText ).to.equal( placeholder ); + } + + function expectedShownItems( expectedItems ) { + const items = Array + .from( linkUI.linkProviderItemsView.listChildren ) + .map( child => ( { + label: child.label, + icon: child.icon + } ) ); + + expect( items ).to.be.deep.equal( expectedItems ); + } + } ); + + describe( '_showFormView()', () => { let balloonAddSpy; beforeEach( () => { @@ -157,7 +591,7 @@ describe( 'BookmarkUI', () => { it( 'should create #formView', () => { setModelData( editor.model, 'f[o]o' ); - bookmarkUIFeature._showUI(); + bookmarkUIFeature._showFormView(); expect( bookmarkUIFeature.formView ).to.be.instanceOf( BookmarkFormView ); } ); @@ -165,17 +599,17 @@ describe( 'BookmarkUI', () => { it( 'should not throw if the UI is already visible', () => { setModelData( editor.model, 'f[o]o' ); - bookmarkUIFeature._showUI(); + bookmarkUIFeature._showFormView(); expect( () => { - bookmarkUIFeature._showUI(); + bookmarkUIFeature._showFormView(); } ).to.not.throw(); } ); it( 'should add #formView to the balloon and attach the balloon to the selection when text fragment is selected', () => { setModelData( editor.model, 'f[o]o' ); - bookmarkUIFeature._showUI(); + bookmarkUIFeature._showFormView(); formView = bookmarkUIFeature.formView; const expectedRange = getMarkersRange( editor ); @@ -195,7 +629,7 @@ describe( 'BookmarkUI', () => { const insertBookmark = editor.commands.get( 'insertBookmark' ); const updateBookmark = editor.commands.get( 'updateBookmark' ); - bookmarkUIFeature._showUI(); + bookmarkUIFeature._showFormView(); const idInputView = bookmarkUIFeature.formView.idInputView; insertBookmark.isEnabled = true; @@ -215,12 +649,12 @@ describe( 'BookmarkUI', () => { expect( idInputView.isEnabled ).to.equal( false ); } ); - it( 'should bind buttonView #isEnabled to insert and update command', () => { + it( 'should bind saveButtonView #isEnabled to insert and update command', () => { const insertBookmark = editor.commands.get( 'insertBookmark' ); const updateBookmark = editor.commands.get( 'updateBookmark' ); - bookmarkUIFeature._showUI(); - const buttonView = bookmarkUIFeature.formView.buttonView; + bookmarkUIFeature._showFormView(); + const buttonView = bookmarkUIFeature.formView.saveButtonView; insertBookmark.isEnabled = true; updateBookmark.isEnabled = true; @@ -239,52 +673,30 @@ describe( 'BookmarkUI', () => { expect( buttonView.isEnabled ).to.equal( false ); } ); - it( 'should add #actionsView to the balloon and attach the balloon to the bookmark element when selected', () => { + it( 'should add #formView to the balloon when bookmark is selected and bookmark toolbar is already visible', () => { setModelData( editor.model, '[]' ); const bookmarkElement = editor.editing.view.getDomRoot().querySelector( 'a' ); - bookmarkUIFeature._showUI(); - formView = bookmarkUIFeature.formView; - actionsView = bookmarkUIFeature.actionsView; - - expect( balloon.visibleView ).to.equal( actionsView ); + editor.ui.update(); - const addSpyCallArgs = balloonAddSpy.firstCall.args[ 0 ]; - - expect( addSpyCallArgs.view ).to.equal( actionsView ); - expect( addSpyCallArgs.position.target ).to.be.a( 'function' ); - expect( addSpyCallArgs.position.target() ).to.equal( bookmarkElement ); - } ); - - it( 'should add #formView to the balloon when bookmark is selected and #actionsView is already visible', () => { - setModelData( editor.model, '[]' ); - const bookmarkElement = editor.editing.view.getDomRoot().querySelector( 'a' ); - - bookmarkUIFeature._showUI(); - formView = bookmarkUIFeature.formView; - actionsView = bookmarkUIFeature.actionsView; - - expect( balloon.visibleView ).to.equal( actionsView ); + expect( balloon.visibleView ).to.equal( toolbarView ); const addSpyFirstCallArgs = balloonAddSpy.firstCall.args[ 0 ]; - expect( addSpyFirstCallArgs.view ).to.equal( actionsView ); - expect( addSpyFirstCallArgs.position.target ).to.be.a( 'function' ); - expect( addSpyFirstCallArgs.position.target() ).to.equal( bookmarkElement ); + expect( addSpyFirstCallArgs.view ).to.equal( toolbarView ); - bookmarkUIFeature._showUI(); + bookmarkUIFeature._showFormView(); const addSpyCallSecondCallArgs = balloonAddSpy.secondCall.args[ 0 ]; - expect( addSpyCallSecondCallArgs.view ).to.equal( formView ); + expect( addSpyCallSecondCallArgs.view ).to.equal( bookmarkUIFeature.formView ); expect( addSpyCallSecondCallArgs.position.target ).to.be.a( 'function' ); expect( addSpyCallSecondCallArgs.position.target() ).to.equal( bookmarkElement ); } ); - it( 'should optionally force `main` stack to be visible', () => { + it( 'should force `main` stack to be visible', () => { bookmarkUIFeature._createViews(); formView = bookmarkUIFeature.formView; - actionsView = bookmarkUIFeature.actionsView; formView.render(); setModelData( editor.model, 'f[o]o' ); @@ -294,7 +706,7 @@ describe( 'BookmarkUI', () => { stackId: 'secondary' } ); - bookmarkUIFeature._showUI( true ); + bookmarkUIFeature._showFormView(); expect( balloon.visibleView ).to.equal( formView ); } ); @@ -302,18 +714,16 @@ describe( 'BookmarkUI', () => { it( 'should update balloon position when is switched in rotator to a visible panel', () => { bookmarkUIFeature._createViews(); formView = bookmarkUIFeature.formView; - actionsView = bookmarkUIFeature.actionsView; formView.render(); setModelData( editor.model, 'fo[]ar' ); - bookmarkUIFeature._showUI(); const customView = new View(); const BookmarkViewElement = editor.editing.view.document.getRoot().getChild( 0 ).getChild( 1 ); const BookmarkDomElement = editor.editing.view.domConverter.mapViewToDom( BookmarkViewElement ); - expect( balloon.visibleView ).to.equal( actionsView ); - expect( balloon.view.pin.lastCall.args[ 0 ].target() ).to.equal( BookmarkDomElement ); + expect( balloon.visibleView ).to.equal( toolbarView ); + expect( balloon.view.pin.lastCall.args[ 0 ].target ).to.equal( BookmarkDomElement ); balloon.add( { stackId: 'custom', @@ -324,25 +734,24 @@ describe( 'BookmarkUI', () => { balloon.showStack( 'custom' ); expect( balloon.visibleView ).to.equal( customView ); - expect( balloon.hasView( actionsView ) ).to.equal( true ); + expect( balloon.hasView( toolbarView ) ).to.equal( true ); editor.execute( 'blockQuote' ); balloon.showStack( 'main' ); - expect( balloon.visibleView ).to.equal( actionsView ); + expect( balloon.visibleView ).to.equal( toolbarView ); expect( balloon.hasView( customView ) ).to.equal( true ); - expect( balloon.view.pin.lastCall.args[ 0 ].target() ).to.not.equal( BookmarkDomElement ); + expect( balloon.view.pin.lastCall.args[ 0 ].target ).to.not.equal( BookmarkDomElement ); const newBookmarkViewElement = editor.editing.view.document.getRoot().getChild( 0 ).getChild( 0 ).getChild( 1 ); const newBookmarkDomElement = editor.editing.view.domConverter.mapViewToDom( newBookmarkViewElement ); - expect( balloon.view.pin.lastCall.args[ 0 ].target() ).to.equal( newBookmarkDomElement ); + expect( balloon.view.pin.lastCall.args[ 0 ].target ).to.equal( newBookmarkDomElement ); } ); - it( 'should optionally force `main` stack to be visible while bookmark is selected', () => { + it( 'should force `main` stack to be visible while bookmark is selected', () => { bookmarkUIFeature._createViews(); formView = bookmarkUIFeature.formView; - actionsView = bookmarkUIFeature.actionsView; formView.render(); setModelData( editor.model, 'fo[]ar' ); @@ -352,15 +761,15 @@ describe( 'BookmarkUI', () => { stackId: 'secondary' } ); - bookmarkUIFeature._showUI( true ); + bookmarkUIFeature._showFormView(); - expect( balloon.visibleView ).to.equal( actionsView ); + expect( balloon.visibleView ).to.equal( formView ); } ); it( 'should add #formView to the balloon and attach the balloon to the marker element when selection is collapsed', () => { // (#7926) setModelData( editor.model, 'f[]oo' ); - bookmarkUIFeature._showUI(); + bookmarkUIFeature._showFormView(); formView = bookmarkUIFeature.formView; const expectedRange = getMarkersRange( editor ); @@ -379,7 +788,7 @@ describe( 'BookmarkUI', () => { const executeSpy = testUtils.sinon.spy( editor, 'execute' ); setModelData( editor.model, '[]' ); - bookmarkUIFeature._showUI(); + bookmarkUIFeature._showFormView(); formView = bookmarkUIFeature.formView; const id = 'new_id'; @@ -400,27 +809,26 @@ describe( 'BookmarkUI', () => { setModelData( editor.model, '[foo]' ); - bookmarkUIFeature._showUI(); + bookmarkUIFeature._showFormView(); formView.idInputView.fieldView.value = 'id_1'; expect( updateSpy ).not.to.be.called; formView.fire( 'submit' ); - expect( updateSpy ).to.be.calledOnce; + expect( updateSpy ).to.be.called; } ); describe( 'form status', () => { - it( 'should update ui on error due to change ballon position', () => { + it( 'should update ui on error due to change balloon position', () => { const updateSpy = sinon.spy( editor.ui, 'update' ); bookmarkUIFeature._createViews(); formView = bookmarkUIFeature.formView; - actionsView = bookmarkUIFeature.actionsView; formView.render(); setModelData( editor.model, '[]' ); - bookmarkUIFeature._showUI(); + bookmarkUIFeature._showFormView(); formView.idInputView.fieldView.value = 'name with space'; @@ -432,11 +840,10 @@ describe( 'BookmarkUI', () => { it( 'should show error form status if passed bookmark name is empty', () => { bookmarkUIFeature._createViews(); formView = bookmarkUIFeature.formView; - actionsView = bookmarkUIFeature.actionsView; formView.render(); setModelData( editor.model, '[]' ); - bookmarkUIFeature._showUI(); + bookmarkUIFeature._showFormView(); formView.idInputView.fieldView.value = ''; @@ -448,12 +855,11 @@ describe( 'BookmarkUI', () => { it( 'should show error form status if passed bookmark name containing spaces', () => { bookmarkUIFeature._createViews(); formView = bookmarkUIFeature.formView; - actionsView = bookmarkUIFeature.actionsView; formView.render(); setModelData( editor.model, '[]' ); - bookmarkUIFeature._showUI(); + bookmarkUIFeature._showFormView(); formView.idInputView.fieldView.value = 'name with space'; @@ -465,11 +871,10 @@ describe( 'BookmarkUI', () => { it( 'should show error form status if passed bookmark name already exists', () => { bookmarkUIFeature._createViews(); formView = bookmarkUIFeature.formView; - actionsView = bookmarkUIFeature.actionsView; formView.render(); setModelData( editor.model, '[]' ); - bookmarkUIFeature._showUI(); + bookmarkUIFeature._showFormView(); formView.idInputView.fieldView.value = 'foo'; @@ -481,11 +886,10 @@ describe( 'BookmarkUI', () => { it( 'should reset form status on show', () => { bookmarkUIFeature._createViews(); formView = bookmarkUIFeature.formView; - actionsView = bookmarkUIFeature.actionsView; formView.render(); setModelData( editor.model, '[]' ); - bookmarkUIFeature._showUI(); + bookmarkUIFeature._showFormView(); formView.idInputView.fieldView.value = 'name with space'; @@ -493,8 +897,8 @@ describe( 'BookmarkUI', () => { expect( formView.idInputView.errorText ).to.be.equal( 'Bookmark name cannot contain space characters.' ); - bookmarkUIFeature._hideUI(); - bookmarkUIFeature._showUI(); + bookmarkUIFeature._hideFormView(); + bookmarkUIFeature._showFormView(); expect( formView.idInputView.errorText ).to.be.null; } ); } ); @@ -503,21 +907,24 @@ describe( 'BookmarkUI', () => { it( 'should not duplicate #update listeners', () => { setModelData( editor.model, 'f[]oo' ); + expect( balloon.visibleView ).to.equal( toolbarView ); + const spy = testUtils.sinon.stub( balloon, 'updatePosition' ).returns( {} ); - bookmarkUIFeature._showUI(); + bookmarkUIFeature._showFormView(); editor.ui.fire( 'update' ); - bookmarkUIFeature._hideUI(); + bookmarkUIFeature._hideFormView(); - bookmarkUIFeature._showUI(); + bookmarkUIFeature._showFormView(); editor.ui.fire( 'update' ); - sinon.assert.calledTwice( spy ); + + sinon.assert.calledThrice( spy ); } ); it( 'updates the position of the panel – creating a new bookmark, then the selection moved', () => { setModelData( editor.model, 'f[o]o' ); - bookmarkUIFeature._showUI(); + bookmarkUIFeature._showFormView(); const spy = testUtils.sinon.stub( balloon, 'updatePosition' ).returns( {} ); const root = editor.model.document.getRoot(); @@ -538,12 +945,11 @@ describe( 'BookmarkUI', () => { it( 'not update the position when is in not visible stack (bookmark selected)', () => { bookmarkUIFeature._createViews(); formView = bookmarkUIFeature.formView; - actionsView = bookmarkUIFeature.actionsView; formView.render(); setModelData( editor.model, '[]' ); - bookmarkUIFeature._showUI(); + bookmarkUIFeature._showFormView(); const customView = new View(); @@ -556,7 +962,7 @@ describe( 'BookmarkUI', () => { balloon.showStack( 'custom' ); expect( balloon.visibleView ).to.equal( customView ); - expect( balloon.hasView( actionsView ) ).to.equal( true ); + expect( balloon.hasView( toolbarView ) ).to.equal( true ); const spy = testUtils.sinon.spy( balloon, 'updatePosition' ); @@ -571,7 +977,7 @@ describe( 'BookmarkUI', () => { setModelData( editor.model, 'f[]oo' ); - bookmarkUIFeature._showUI(); + bookmarkUIFeature._showFormView(); const customView = new View(); @@ -595,10 +1001,10 @@ describe( 'BookmarkUI', () => { it( 'hides of the panel – editing a bookmark, then the selection moved out of the bookmark', () => { setModelData( editor.model, '[]bar' ); - bookmarkUIFeature._showUI(); + bookmarkUIFeature._showFormView(); const spyUpdate = testUtils.sinon.stub( balloon, 'updatePosition' ).returns( {} ); - const spyHide = testUtils.sinon.spy( bookmarkUIFeature, '_hideUI' ); + const spyHide = testUtils.sinon.spy( bookmarkUIFeature, '_hideFormView' ); const root = editor.model.document.getRoot(); @@ -614,10 +1020,10 @@ describe( 'BookmarkUI', () => { it( 'hides the panel – editing a bookmark, then the selection expands', () => { setModelData( editor.model, '[]foo' ); - bookmarkUIFeature._showUI(); + bookmarkUIFeature._showFormView(); const spyUpdate = testUtils.sinon.stub( balloon, 'updatePosition' ).returns( {} ); - const spyHide = testUtils.sinon.spy( bookmarkUIFeature, '_hideUI' ); + const spyHide = testUtils.sinon.spy( bookmarkUIFeature, '_hideFormView' ); const root = editor.model.document.getRoot(); @@ -636,10 +1042,10 @@ describe( 'BookmarkUI', () => { it( 'hides the panel – creating a new bookmark, then the selection moved to another parent', () => { setModelData( editor.model, 'f[]oobar' ); - bookmarkUIFeature._showUI(); + bookmarkUIFeature._showFormView(); const spyUpdate = testUtils.sinon.stub( balloon, 'updatePosition' ).returns( {} ); - const spyHide = testUtils.sinon.spy( bookmarkUIFeature, '_hideUI' ); + const spyHide = testUtils.sinon.spy( bookmarkUIFeature, '_hideFormView' ); const root = editor.model.document.getRoot(); @@ -661,7 +1067,7 @@ describe( 'BookmarkUI', () => { it( 'should be displayed when a text fragment is selected', () => { setModelData( editor.model, 'f[o]o' ); - bookmarkUIFeature._showUI(); + bookmarkUIFeature._showFormView(); expect( editor.model.markers.has( 'bookmark-ui' ) ).to.be.true; @@ -682,7 +1088,7 @@ describe( 'BookmarkUI', () => { 'of the empty block in the multiline selection', () => { setModelData( editor.model, '[foo]' ); - bookmarkUIFeature._showUI(); + bookmarkUIFeature._showFormView(); expect( editor.model.markers.has( 'bookmark-ui' ) ).to.be.true; @@ -707,7 +1113,7 @@ describe( 'BookmarkUI', () => { 'of the first block in the multiline selection', () => { setModelData( editor.model, 'foo[bar]' ); - bookmarkUIFeature._showUI(); + bookmarkUIFeature._showFormView(); expect( editor.model.markers.has( 'bookmark-ui' ) ).to.be.true; @@ -737,7 +1143,7 @@ describe( 'BookmarkUI', () => { '' + ']baz' ); - bookmarkUIFeature._showUI(); + bookmarkUIFeature._showFormView(); expect( editor.model.markers.has( 'bookmark-ui' ) ).to.be.true; @@ -775,7 +1181,7 @@ describe( 'BookmarkUI', () => { it( 'should be displayed on a collapsed selection', () => { setModelData( editor.model, 'f[]o' ); - bookmarkUIFeature._showUI(); + bookmarkUIFeature._showFormView(); expect( editor.model.markers.has( 'bookmark-ui' ) ).to.be.true; @@ -800,7 +1206,7 @@ describe( 'BookmarkUI', () => { '' + ']bar' ); - bookmarkUIFeature._showUI(); + bookmarkUIFeature._showFormView(); expect( editor.model.markers.has( 'bookmark-ui' ) ).to.be.true; @@ -828,7 +1234,7 @@ describe( 'BookmarkUI', () => { '' + ']bar' ); - bookmarkUIFeature._showUI(); + bookmarkUIFeature._showFormView(); expect( editor.model.markers.has( 'bookmark-ui' ) ).to.be.true; @@ -857,7 +1263,7 @@ describe( 'BookmarkUI', () => { ']' + 'bar' ); - bookmarkUIFeature._showUI(); + bookmarkUIFeature._showFormView(); expect( editor.model.markers.has( 'bookmark-ui' ) ).to.be.true; @@ -882,29 +1288,6 @@ describe( 'BookmarkUI', () => { } ); } ); - describe( '_addActionsView()', () => { - beforeEach( () => { - editor.editing.view.document.isFocused = true; - } ); - - it( 'should create #actionsView', () => { - setModelData( editor.model, 'f[o]o' ); - - bookmarkUIFeature._addActionsView(); - - expect( bookmarkUIFeature.actionsView ).to.be.instanceOf( BookmarkActionsView ); - } ); - - it( 'should add #actionsView to the balloon and attach the balloon to the bookmark element when selected', () => { - setModelData( editor.model, '[]' ); - - bookmarkUIFeature._addActionsView(); - actionsView = bookmarkUIFeature.actionsView; - - expect( balloon.visibleView ).to.equal( actionsView ); - } ); - } ); - describe( '_addFormView()', () => { beforeEach( () => { editor.editing.view.document.isFocused = true; @@ -940,7 +1323,7 @@ describe( 'BookmarkUI', () => { bookmarkUIFeature._addFormView(); formView = bookmarkUIFeature.formView; - expect( formView.buttonView.label ).to.equal( 'Insert' ); + expect( formView.saveButtonView.label ).to.equal( 'Insert' ); } ); it( 'should have "Insert" label when bookmark is not selected', () => { @@ -949,62 +1332,58 @@ describe( 'BookmarkUI', () => { bookmarkUIFeature._addFormView(); formView = bookmarkUIFeature.formView; - bookmarkUIFeature._showUI(); + bookmarkUIFeature._showFormView(); - expect( formView.buttonView.label ).to.equal( 'Insert' ); + expect( formView.saveButtonView.label ).to.equal( 'Insert' ); } ); - it( 'should have "Update" label when bookmark is selected', () => { + it( 'should have "Save" label when bookmark is selected', () => { setModelData( editor.model, '[]' ); bookmarkUIFeature._addFormView(); formView = bookmarkUIFeature.formView; - bookmarkUIFeature._showUI(); + bookmarkUIFeature._showFormView(); - expect( formView.buttonView.label ).to.equal( 'Update' ); + expect( formView.saveButtonView.label ).to.equal( 'Save' ); } ); - it( 'should have "Update" label when bookmark already inserted but balloon is not closed.', () => { + it( 'should have "Save" label when bookmark already inserted but balloon is not closed.', () => { setModelData( editor.model, 'f[o]o' ); - bookmarkUIFeature._showUI(); + bookmarkUIFeature._showFormView(); formView = bookmarkUIFeature.formView; - actionsView = bookmarkUIFeature.actionsView; - expect( formView.buttonView.label ).to.equal( 'Insert' ); + expect( formView.saveButtonView.label ).to.equal( 'Insert' ); formView.idInputView.fieldView.value = 'new_id'; formView.fire( 'submit' ); - actionsView.fire( 'edit' ); + bookmarkUIFeature._showFormView(); - expect( formView.buttonView.label ).to.equal( 'Update' ); + expect( formView.saveButtonView.label ).to.equal( 'Save' ); } ); } ); } ); - describe( '_hideUI()', () => { + describe( '_hideFormView()', () => { beforeEach( () => { - bookmarkUIFeature._showUI(); + bookmarkUIFeature._showFormView(); formView = bookmarkUIFeature.formView; - actionsView = bookmarkUIFeature.actionsView; } ); it( 'should remove the UI from the balloon', () => { expect( balloon.hasView( formView ) ).to.be.true; - expect( balloon.hasView( actionsView ) ).to.be.true; - bookmarkUIFeature._hideUI(); + bookmarkUIFeature._hideFormView(); expect( balloon.hasView( formView ) ).to.be.false; - expect( balloon.hasView( actionsView ) ).to.be.false; } ); it( 'should focus the `editable` by default', () => { const spy = testUtils.sinon.spy( editor.editing.view, 'focus' ); - bookmarkUIFeature._hideUI(); + bookmarkUIFeature._hideFormView(); // First call is from _removeFormView. sinon.assert.calledTwice( spy ); @@ -1014,16 +1393,16 @@ describe( 'BookmarkUI', () => { const focusSpy = testUtils.sinon.spy( editor.editing.view, 'focus' ); const removeSpy = testUtils.sinon.spy( balloon, 'remove' ); - bookmarkUIFeature._hideUI(); + bookmarkUIFeature._hideFormView(); expect( focusSpy.calledBefore( removeSpy ) ).to.equal( true ); } ); it( 'should not throw an error when views are not in the `balloon`', () => { - bookmarkUIFeature._hideUI(); + bookmarkUIFeature._hideFormView(); expect( () => { - bookmarkUIFeature._hideUI(); + bookmarkUIFeature._hideFormView(); } ).to.not.throw(); } ); @@ -1031,7 +1410,7 @@ describe( 'BookmarkUI', () => { const spy = sinon.spy(); bookmarkUIFeature.listenTo( editor.ui, 'update', spy ); - bookmarkUIFeature._hideUI(); + bookmarkUIFeature._hideFormView(); editor.ui.fire( 'update' ); sinon.assert.notCalled( spy ); @@ -1040,22 +1419,31 @@ describe( 'BookmarkUI', () => { it( 'should clear the fake visual selection from a selected text fragment', () => { expect( editor.model.markers.has( 'bookmark-ui' ) ).to.be.true; - bookmarkUIFeature._hideUI(); + bookmarkUIFeature._hideFormView(); expect( editor.model.markers.has( 'bookmark-ui' ) ).to.be.false; } ); it( 'should not throw if selection includes soft break before text item', () => { - bookmarkUIFeature._hideUI(); + bookmarkUIFeature._hideFormView(); setModelData( editor.model, '[fo]' ); - bookmarkUIFeature._showUI(); + bookmarkUIFeature._showFormView(); expect( () => { - bookmarkUIFeature._hideUI(); + bookmarkUIFeature._hideFormView(); } ).to.not.throw(); } ); + + it( 'should be called when the back button is clicked', () => { + const spy = testUtils.sinon.spy( bookmarkUIFeature, '_hideFormView' ); + + bookmarkUIFeature._showFormView(); + formView.backButtonView.fire( 'execute' ); + + sinon.assert.calledOnce( spy ); + } ); } ); describe( 'keyboard support', () => { @@ -1065,11 +1453,10 @@ describe( 'BookmarkUI', () => { bookmarkUIFeature._createViews(); formView = bookmarkUIFeature.formView; - actionsView = bookmarkUIFeature.actionsView; } ); it( 'should hide the UI after Esc key press (from editor) and not focus the editable', () => { - const spy = testUtils.sinon.spy( bookmarkUIFeature, '_hideUI' ); + const spy = testUtils.sinon.spy( bookmarkUIFeature, '_hideFormView' ); const keyEvtData = { keyCode: keyCodes.esc, preventDefault: sinon.spy(), @@ -1077,62 +1464,14 @@ describe( 'BookmarkUI', () => { }; // Balloon is visible. - bookmarkUIFeature._showUI(); + bookmarkUIFeature._showFormView(); editor.keystrokes.press( keyEvtData ); sinon.assert.calledWithExactly( spy ); } ); - it( 'should focus the the #actionsView on `Tab` key press when #actionsView is visible', () => { - const keyEvtData = { - keyCode: keyCodes.tab, - preventDefault: sinon.spy(), - stopPropagation: sinon.spy() - }; - - const normalPriorityTabCallbackSpy = sinon.spy(); - const highestPriorityTabCallbackSpy = sinon.spy(); - editor.keystrokes.set( 'Tab', normalPriorityTabCallbackSpy ); - editor.keystrokes.set( 'Tab', highestPriorityTabCallbackSpy, { priority: 'highest' } ); - - // Balloon is invisible, form not focused. - actionsView.focusTracker.isFocused = false; - - const spy = sinon.spy( actionsView, 'focus' ); - - editor.keystrokes.press( keyEvtData ); - sinon.assert.notCalled( keyEvtData.preventDefault ); - sinon.assert.notCalled( keyEvtData.stopPropagation ); - sinon.assert.notCalled( spy ); - sinon.assert.calledOnce( normalPriorityTabCallbackSpy ); - sinon.assert.calledOnce( highestPriorityTabCallbackSpy ); - - // Balloon is visible, form focused. - bookmarkUIFeature._showUI(); - testUtils.sinon.stub( bookmarkUIFeature, '_areActionsVisible' ).value( true ); - - actionsView.focusTracker.isFocused = true; - - editor.keystrokes.press( keyEvtData ); - sinon.assert.notCalled( keyEvtData.preventDefault ); - sinon.assert.notCalled( keyEvtData.stopPropagation ); - sinon.assert.notCalled( spy ); - sinon.assert.calledTwice( normalPriorityTabCallbackSpy ); - sinon.assert.calledTwice( highestPriorityTabCallbackSpy ); - - // Balloon is still visible, form not focused. - actionsView.focusTracker.isFocused = false; - - editor.keystrokes.press( keyEvtData ); - sinon.assert.calledOnce( keyEvtData.preventDefault ); - sinon.assert.calledOnce( keyEvtData.stopPropagation ); - sinon.assert.calledOnce( spy ); - sinon.assert.calledTwice( normalPriorityTabCallbackSpy ); - sinon.assert.calledThrice( highestPriorityTabCallbackSpy ); - } ); - it( 'should hide the UI after Esc key press when form has focus', () => { - const spy = testUtils.sinon.spy( bookmarkUIFeature, '_hideUI' ); + const spy = testUtils.sinon.spy( bookmarkUIFeature, '_hideFormView' ); const keyEvtData = { keyCode: keyCodes.esc, preventDefault: sinon.spy(), @@ -1142,7 +1481,7 @@ describe( 'BookmarkUI', () => { bookmarkUIFeature._createViews(); formView = bookmarkUIFeature.formView; - bookmarkUIFeature._showUI(); + bookmarkUIFeature._showFormView(); bookmarkUIFeature._removeFormView(); formView.keystrokes.press( keyEvtData ); @@ -1151,7 +1490,7 @@ describe( 'BookmarkUI', () => { } ); it( 'should not hide the UI after Esc key press (from editor) when UI is open but is not visible', () => { - const spy = testUtils.sinon.spy( bookmarkUIFeature, '_hideUI' ); + const spy = testUtils.sinon.spy( bookmarkUIFeature, '_hideFormView' ); const keyEvtData = { keyCode: keyCodes.esc, preventDefault: () => {}, @@ -1164,7 +1503,7 @@ describe( 'BookmarkUI', () => { destroy: () => {} }; - bookmarkUIFeature._showUI(); + bookmarkUIFeature._showFormView(); // Some view precedes the bookmark UI in the balloon. balloon.add( { view: viewMock } ); @@ -1178,159 +1517,28 @@ describe( 'BookmarkUI', () => { beforeEach( () => { // Make sure that forms are lazy initiated. expect( bookmarkUIFeature.formView ).to.be.null; - expect( bookmarkUIFeature.actionsView ).to.be.null; bookmarkUIFeature._createViews(); formView = bookmarkUIFeature.formView; - actionsView = bookmarkUIFeature.actionsView; } ); it( 'should hide the UI and not focus editable upon clicking outside the UI', () => { - const spy = testUtils.sinon.spy( bookmarkUIFeature, '_hideUI' ); - - bookmarkUIFeature._showUI(); - document.body.dispatchEvent( new Event( 'mousedown', { bubbles: true } ) ); - - sinon.assert.calledWithExactly( spy ); - } ); - - it( 'should hide the UI when bookmark is in not currently visible stack', () => { - const spy = testUtils.sinon.spy( bookmarkUIFeature, '_hideUI' ); - - balloon.add( { - view: new View(), - stackId: 'secondary' - } ); - - bookmarkUIFeature._showUI(); - - // Be sure any of bookmark view is not currently visible/ - expect( balloon.visibleView ).to.not.equal( formView ); - expect( balloon.visibleView ).to.not.equal( actionsView ); + const spy = testUtils.sinon.spy( bookmarkUIFeature, '_hideFormView' ); + bookmarkUIFeature._showFormView(); document.body.dispatchEvent( new Event( 'mousedown', { bubbles: true } ) ); sinon.assert.calledWithExactly( spy ); } ); it( 'should not hide the UI upon clicking inside the the UI', () => { - const spy = testUtils.sinon.spy( bookmarkUIFeature, '_hideUI' ); + const spy = testUtils.sinon.spy( bookmarkUIFeature, '_hideFormView' ); - bookmarkUIFeature._showUI(); + bookmarkUIFeature._showFormView(); balloon.view.element.dispatchEvent( new Event( 'mousedown', { bubbles: true } ) ); sinon.assert.notCalled( spy ); } ); - - describe( 'clicking on editable', () => { - let observer, spy; - - beforeEach( () => { - observer = editor.editing.view.getObserver( ClickObserver ); - editor.model.schema.extend( 'bookmark', { allowIn: '$root' } ); - - spy = testUtils.sinon.stub( bookmarkUIFeature, '_showUI' ).returns( {} ); - } ); - - it( 'should show the UI when bookmark element selected', () => { - setModelData( editor.model, '' ); - - observer.fire( 'click', { target: document.body } ); - sinon.assert.calledWithExactly( spy ); - } ); - - it( 'should do nothing when selection is not inside link element', () => { - setModelData( editor.model, '[]' ); - - observer.fire( 'click', { target: {} } ); - sinon.assert.notCalled( spy ); - } ); - } ); - } ); - - describe( 'actions view', () => { - let focusEditableSpy; - - beforeEach( () => { - // Make sure that forms are lazy initiated. - expect( bookmarkUIFeature.formView ).to.be.null; - expect( bookmarkUIFeature.actionsView ).to.be.null; - - bookmarkUIFeature._createViews(); - formView = bookmarkUIFeature.formView; - actionsView = bookmarkUIFeature.actionsView; - - formView.render(); - - focusEditableSpy = testUtils.sinon.spy( editor.editing.view, 'focus' ); - } ); - - it( 'should mark the editor UI as focused when the #actionsView is focused', () => { - bookmarkUIFeature._showUI(); - bookmarkUIFeature._removeFormView(); - - expect( balloon.visibleView ).to.equal( actionsView ); - - editor.ui.focusTracker.isFocused = false; - actionsView.element.dispatchEvent( new Event( 'focus' ) ); - - expect( editor.ui.focusTracker.isFocused ).to.be.true; - } ); - - describe( 'binding', () => { - it( 'should show the #formView on #edit event and select the ID input field', () => { - bookmarkUIFeature._showUI(); - bookmarkUIFeature._removeFormView(); - - const selectSpy = testUtils.sinon.spy( formView.idInputView.fieldView, 'select' ); - actionsView.fire( 'edit' ); - - expect( balloon.visibleView ).to.equal( formView ); - sinon.assert.calledOnce( selectSpy ); - } ); - - it( 'should disable CSS transitions before showing the form to avoid unnecessary animations' + - '(and then enable them again)', () => { - const addSpy = testUtils.sinon.spy( balloon, 'add' ); - const disableCssTransitionsSpy = testUtils.sinon.spy( formView, 'disableCssTransitions' ); - const enableCssTransitionsSpy = testUtils.sinon.spy( formView, 'enableCssTransitions' ); - const selectSpy = testUtils.sinon.spy( formView.idInputView.fieldView, 'select' ); - - actionsView.fire( 'edit' ); - - sinon.assert.callOrder( disableCssTransitionsSpy, addSpy, selectSpy, enableCssTransitionsSpy ); - } ); - - it( 'should hide and focus editable on actionsView#remove event', () => { - bookmarkUIFeature._showUI(); - bookmarkUIFeature._removeFormView(); - - // Removing the form would call the focus spy. - focusEditableSpy.resetHistory(); - actionsView.fire( 'remove' ); - - expect( balloon.visibleView ).to.be.null; - expect( focusEditableSpy.calledOnce ).to.be.true; - } ); - - it( 'should hide after Esc key press', () => { - const keyEvtData = { - keyCode: keyCodes.esc, - preventDefault: sinon.spy(), - stopPropagation: sinon.spy() - }; - - bookmarkUIFeature._showUI(); - bookmarkUIFeature._removeFormView(); - - // Removing the form would call the focus spy. - focusEditableSpy.resetHistory(); - - actionsView.keystrokes.press( keyEvtData ); - expect( balloon.visibleView ).to.equal( null ); - expect( focusEditableSpy.calledOnce ).to.be.true; - } ); - } ); } ); function getMarkersRange( editor ) { diff --git a/packages/ckeditor5-bookmark/tests/ui/bookmarkactionsview.js b/packages/ckeditor5-bookmark/tests/ui/bookmarkactionsview.js deleted file mode 100644 index f46281fe9d9..00000000000 --- a/packages/ckeditor5-bookmark/tests/ui/bookmarkactionsview.js +++ /dev/null @@ -1,225 +0,0 @@ -/** - * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. - * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options - */ - -/* globals document */ - -import BookmarkActionsView from '../../src/ui/bookmarkactionsview.js'; -import { ButtonView, LabelView, ViewCollection, FocusCycler } from '@ckeditor/ckeditor5-ui'; -import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard.js'; -import { KeystrokeHandler, FocusTracker } from '@ckeditor/ckeditor5-utils'; - -import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils.js'; - -describe( 'BookmarkActionsView', () => { - let view; - - testUtils.createSinonSandbox(); - - beforeEach( () => { - view = new BookmarkActionsView( { t: val => val } ); - view.render(); - document.body.appendChild( view.element ); - } ); - - afterEach( () => { - view.element.remove(); - view.destroy(); - } ); - - describe( 'constructor()', () => { - it( 'should create element from template', () => { - expect( view.element.classList.contains( 'ck' ) ).to.true; - expect( view.element.classList.contains( 'ck-bookmark-actions' ) ).to.true; - expect( view.element.classList.contains( 'ck-responsive-form' ) ).to.true; - expect( view.element.getAttribute( 'tabindex' ) ).to.equal( '-1' ); - } ); - - it( 'should create child views', () => { - expect( view.bookmarkPreviewView ).to.be.instanceOf( LabelView ); - expect( view.removeButtonView ).to.be.instanceOf( ButtonView ); - expect( view.editButtonView ).to.be.instanceOf( ButtonView ); - } ); - - it( 'should set `ariaLabelledBy` for `removeButtonView`', () => { - const originalButtonLabelId = view.removeButtonView.labelView.id; - const bookmarkPreviewId = view.bookmarkPreviewView.id; - const concatenatedIds = `${ originalButtonLabelId } ${ bookmarkPreviewId }`; - - expect( view.removeButtonView.ariaLabelledBy ).to.be.equal( concatenatedIds ); - expect( view.removeButtonView.labelView.id ).to.be.equal( originalButtonLabelId ); - } ); - - it( 'should set `ariaLabelledBy` for `editButtonView`', () => { - const originalButtonLabelId = view.editButtonView.labelView.id; - const bookmarkPreviewId = view.bookmarkPreviewView.id; - const concatenatedIds = `${ originalButtonLabelId } ${ bookmarkPreviewId }`; - - expect( view.editButtonView.ariaLabelledBy ).to.be.equal( concatenatedIds ); - expect( view.editButtonView.labelView.id ).to.be.equal( originalButtonLabelId ); - } ); - - it( 'should create #focusTracker instance', () => { - expect( view.focusTracker ).to.be.instanceOf( FocusTracker ); - } ); - - it( 'should create #keystrokes instance', () => { - expect( view.keystrokes ).to.be.instanceOf( KeystrokeHandler ); - } ); - - it( 'should create #_focusCycler instance', () => { - expect( view._focusCycler ).to.be.instanceOf( FocusCycler ); - } ); - - it( 'should create #_focusables view collection', () => { - expect( view._focusables ).to.be.instanceOf( ViewCollection ); - } ); - - it( 'should fire `edit` event on editButtonView#execute', () => { - const spy = sinon.spy(); - - view.on( 'edit', spy ); - - view.editButtonView.fire( 'execute' ); - - expect( spy.calledOnce ).to.true; - } ); - - it( 'should fire `remove` event on removeButtonView#execute', () => { - const spy = sinon.spy(); - - view.on( 'remove', spy ); - - view.removeButtonView.fire( 'execute' ); - - expect( spy.calledOnce ).to.true; - } ); - - describe( 'preview button view', () => { - it( 'has a CSS class', () => { - expect( view.bookmarkPreviewView.element.classList.contains( 'ck-bookmark-actions__preview' ) ).to.be.true; - } ); - - describe( 'bindings', () => { - it( 'binds id attribute to view#label', () => { - expect( view.bookmarkPreviewView.text ).to.be.undefined; - - view.id = 'foo'; - - expect( view.bookmarkPreviewView.text ).to.equal( 'foo' ); - } ); - } ); - } ); - - describe( 'template', () => { - it( 'has child views', () => { - expect( view.template.children[ 0 ] ).to.equal( view.bookmarkPreviewView ); - expect( view.template.children[ 1 ] ).to.equal( view.editButtonView ); - expect( view.template.children[ 2 ] ).to.equal( view.removeButtonView ); - } ); - } ); - } ); - - describe( 'render()', () => { - it( 'should register child views in #_focusables', () => { - expect( view._focusables.map( f => f ) ).to.have.members( [ - view.editButtonView, - view.removeButtonView - ] ); - } ); - - it( 'should register child views\' #element in #focusTracker', () => { - const spy = testUtils.sinon.spy( FocusTracker.prototype, 'add' ); - - const view = new BookmarkActionsView( { t: () => {} } ); - view.render(); - - sinon.assert.calledWithExactly( spy.getCall( 0 ), view.editButtonView.element ); - sinon.assert.calledWithExactly( spy.getCall( 1 ), view.removeButtonView.element ); - - view.destroy(); - } ); - - it( 'starts listening for #keystrokes coming from #element', () => { - const view = new BookmarkActionsView( { t: () => {} } ); - - const spy = sinon.spy( view.keystrokes, 'listenTo' ); - - view.render(); - sinon.assert.calledOnce( spy ); - sinon.assert.calledWithExactly( spy, view.element ); - - view.destroy(); - } ); - - describe( 'activates keyboard navigation for the toolbar', () => { - it( 'so "tab" focuses the next focusable item', () => { - const keyEvtData = { - keyCode: keyCodes.tab, - preventDefault: sinon.spy(), - stopPropagation: sinon.spy() - }; - - // Mock the preview button is focused. - view.focusTracker.isFocused = true; - view.focusTracker.focusedElement = view.bookmarkPreviewView.element; - - const spy = sinon.spy( view.editButtonView, 'focus' ); - - view.keystrokes.press( keyEvtData ); - sinon.assert.calledOnce( keyEvtData.preventDefault ); - sinon.assert.calledOnce( keyEvtData.stopPropagation ); - sinon.assert.calledOnce( spy ); - } ); - - it( 'so "shift + tab" focuses the previous focusable item', () => { - const keyEvtData = { - keyCode: keyCodes.tab, - shiftKey: true, - preventDefault: sinon.spy(), - stopPropagation: sinon.spy() - }; - - // Mock the edit button is focused. - view.focusTracker.isFocused = true; - view.focusTracker.focusedElement = view.editButtonView.element; - - const spy = sinon.spy( view.removeButtonView, 'focus' ); - - view.keystrokes.press( keyEvtData ); - sinon.assert.calledOnce( keyEvtData.preventDefault ); - sinon.assert.calledOnce( keyEvtData.stopPropagation ); - sinon.assert.calledOnce( spy ); - } ); - } ); - } ); - - describe( 'destroy()', () => { - it( 'should destroy the FocusTracker instance', () => { - const destroySpy = sinon.spy( view.focusTracker, 'destroy' ); - - view.destroy(); - - sinon.assert.calledOnce( destroySpy ); - } ); - - it( 'should destroy the KeystrokeHandler instance', () => { - const destroySpy = sinon.spy( view.keystrokes, 'destroy' ); - - view.destroy(); - - sinon.assert.calledOnce( destroySpy ); - } ); - } ); - - describe( 'focus()', () => { - it( 'focuses the #editButtonView', () => { - const spy = sinon.spy( view.editButtonView, 'focus' ); - - view.focus(); - - sinon.assert.calledOnce( spy ); - } ); - } ); -} ); diff --git a/packages/ckeditor5-bookmark/tests/ui/bookmarkformview.js b/packages/ckeditor5-bookmark/tests/ui/bookmarkformview.js index 8e8d236d78e..8053445d9d8 100644 --- a/packages/ckeditor5-bookmark/tests/ui/bookmarkformview.js +++ b/packages/ckeditor5-bookmark/tests/ui/bookmarkformview.js @@ -12,6 +12,7 @@ import KeystrokeHandler from '@ckeditor/ckeditor5-utils/src/keystrokehandler.js' import FocusTracker from '@ckeditor/ckeditor5-utils/src/focustracker.js'; import FocusCycler from '@ckeditor/ckeditor5-ui/src/focuscycler.js'; import FormHeaderView from '@ckeditor/ckeditor5-ui/src/formheader/formheaderview.js'; +import FormRowView from '@ckeditor/ckeditor5-ui/src/formrow/formrowview.js'; import ViewCollection from '@ckeditor/ckeditor5-ui/src/viewcollection.js'; import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils.js'; @@ -34,28 +35,43 @@ describe( 'BookmarkFormView', () => { describe( 'constructor()', () => { it( 'should create element from template', () => { expect( view.element.classList.contains( 'ck' ) ).to.true; - expect( view.element.classList.contains( 'ck-bookmark-view' ) ).to.true; + expect( view.element.classList.contains( 'ck-form' ) ).to.true; + expect( view.element.classList.contains( 'ck-responsive-form' ) ).to.true; + expect( view.element.classList.contains( 'ck-bookmark-form' ) ).to.true; expect( view.element.getAttribute( 'tabindex' ) ).to.equal( '-1' ); } ); it( 'should create child views', () => { expect( view.idInputView ).to.be.instanceOf( View ); - expect( view.buttonView ).to.be.instanceOf( View ); + expect( view.saveButtonView ).to.be.instanceOf( View ); + expect( view.backButtonView ).to.be.instanceOf( View ); - expect( view.buttonView.element.classList.contains( 'ck-button-action' ) ).to.be.true; - expect( view.buttonView.element.classList.contains( 'ck-button-bold' ) ).to.be.true; + expect( view.saveButtonView.element.classList.contains( 'ck-button-action' ) ).to.be.true; + expect( view.saveButtonView.element.classList.contains( 'ck-button-bold' ) ).to.be.true; expect( view.children.get( 0 ) ).to.be.instanceOf( FormHeaderView ); expect( view.children.get( 1 ) ).to.be.instanceOf( View ); - const formContentView = view.children.get( 1 ); + const formRowView = view.children.get( 1 ); - expect( formContentView.element.classList.contains( 'ck' ) ).to.true; - expect( formContentView.element.classList.contains( 'ck-bookmark-form' ) ).to.true; - expect( formContentView.element.classList.contains( 'ck-responsive-form' ) ).to.true; + expect( formRowView ).to.be.instanceOf( FormRowView ); + expect( formRowView.element.classList.contains( 'ck' ) ).to.true; + expect( formRowView.element.classList.contains( 'ck-form__row' ) ).to.true; + expect( formRowView.element.classList.contains( 'ck-form__row_with-submit' ) ).to.true; + expect( formRowView.element.classList.contains( 'ck-form__row_large-top-padding' ) ).to.true; + expect( formRowView.children.get( 0 ) ).to.equal( view.idInputView ); + expect( formRowView.children.get( 1 ) ).to.equal( view.saveButtonView ); - expect( formContentView.template.children[ 0 ].get( 0 ) ).to.equal( view.idInputView ); - expect( formContentView.template.children[ 0 ].get( 1 ) ).to.equal( view.buttonView ); + const formHeaderView = view.children.get( 0 ); + + expect( formHeaderView.element.classList.contains( 'ck' ) ).to.true; + expect( formHeaderView.element.classList.contains( 'ck-form__header' ) ).to.true; + expect( formHeaderView.children.get( 0 ) ).to.equal( view.backButtonView ); + } ); + + it( 'should create back button view with proper classes', () => { + expect( view.backButtonView.element.classList.contains( 'ck-button' ) ).to.be.true; + expect( view.backButtonView.element.classList.contains( 'ck-button-back' ) ).to.be.true; } ); it( 'should create #focusTracker instance', () => { @@ -70,6 +86,14 @@ describe( 'BookmarkFormView', () => { expect( view._focusCycler ).to.be.instanceOf( FocusCycler ); } ); + it( 'should fire `cancel` event on backButtonView#execute', () => { + const spy = sinon.spy(); + view.on( 'cancel', spy ); + view.backButtonView.fire( 'execute' ); + + sinon.assert.calledOnce( spy ); + } ); + it( 'should create #_focusables view collection', () => { expect( view._focusables ).to.be.instanceOf( ViewCollection ); } ); @@ -86,8 +110,9 @@ describe( 'BookmarkFormView', () => { describe( 'render()', () => { it( 'should register child views in #_focusables', () => { expect( view._focusables.map( f => f ) ).to.have.members( [ + view.backButtonView, view.idInputView, - view.buttonView + view.saveButtonView ] ); } ); @@ -98,8 +123,9 @@ describe( 'BookmarkFormView', () => { view.render(); - sinon.assert.calledWithExactly( spy.getCall( 0 ), view.idInputView.element ); - sinon.assert.calledWithExactly( spy.getCall( 1 ), view.buttonView.element ); + sinon.assert.calledWithExactly( spy.getCall( 0 ), view.backButtonView.element ); + sinon.assert.calledWithExactly( spy.getCall( 1 ), view.idInputView.element ); + sinon.assert.calledWithExactly( spy.getCall( 2 ), view.saveButtonView.element ); view.destroy(); } ); @@ -128,7 +154,7 @@ describe( 'BookmarkFormView', () => { view.focusTracker.isFocused = true; view.focusTracker.focusedElement = view.idInputView.element; - const spy = sinon.spy( view.buttonView, 'focus' ); + const spy = sinon.spy( view.saveButtonView, 'focus' ); view.keystrokes.press( keyEvtData ); sinon.assert.calledOnce( keyEvtData.preventDefault ); @@ -148,7 +174,7 @@ describe( 'BookmarkFormView', () => { view.focusTracker.isFocused = true; view.focusTracker.focusedElement = view.idInputView.element; - const spy = sinon.spy( view.buttonView, 'focus' ); + const spy = sinon.spy( view.backButtonView, 'focus' ); view.keystrokes.press( keyEvtData ); sinon.assert.calledOnce( keyEvtData.preventDefault ); diff --git a/packages/ckeditor5-bookmark/theme/bookmark.css b/packages/ckeditor5-bookmark/theme/bookmark.css index dc81ee53785..e770d7e4759 100644 --- a/packages/ckeditor5-bookmark/theme/bookmark.css +++ b/packages/ckeditor5-bookmark/theme/bookmark.css @@ -2,49 +2,3 @@ * Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options */ - -:root { - --ck-bookmark-icon-hover-fill-color: var(--ck-color-widget-hover-border); - --ck-bookmark-icon-selected-fill-color: var(--ck-color-focus-border); - --ck-bookmark-icon-animation-duration: var(--ck-widget-handler-animation-duration); - --ck-bookmark-icon-animation-curve: var(--ck-widget-handler-animation-curve); -} - -.ck-bookmark { - &.ck-widget { - outline: none; - - & .ck-bookmark__icon .ck-icon__fill { - transition: fill var(--ck-bookmark-icon-animation-duration) var(--ck-bookmark-icon-animation-curve); - } - - &:hover { - & .ck-bookmark__icon .ck-icon__fill { - fill: var(--ck-bookmark-icon-hover-fill-color); - } - } - - &.ck-widget_selected { - .ck-bookmark__icon .ck-icon__fill { - fill: var(--ck-bookmark-icon-selected-fill-color); - } - } - - &.ck-widget_selected, - &.ck-widget_selected:hover { - outline: none; - } - - & .ck-bookmark__icon { - position: relative; - /* To make it align with text baseline. */ - top: -0.1em; - - & .ck-icon { - height: 1.2em; - width: auto; - vertical-align: middle; - } - } - } -} diff --git a/packages/ckeditor5-bookmark/theme/bookmarkactions.css b/packages/ckeditor5-bookmark/theme/bookmarkactions.css deleted file mode 100644 index 1998bb9538a..00000000000 --- a/packages/ckeditor5-bookmark/theme/bookmarkactions.css +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. - * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options - */ - -@import "@ckeditor/ckeditor5-ui/theme/mixins/_rwd.css"; -@import "@ckeditor/ckeditor5-ui/theme/mixins/_unselectable.css"; - -.ck.ck-bookmark-actions { - display: flex; - align-items: center; - - & .ck-bookmark-actions__preview { - max-width: var(--ck-input-width); - min-width: 3em; - font-weight: normal; - text-overflow: ellipsis; - text-align: center; - overflow: hidden; - - @mixin ck-unselectable; - cursor: default; - } - - @mixin ck-media-phone { - display: flex; - flex-wrap: wrap; - - & .ck-bookmark-actions__preview { - flex-basis: 100%; - margin: var(--ck-spacing-standard) var(--ck-spacing-standard) 0; - min-width: auto; - } - } - - &.ck-responsive-form { - & .ck-button { - @mixin ck-media-phone { - flex-basis: 50%; - margin-top: var(--ck-spacing-standard); - } - } - } -} diff --git a/packages/ckeditor5-bookmark/theme/bookmarkform.css b/packages/ckeditor5-bookmark/theme/bookmarkform.css index 9d725157ecf..e770d7e4759 100644 --- a/packages/ckeditor5-bookmark/theme/bookmarkform.css +++ b/packages/ckeditor5-bookmark/theme/bookmarkform.css @@ -2,41 +2,3 @@ * Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options */ - -@import "@ckeditor/ckeditor5-ui/theme/mixins/_rwd.css"; - -.ck.ck-bookmark-view { - &:focus { - outline: none; - } -} - -.ck.ck-bookmark-form { - display: flex; - align-items: flex-start; - - @mixin ck-media-phone { - flex-wrap: wrap; - - & .ck-button, - & .ck-labeled-field-view { - flex-basis: 100%; - } - - & .ck-button { - justify-content: center; - } - } - - &.ck-responsive-form { - & > .ck-button { - @mixin ck-media-phone { - &:nth-last-child(1) { - margin: var(--ck-spacing-large); - padding: 0 var(--ck-spacing-standard); - border-radius: var(--ck-border-radius); - } - } - } - } -} diff --git a/packages/ckeditor5-bookmark/theme/bookmarktoolbar.css b/packages/ckeditor5-bookmark/theme/bookmarktoolbar.css new file mode 100644 index 00000000000..e770d7e4759 --- /dev/null +++ b/packages/ckeditor5-bookmark/theme/bookmarktoolbar.css @@ -0,0 +1,4 @@ +/* + * Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options + */ diff --git a/packages/ckeditor5-core/lang/contexts.json b/packages/ckeditor5-core/lang/contexts.json index 204c3f280c7..3875b96bbcc 100644 --- a/packages/ckeditor5-core/lang/contexts.json +++ b/packages/ckeditor5-core/lang/contexts.json @@ -29,5 +29,8 @@ "Execute the currently focused button. Executing buttons that interact with the editor content moves the focus back to the content.": "Keystroke description for assistive technologies: keystroke for executing currently focused button.", "Accept": "Label of the button confirming the changes done in the current interface.", "Paragraph": "Dropdown option label for the paragraph format.", - "Color picker": "The label used by assistive technologies describing a button that opens a color picker, where user can choose a configured color for a certain properties (eg.: background color, color, border-color etc.)." + "Color picker": "The label used by assistive technologies describing a button that opens a color picker, where user can choose a configured color for a certain properties (eg.: background color, color, border-color etc.).", + "Insert": "Label for the Insert button.", + "Update": "Label for the Update button.", + "Back": "Label for the Back button." } diff --git a/packages/ckeditor5-core/src/index.ts b/packages/ckeditor5-core/src/index.ts index e8967ef16e7..f6ff0300e4c 100644 --- a/packages/ckeditor5-core/src/index.ts +++ b/packages/ckeditor5-core/src/index.ts @@ -58,6 +58,7 @@ import image from './../theme/icons/image.svg'; import imageUpload from './../theme/icons/image-upload.svg'; import imageAssetManager from './../theme/icons/image-asset-manager.svg'; import imageUrl from './../theme/icons/image-url.svg'; +import settings from './../theme/icons/settings.svg'; import alignBottom from './../theme/icons/align-bottom.svg'; import alignMiddle from './../theme/icons/align-middle.svg'; @@ -124,6 +125,8 @@ import remove from './../theme/icons/remove.svg'; import bookmark from './../theme/icons/bookmark.svg'; import bookmarkInline from './../theme/icons/bookmark_inline.svg'; +import bookmarkSmall from './../theme/icons/bookmark_small.svg'; +import bookmarkMedium from './../theme/icons/bookmark_medium.svg'; export const icons = { bold, @@ -147,6 +150,7 @@ export const icons = { paragraph, plus, text, + settings, alignBottom, alignMiddle, @@ -206,7 +210,9 @@ export const icons = { remove, bookmark, - bookmarkInline + bookmarkInline, + bookmarkSmall, + bookmarkMedium }; import './augmentation.js'; diff --git a/packages/ckeditor5-core/theme/icons/bookmark_medium.svg b/packages/ckeditor5-core/theme/icons/bookmark_medium.svg new file mode 100644 index 00000000000..374a1906387 --- /dev/null +++ b/packages/ckeditor5-core/theme/icons/bookmark_medium.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ckeditor5-core/theme/icons/bookmark_small.svg b/packages/ckeditor5-core/theme/icons/bookmark_small.svg new file mode 100644 index 00000000000..41281a34ada --- /dev/null +++ b/packages/ckeditor5-core/theme/icons/bookmark_small.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ckeditor5-core/theme/icons/settings.svg b/packages/ckeditor5-core/theme/icons/settings.svg new file mode 100644 index 00000000000..fc0ebb84abf --- /dev/null +++ b/packages/ckeditor5-core/theme/icons/settings.svg @@ -0,0 +1 @@ + diff --git a/packages/ckeditor5-image/lang/contexts.json b/packages/ckeditor5-image/lang/contexts.json index 73c4adc2048..1439ab7004d 100644 --- a/packages/ckeditor5-image/lang/contexts.json +++ b/packages/ckeditor5-image/lang/contexts.json @@ -32,7 +32,7 @@ "Image resize list": "The accessibility label of the image resize dropdown for screen readers.", "Insert image via URL": "The input label for the Insert image via URL form.", "Insert via URL": "The label for the insert image via url dropdown button.", - "Image via URL": "The label for the insert image via url menu bar button (standalone button).", + "Image via URL": "The label for the insert image menu bar button and the title of the balloon header.", "Via URL": "The label for the insert image via url menu bar button (inside 'Image' menu).", "Update image URL": "The input label for the Insert image via URL form for a pre-existing image.", "Caption for the image": "Text used by screen readers do describe an image when the image has no text alternative.", @@ -42,5 +42,6 @@ "Uploading image": "Aria status message indicating that the image is being uploaded. Example: 'Uploading image'.", "Image upload complete": "Aria status message indicating that the image has been uploaded successfully. Example: 'Image upload complete'.", "Error during image upload": "Aria status message indicating that an error has occurred during image upload. Example: 'Error during image upload'.", - "Image": "Label for the widget inserted by the image feature (as in 'insert image')" + "Image": "Label for the widget inserted by the image feature (as in 'insert image')", + "Image Resize": "The label for the header of the image resize balloon." } diff --git a/packages/ckeditor5-image/src/imageinsert/imageinsertviaurlui.ts b/packages/ckeditor5-image/src/imageinsert/imageinsertviaurlui.ts index a9f712eb08f..d5422461e5d 100644 --- a/packages/ckeditor5-image/src/imageinsert/imageinsertviaurlui.ts +++ b/packages/ckeditor5-image/src/imageinsert/imageinsertviaurlui.ts @@ -182,9 +182,7 @@ export default class ImageInsertViaUrlUI extends Plugin { dialog.show( { id: 'insertImageViaUrl', - title: this._imageInsertUI.isImageSelected ? - t( 'Update image URL' ) : - t( 'Insert image via URL' ), + title: t( 'Image via URL' ), isModal: true, content: this._formView, actionButtons: [ @@ -194,7 +192,7 @@ export default class ImageInsertViaUrlUI extends Plugin { onExecute: () => dialog.hide() }, { - label: t( 'Accept' ), + label: this._imageInsertUI.isImageSelected ? t( 'Save' ) : t( 'Insert' ), class: 'ck-button-action', withText: true, onExecute: () => this._handleSave() diff --git a/packages/ckeditor5-image/src/imageresize/imagecustomresizeui.ts b/packages/ckeditor5-image/src/imageresize/imagecustomresizeui.ts index e9005b758b9..0106aa391bf 100644 --- a/packages/ckeditor5-image/src/imageresize/imagecustomresizeui.ts +++ b/packages/ckeditor5-image/src/imageresize/imagecustomresizeui.ts @@ -107,12 +107,6 @@ export default class ImageCustomResizeUI extends Plugin { this._hideForm( true ); } ); - // Close the form on Esc key press. - this._form.keystrokes.set( 'Esc', ( data, cancel ) => { - this._hideForm( true ); - cancel(); - } ); - // Close on click outside of balloon panel element. clickOutsideHandler( { emitter: this._form, diff --git a/packages/ckeditor5-image/src/imageresize/imageresizebuttons.ts b/packages/ckeditor5-image/src/imageresize/imageresizebuttons.ts index d3bfccd243e..a0e781759ff 100644 --- a/packages/ckeditor5-image/src/imageresize/imageresizebuttons.ts +++ b/packages/ckeditor5-image/src/imageresize/imageresizebuttons.ts @@ -143,7 +143,11 @@ export default class ImageResizeButtons extends Plugin { } else { const optionValueWithUnit = value ? value + this._resizeUnit : null; - button.bind( 'isOn' ).to( command, 'value', getIsOnButtonCallback( optionValueWithUnit ) ); + button.bind( 'isOn' ).to( + command, 'value', + command, 'isEnabled', + getIsOnButtonCallback( optionValueWithUnit ) + ); this.listenTo( button, 'execute', () => { editor.execute( 'resizeImage', { width: optionValueWithUnit } ); @@ -303,7 +307,11 @@ export default class ImageResizeButtons extends Plugin { const allDropdownValues = map( optionsWithSerializedValues, 'valueWithUnits' ); - definition.model.bind( 'isOn' ).to( command, 'value', getIsOnCustomButtonCallback( allDropdownValues ) ); + definition.model.bind( 'isOn' ).to( + command, 'value', + command, 'isEnabled', + getIsOnCustomButtonCallback( allDropdownValues ) + ); } else { definition = { type: 'button', @@ -317,7 +325,11 @@ export default class ImageResizeButtons extends Plugin { } ) }; - definition.model.bind( 'isOn' ).to( command, 'value', getIsOnButtonCallback( option.valueWithUnits ) ); + definition.model.bind( 'isOn' ).to( + command, 'value', + command, 'isEnabled', + getIsOnButtonCallback( option.valueWithUnits ) + ); } definition.model.bind( 'isEnabled' ).to( command, 'isEnabled' ); @@ -338,9 +350,14 @@ function isCustomImageResizeOption( option: ImageResizeOption ) { /** * A helper function for setting the `isOn` state of buttons in value bindings. */ -function getIsOnButtonCallback( value: string | null ): ( commandValue: unknown ) => boolean { - return ( commandValue: unknown ): boolean => { - const objectCommandValue = commandValue as null | { width: string | null }; +function getIsOnButtonCallback( value: string | null ) { + return ( commandValue: ResizeImageCommand['value'], isEnabled: boolean ): boolean => { + const objectCommandValue = commandValue as null | undefined | { width: string | null }; + + if ( objectCommandValue === undefined || !isEnabled ) { + return false; + } + if ( value === null && objectCommandValue === value ) { return true; } @@ -352,8 +369,8 @@ function getIsOnButtonCallback( value: string | null ): ( commandValue: unknown /** * A helper function for setting the `isOn` state of custom size button in value bindings. */ -function getIsOnCustomButtonCallback( allDropdownValues: Array ): ( commandValue: unknown ) => boolean { - return ( commandValue: unknown ): boolean => !allDropdownValues.some( - dropdownValue => getIsOnButtonCallback( dropdownValue )( commandValue ) +function getIsOnCustomButtonCallback( allDropdownValues: Array ) { + return ( commandValue: ResizeImageCommand['value'], isEnabled: boolean ): boolean => !allDropdownValues.some( + dropdownValue => getIsOnButtonCallback( dropdownValue )( commandValue, isEnabled ) ); } diff --git a/packages/ckeditor5-image/src/imageresize/resizeimagecommand.ts b/packages/ckeditor5-image/src/imageresize/resizeimagecommand.ts index da6a7ac81b1..5844d8f3d78 100644 --- a/packages/ckeditor5-image/src/imageresize/resizeimagecommand.ts +++ b/packages/ckeditor5-image/src/imageresize/resizeimagecommand.ts @@ -17,7 +17,7 @@ export default class ResizeImageCommand extends Command { /** * Desired image width and height. */ - declare public value: null | { + declare public value: undefined | null | { width: string | null; height: string | null; }; diff --git a/packages/ckeditor5-image/src/imageresize/ui/imagecustomresizeformview.ts b/packages/ckeditor5-image/src/imageresize/ui/imagecustomresizeformview.ts index 6f921d1b7ce..0cdc0ac295f 100644 --- a/packages/ckeditor5-image/src/imageresize/ui/imagecustomresizeformview.ts +++ b/packages/ckeditor5-image/src/imageresize/ui/imagecustomresizeformview.ts @@ -10,6 +10,8 @@ import { ButtonView, FocusCycler, + FormHeaderView, + FormRowView, LabeledFieldView, View, ViewCollection, @@ -27,6 +29,8 @@ import '../../../theme/imagecustomresizeform.css'; // See: #8833. // eslint-disable-next-line ckeditor5-rules/ckeditor-imports import '@ckeditor/ckeditor5-ui/theme/components/responsive-form/responsiveform.css'; +// eslint-disable-next-line ckeditor5-rules/ckeditor-imports +import '@ckeditor/ckeditor5-ui/theme/components/form/form.css'; /** * The ImageCustomResizeFormView class. @@ -48,9 +52,9 @@ export default class ImageCustomResizeFormView extends View { public readonly unit: string; /** - * An input with a label. + * The Back button view displayed in the header. */ - public labeledInput: LabeledFieldView; + public backButtonView: ButtonView; /** * A button used to submit the form. @@ -58,9 +62,14 @@ export default class ImageCustomResizeFormView extends View { public saveButtonView: ButtonView; /** - * A button used to cancel the form. + * An input with a label. + */ + public labeledInput: LabeledFieldView; + + /** + * A collection of child views. */ - public cancelButtonView: ButtonView; + public readonly children: ViewCollection; /** * A collection of views which can be focused in the form. @@ -82,22 +91,39 @@ export default class ImageCustomResizeFormView extends View { */ constructor( locale: Locale, unit: string, validators: Array ) { super( locale ); - const t = this.locale!.t; this.focusTracker = new FocusTracker(); this.keystrokes = new KeystrokeHandler(); this.unit = unit; - this.labeledInput = this._createLabeledInputView(); + // Create buttons. + this.backButtonView = this._createBackButton(); + this.saveButtonView = this._createSaveButton(); - this.saveButtonView = this._createButton( t( 'Save' ), icons.check, 'ck-button-save' ); - this.saveButtonView.type = 'submit'; + // Create input fields. + this.labeledInput = this._createLabeledInputView(); - this.cancelButtonView = this._createButton( t( 'Cancel' ), icons.cancel, 'ck-button-cancel', 'cancel' ); + this.children = this.createCollection( [ this._createHeaderView() ] ); + this.children.add( new FormRowView( locale, { + children: [ + this.labeledInput, + this.saveButtonView + ], + class: [ + 'ck-form__row_with-submit', + 'ck-form__row_large-top-padding' + ] + } ) ); this._focusables = new ViewCollection(); this._validators = validators; + // Close the panel on esc key press when the **form has focus**. + this.keystrokes.set( 'Esc', ( data, cancel ) => { + this.fire( 'cancel' ); + cancel(); + } ); + this._focusCycler = new FocusCycler( { focusables: this._focusables, focusTracker: this.focusTracker, @@ -113,9 +139,11 @@ export default class ImageCustomResizeFormView extends View { this.setTemplate( { tag: 'form', + attributes: { class: [ 'ck', + 'ck-form', 'ck-image-custom-resize-form', 'ck-responsive-form' ], @@ -124,11 +152,7 @@ export default class ImageCustomResizeFormView extends View { tabindex: '-1' }, - children: [ - this.labeledInput, - this.saveButtonView, - this.cancelButtonView - ] + children: this.children } ); } @@ -138,18 +162,25 @@ export default class ImageCustomResizeFormView extends View { public override render(): void { super.render(); - this.keystrokes.listenTo( this.element! ); + submitHandler( { + view: this + } ); - submitHandler( { view: this } ); + const childViews = [ + this.backButtonView, + this.labeledInput, + this.saveButtonView + ]; - [ this.labeledInput, this.saveButtonView, this.cancelButtonView ] - .forEach( v => { - // Register the view as focusable. - this._focusables.add( v ); + childViews.forEach( v => { + // Register the view as focusable. + this._focusables.add( v ); - // Register the view in the focus tracker. - this.focusTracker.add( v.element! ); - } ); + // Register the view in the focus tracker. + this.focusTracker.add( v.element! ); + } ); + + this.keystrokes.listenTo( this.element! ); } /** @@ -163,34 +194,54 @@ export default class ImageCustomResizeFormView extends View { } /** - * Creates the button view. - * - * @param label The button label - * @param icon The button's icon. - * @param className The additional button CSS class name. - * @param eventName The event name that the ButtonView#execute event will be delegated to. - * @returns The button view instance. + * Creates a back button view that cancels the form. */ - private _createButton( label: string, icon: string, className: string, eventName?: string ): ButtonView { - const button = new ButtonView( this.locale ); + private _createBackButton(): ButtonView { + const t = this.locale!.t; + const backButton = new ButtonView( this.locale ); - button.set( { - label, - icon, + backButton.set( { + class: 'ck-button-back', + label: t( 'Back' ), + icon: icons.previousArrow, tooltip: true } ); - button.extendTemplate( { - attributes: { - class: className - } + backButton.delegate( 'execute' ).to( this, 'cancel' ); + + return backButton; + } + + /** + * Creates a save button view that resize the image. + */ + private _createSaveButton(): ButtonView { + const t = this.locale!.t; + const saveButton = new ButtonView( this.locale ); + + saveButton.set( { + label: t( 'Save' ), + withText: true, + type: 'submit', + class: 'ck-button-action ck-button-bold' } ); - if ( eventName ) { - button.delegate( 'execute' ).to( this, eventName ); - } + return saveButton; + } + + /** + * Creates a header view for the form. + */ + private _createHeaderView(): FormHeaderView { + const t = this.locale!.t; + + const header = new FormHeaderView( this.locale, { + label: t( 'Image Resize' ) + } ); + + header.children.add( this.backButtonView, 0 ); - return button; + return header; } /** @@ -203,6 +254,7 @@ export default class ImageCustomResizeFormView extends View { const labeledInput = new LabeledFieldView( this.locale, createLabeledInputNumber ); labeledInput.label = t( 'Resize image (in %0)', this.unit ); + labeledInput.class = 'ck-labeled-field-view_full-width'; labeledInput.fieldView.set( { step: 0.1 } ); diff --git a/packages/ckeditor5-image/src/imagetextalternative/imagetextalternativeui.ts b/packages/ckeditor5-image/src/imagetextalternative/imagetextalternativeui.ts index 23d9ee0b303..c4aa688a132 100644 --- a/packages/ckeditor5-image/src/imagetextalternative/imagetextalternativeui.ts +++ b/packages/ckeditor5-image/src/imagetextalternative/imagetextalternativeui.ts @@ -138,12 +138,6 @@ export default class ImageTextAlternativeUI extends Plugin { this._hideForm( true ); } ); - // Close the form on Esc key press. - this._form.keystrokes.set( 'Esc', ( data, cancel ) => { - this._hideForm( true ); - cancel(); - } ); - // Reposition the balloon or hide the form if an image widget is no longer selected. this.listenTo( editor.ui, 'update', () => { if ( !imageUtils.getClosestSelectedImageWidget( viewDocument.selection ) ) { diff --git a/packages/ckeditor5-image/src/imagetextalternative/ui/textalternativeformview.ts b/packages/ckeditor5-image/src/imagetextalternative/ui/textalternativeformview.ts index d75cedc706a..245d4a1a9d1 100644 --- a/packages/ckeditor5-image/src/imagetextalternative/ui/textalternativeformview.ts +++ b/packages/ckeditor5-image/src/imagetextalternative/ui/textalternativeformview.ts @@ -10,6 +10,8 @@ import { ButtonView, FocusCycler, + FormRowView, + FormHeaderView, LabeledFieldView, View, ViewCollection, @@ -26,6 +28,8 @@ import '../../../theme/textalternativeform.css'; // See: #8833. // eslint-disable-next-line ckeditor5-rules/ckeditor-imports import '@ckeditor/ckeditor5-ui/theme/components/responsive-form/responsiveform.css'; +// eslint-disable-next-line ckeditor5-rules/ckeditor-imports +import '@ckeditor/ckeditor5-ui/theme/components/form/form.css'; /** * The TextAlternativeFormView class. @@ -46,15 +50,20 @@ export default class TextAlternativeFormView extends View { */ public labeledInput: LabeledFieldView; + /** + * The Back button view displayed in the header. + */ + public backButtonView: ButtonView; + /** * A button used to submit the form. */ public saveButtonView: ButtonView; /** - * A button used to cancel the form. + * A collection of child views. */ - public cancelButtonView: ButtonView; + public readonly children: ViewCollection; /** * A collection of views which can be focused in the form. @@ -71,21 +80,39 @@ export default class TextAlternativeFormView extends View { */ constructor( locale: Locale ) { super( locale ); - const t = this.locale!.t; this.focusTracker = new FocusTracker(); - this.keystrokes = new KeystrokeHandler(); - this.labeledInput = this._createLabeledInputView(); + // Create buttons. + this.backButtonView = this._createBackButton(); + this.saveButtonView = this._createSaveButton(); - this.saveButtonView = this._createButton( t( 'Save' ), icons.check, 'ck-button-save' ); - this.saveButtonView.type = 'submit'; + // Create input fields. + this.labeledInput = this._createLabeledInputView(); - this.cancelButtonView = this._createButton( t( 'Cancel' ), icons.cancel, 'ck-button-cancel', 'cancel' ); + this.children = this.createCollection( [ + this._createHeaderView() + ] ); + this.children.add( new FormRowView( locale, { + children: [ + this.labeledInput, + this.saveButtonView + ], + class: [ + 'ck-form__row_with-submit', + 'ck-form__row_large-top-padding' + ] + } ) ); this._focusables = new ViewCollection(); + // Close the panel on esc key press when the **form has focus**. + this.keystrokes.set( 'Esc', ( data, cancel ) => { + this.fire( 'cancel' ); + cancel(); + } ); + this._focusCycler = new FocusCycler( { focusables: this._focusables, focusTracker: this.focusTracker, @@ -105,6 +132,7 @@ export default class TextAlternativeFormView extends View { attributes: { class: [ 'ck', + 'ck-form', 'ck-text-alternative-form', 'ck-responsive-form' ], @@ -113,11 +141,7 @@ export default class TextAlternativeFormView extends View { tabindex: '-1' }, - children: [ - this.labeledInput, - this.saveButtonView, - this.cancelButtonView - ] + children: this.children } ); } @@ -127,18 +151,25 @@ export default class TextAlternativeFormView extends View { public override render(): void { super.render(); - this.keystrokes.listenTo( this.element! ); + submitHandler( { + view: this + } ); - submitHandler( { view: this } ); + const childViews = [ + this.backButtonView, + this.labeledInput, + this.saveButtonView + ]; - [ this.labeledInput, this.saveButtonView, this.cancelButtonView ] - .forEach( v => { - // Register the view as focusable. - this._focusables.add( v ); + childViews.forEach( v => { + // Register the view as focusable. + this._focusables.add( v ); + + // Register the view in the focus tracker. + this.focusTracker.add( v.element! ); + } ); - // Register the view in the focus tracker. - this.focusTracker.add( v.element! ); - } ); + this.keystrokes.listenTo( this.element! ); } /** @@ -152,34 +183,54 @@ export default class TextAlternativeFormView extends View { } /** - * Creates the button view. - * - * @param label The button label - * @param icon The button's icon. - * @param className The additional button CSS class name. - * @param eventName The event name that the ButtonView#execute event will be delegated to. - * @returns The button view instance. + * Creates a back button view that cancels the form. */ - private _createButton( label: string, icon: string, className: string, eventName?: string ): ButtonView { - const button = new ButtonView( this.locale ); + private _createBackButton(): ButtonView { + const t = this.locale!.t; + const backButton = new ButtonView( this.locale ); - button.set( { - label, - icon, + backButton.set( { + class: 'ck-button-back', + label: t( 'Back' ), + icon: icons.previousArrow, tooltip: true } ); - button.extendTemplate( { - attributes: { - class: className - } + backButton.delegate( 'execute' ).to( this, 'cancel' ); + + return backButton; + } + + /** + * Creates a save button view that text alternative the image. + */ + private _createSaveButton(): ButtonView { + const t = this.locale!.t; + const saveButton = new ButtonView( this.locale ); + + saveButton.set( { + label: t( 'Save' ), + withText: true, + type: 'submit', + class: 'ck-button-action ck-button-bold' + } ); + + return saveButton; + } + + /** + * Creates a header view for the form. + */ + private _createHeaderView(): FormHeaderView { + const t = this.locale!.t; + + const header = new FormHeaderView( this.locale, { + label: t( 'Text Alternative' ) } ); - if ( eventName ) { - button.delegate( 'execute' ).to( this, eventName ); - } + header.children.add( this.backButtonView, 0 ); - return button; + return header; } /** @@ -192,6 +243,7 @@ export default class TextAlternativeFormView extends View { const labeledInput = new LabeledFieldView( this.locale, createLabeledInputText ); labeledInput.label = t( 'Text alternative' ); + labeledInput.class = 'ck-labeled-field-view_full-width'; return labeledInput; } diff --git a/packages/ckeditor5-image/tests/imageinsert/imageinsertviaurlui.js b/packages/ckeditor5-image/tests/imageinsert/imageinsertviaurlui.js index 08952502377..8b5e9be3b58 100644 --- a/packages/ckeditor5-image/tests/imageinsert/imageinsertviaurlui.js +++ b/packages/ckeditor5-image/tests/imageinsert/imageinsertviaurlui.js @@ -117,7 +117,7 @@ describe( 'ImageInsertViaUrlUI', () => { it( 'has two action buttons', () => { expect( dialog.view.actionsView.children ).to.have.length( 2 ); expect( dialog.view.actionsView.children.get( 0 ).label ).to.equal( 'Cancel' ); - expect( dialog.view.actionsView.children.get( 1 ).label ).to.equal( 'Accept' ); + expect( dialog.view.actionsView.children.get( 1 ).label ).to.equal( 'Insert' ); } ); it( 'has submittable form', () => { @@ -134,14 +134,29 @@ describe( 'ImageInsertViaUrlUI', () => { expect( urlView.isImageSelected ).to.be.false; } ); - it( 'should change title if image is selected', () => { - expect( dialog.view.headerView.label ).to.equal( 'Insert image via URL' ); + it( 'should have a title', () => { + const sinonSpy = sinon.spy( dialog, 'show' ); + dialog.hide(); + openDialog(); + + expect( sinonSpy ).to.have.been.calledWithMatch( { title: 'Image via URL' } ); + } ); + + it( 'should show save button if image is selected', () => { dialog.hide(); insertImageUI.isImageSelected = true; openDialog(); - expect( dialog.view.headerView.label ).to.equal( 'Update image URL' ); + expect( dialog.view.actionsView.children.get( 1 ).label ).to.equal( 'Save' ); + } ); + + it( 'should show insert button if image is not selected', () => { + dialog.hide(); + insertImageUI.isImageSelected = false; + openDialog(); + + expect( dialog.view.actionsView.children.get( 1 ).label ).to.equal( 'Insert' ); } ); it( 'should bind #isEnabled', () => { diff --git a/packages/ckeditor5-image/tests/imageresize/imageresizebuttons.js b/packages/ckeditor5-image/tests/imageresize/imageresizebuttons.js index d0214a420bd..d4fb17c9b4f 100644 --- a/packages/ckeditor5-image/tests/imageresize/imageresizebuttons.js +++ b/packages/ckeditor5-image/tests/imageresize/imageresizebuttons.js @@ -5,6 +5,7 @@ /* global document */ +import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor.js'; import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor.js'; import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph.js'; import Image from '../../src/image.js'; @@ -111,6 +112,108 @@ describe( 'ImageResizeButtons', () => { } ); } ); + describe( 'resize options main toolbar buttons', () => { + let editor; + + beforeEach( async () => { + editor = await ClassicEditor + .create( editorElement, { + plugins: [ Image, ImageStyle, Paragraph, Undo, Table, ImageResizeButtons, ImageCustomResizeUI ], + image: { + resizeUnit: '%', + resizeOptions: [ { + name: 'resizeImage:original', + value: null, + icon: 'original' + }, + { + name: 'resizeImage:custom', + value: 'custom', + icon: 'custom' + }, + { + name: 'resizeImage:25', + value: '25', + icon: 'small' + }, + { + name: 'resizeImage:50', + value: '50', + icon: 'medium' + }, + { + name: 'resizeImage:75', + value: '75', + icon: 'large' + } ] + }, + toolbar: [ 'resizeImage:original', 'resizeImage:custom', 'resizeImage:25', 'resizeImage:50', 'resizeImage:75' ] + } ); + + plugin = editor.plugins.get( 'ImageResizeButtons' ); + } ); + + afterEach( async () => { + if ( editorElement ) { + editorElement.remove(); + } + + if ( editor && editor.state !== 'destroyed' ) { + await editor.destroy(); + } + } ); + + it( 'should register resize options as items in the main toolbar', () => { + const toolbar = editor.ui.view.toolbar; + + expect( toolbar.items.map( item => item.label ) ).to.deep.equal( [ + 'Resize image to the original size', + 'Custom image size', + 'Resize image to 25%', + 'Resize image to 50%', + 'Resize image to 75%' + ] ); + } ); + + it( 'should synchronize button states with command\'s isEnabled property', () => { + const toolbar = editor.ui.view.toolbar; + const resizeCommand = editor.commands.get( 'resizeImage' ); + const resizeComponents = toolbar.items.filter( item => item.label && item.label.includes( 'Resize image' ) ); + + resizeCommand.isEnabled = true; + expect( resizeComponents.every( item => item.isEnabled ) ).to.be.true; + + resizeCommand.isEnabled = false; + expect( resizeComponents.every( item => item.isEnabled ) ).to.be.false; + } ); + + it( 'should properly sync isOn states of buttons', () => { + const toolbar = editor.ui.view.toolbar; + const resizeCommand = editor.commands.get( 'resizeImage' ); + const resizeComponents = toolbar.items.filter( item => item.label && item.label.includes( 'Resize image' ) ); + + resizeCommand.isEnabled = false; + resizeCommand.value = undefined; + + expect( resizeComponents.every( item => item.isOn ) ).to.be.false; + + resizeCommand.isEnabled = false; + expect( resizeComponents.every( item => item.isOn ) ).to.be.false; + + resizeCommand.value = undefined; + resizeCommand.isEnabled = true; + expect( resizeComponents.every( item => item.isOn ) ).to.be.false; + + resizeCommand.value = { width: '50%' }; + resizeCommand.isEnabled = true; + + expect( resizeComponents[ 2 ].isOn ).to.be.true; + + resizeCommand.isEnabled = false; + expect( resizeComponents[ 2 ].isOn ).to.be.false; + } ); + } ); + describe( 'resize options dropdown', () => { it( 'should be disabled when plugin is disabled', () => { const dropdownView = editor.ui.componentFactory.create( 'resizeImage' ); diff --git a/packages/ckeditor5-image/tests/imageresize/ui/imagecustomresizeformview.js b/packages/ckeditor5-image/tests/imageresize/ui/imagecustomresizeformview.js index f2371a972fb..9f8a229ebea 100644 --- a/packages/ckeditor5-image/tests/imageresize/ui/imagecustomresizeformview.js +++ b/packages/ckeditor5-image/tests/imageresize/ui/imagecustomresizeformview.js @@ -14,7 +14,7 @@ import FocusCycler from '@ckeditor/ckeditor5-ui/src/focuscycler.js'; import ViewCollection from '@ckeditor/ckeditor5-ui/src/viewcollection.js'; import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils.js'; -describe( 'ImageColumnResizeFormView', () => { +describe( 'ImageCustomResizeFormView', () => { let view; testUtils.createSinonSandbox(); @@ -28,6 +28,7 @@ describe( 'ImageColumnResizeFormView', () => { view.render(); expect( view.element.classList.contains( 'ck' ) ).to.be.true; + expect( view.element.classList.contains( 'ck-form' ) ).to.be.true; expect( view.element.classList.contains( 'ck-image-custom-resize-form' ) ).to.be.true; expect( view.element.classList.contains( 'ck-responsive-form' ) ).to.be.true; expect( view.element.getAttribute( 'tabindex' ) ).to.equal( '-1' ); @@ -44,12 +45,17 @@ describe( 'ImageColumnResizeFormView', () => { it( 'should create child views', () => { expect( view.labeledInput ).to.be.instanceOf( View ); expect( view.saveButtonView ).to.be.instanceOf( View ); - expect( view.cancelButtonView ).to.be.instanceOf( View ); + expect( view.backButtonView ).to.be.instanceOf( View ); view.render(); + } ); + + it( 'should create header element at the top', () => { + view.render(); + + const header = view.children.first; - expect( view.saveButtonView.element.classList.contains( 'ck-button-save' ) ).to.be.true; - expect( view.cancelButtonView.element.classList.contains( 'ck-button-cancel' ) ).to.be.true; + expect( header.children.last.element.classList.contains( 'ck-form__header__label' ) ).to.be.true; } ); it( 'should create #_focusCycler instance', () => { @@ -60,10 +66,10 @@ describe( 'ImageColumnResizeFormView', () => { expect( view._focusables ).to.be.instanceOf( ViewCollection ); } ); - it( 'should fire `cancel` event on cancelButtonView#execute', () => { + it( 'should fire `cancel` event on backButtonView#execute', () => { const spy = sinon.spy(); view.on( 'cancel', spy ); - view.cancelButtonView.fire( 'execute' ); + view.backButtonView.fire( 'execute' ); sinon.assert.calledOnce( spy ); } ); @@ -83,9 +89,9 @@ describe( 'ImageColumnResizeFormView', () => { view.render(); expect( view._focusables.map( f => f ) ).to.have.members( [ + view.backButtonView, view.labeledInput, - view.saveButtonView, - view.cancelButtonView + view.saveButtonView ] ); } ); @@ -94,9 +100,9 @@ describe( 'ImageColumnResizeFormView', () => { view.render(); - sinon.assert.calledWithExactly( spy.getCall( 0 ), view.labeledInput.element ); - sinon.assert.calledWithExactly( spy.getCall( 1 ), view.saveButtonView.element ); - sinon.assert.calledWithExactly( spy.getCall( 2 ), view.cancelButtonView.element ); + sinon.assert.calledWithExactly( spy.getCall( 0 ), view.backButtonView.element ); + sinon.assert.calledWithExactly( spy.getCall( 1 ), view.labeledInput.element ); + sinon.assert.calledWithExactly( spy.getCall( 2 ), view.saveButtonView.element ); } ); describe( 'activates keyboard navigation in the form', () => { @@ -139,7 +145,7 @@ describe( 'ImageColumnResizeFormView', () => { // Mock the cancel button is focused. view.focusTracker.isFocused = true; - view.focusTracker.focusedElement = view.cancelButtonView.element; + view.focusTracker.focusedElement = view.backButtonView.element; const spy = sinon.spy( view.saveButtonView, 'focus' ); diff --git a/packages/ckeditor5-image/tests/imagetextalternative/ui/textalternativeformview.js b/packages/ckeditor5-image/tests/imagetextalternative/ui/textalternativeformview.js index 5bcdf296932..225662d89d2 100644 --- a/packages/ckeditor5-image/tests/imagetextalternative/ui/textalternativeformview.js +++ b/packages/ckeditor5-image/tests/imagetextalternative/ui/textalternativeformview.js @@ -28,6 +28,7 @@ describe( 'TextAlternativeFormView', () => { view.render(); expect( view.element.classList.contains( 'ck' ) ).to.be.true; + expect( view.element.classList.contains( 'ck-form' ) ).to.be.true; expect( view.element.classList.contains( 'ck-text-alternative-form' ) ).to.be.true; expect( view.element.classList.contains( 'ck-responsive-form' ) ).to.be.true; expect( view.element.getAttribute( 'tabindex' ) ).to.equal( '-1' ); @@ -44,12 +45,9 @@ describe( 'TextAlternativeFormView', () => { it( 'should create child views', () => { expect( view.labeledInput ).to.be.instanceOf( View ); expect( view.saveButtonView ).to.be.instanceOf( View ); - expect( view.cancelButtonView ).to.be.instanceOf( View ); + expect( view.backButtonView ).to.be.instanceOf( View ); view.render(); - - expect( view.saveButtonView.element.classList.contains( 'ck-button-save' ) ).to.be.true; - expect( view.cancelButtonView.element.classList.contains( 'ck-button-cancel' ) ).to.be.true; } ); it( 'should create #_focusCycler instance', () => { @@ -60,10 +58,18 @@ describe( 'TextAlternativeFormView', () => { expect( view._focusables ).to.be.instanceOf( ViewCollection ); } ); - it( 'should fire `cancel` event on cancelButtonView#execute', () => { + it( 'should create header element at the top', () => { + view.render(); + + const header = view.children.first; + + expect( header.children.last.element.classList.contains( 'ck-form__header__label' ) ).to.be.true; + } ); + + it( 'should fire `cancel` event on backButtonView#execute', () => { const spy = sinon.spy(); view.on( 'cancel', spy ); - view.cancelButtonView.fire( 'execute' ); + view.backButtonView.fire( 'execute' ); sinon.assert.calledOnce( spy ); } ); @@ -83,9 +89,9 @@ describe( 'TextAlternativeFormView', () => { view.render(); expect( view._focusables.map( f => f ) ).to.have.members( [ + view.backButtonView, view.labeledInput, - view.saveButtonView, - view.cancelButtonView + view.saveButtonView ] ); } ); @@ -94,9 +100,9 @@ describe( 'TextAlternativeFormView', () => { view.render(); - sinon.assert.calledWithExactly( spy.getCall( 0 ), view.labeledInput.element ); - sinon.assert.calledWithExactly( spy.getCall( 1 ), view.saveButtonView.element ); - sinon.assert.calledWithExactly( spy.getCall( 2 ), view.cancelButtonView.element ); + sinon.assert.calledWithExactly( spy.getCall( 0 ), view.backButtonView.element ); + sinon.assert.calledWithExactly( spy.getCall( 1 ), view.labeledInput.element ); + sinon.assert.calledWithExactly( spy.getCall( 2 ), view.saveButtonView.element ); } ); describe( 'activates keyboard navigation in the form', () => { @@ -139,7 +145,7 @@ describe( 'TextAlternativeFormView', () => { // Mock the cancel button is focused. view.focusTracker.isFocused = true; - view.focusTracker.focusedElement = view.cancelButtonView.element; + view.focusTracker.focusedElement = view.backButtonView.element; const spy = sinon.spy( view.saveButtonView, 'focus' ); diff --git a/packages/ckeditor5-image/tests/manual/imageresizebuttons.js b/packages/ckeditor5-image/tests/manual/imageresizebuttons.js index 90ecdf0150c..287832fcba2 100644 --- a/packages/ckeditor5-image/tests/manual/imageresizebuttons.js +++ b/packages/ckeditor5-image/tests/manual/imageresizebuttons.js @@ -15,6 +15,7 @@ import CloudServices from '@ckeditor/ckeditor5-cloud-services/src/cloudservices. import ImageUpload from '../../src/imageupload.js'; import ImageResizeEditing from '../../src/imageresize/imageresizeediting.js'; import ImageResizeButtons from '../../src/imageresize/imageresizebuttons.js'; +import ImageCustomResizeUI from '../../src/imageresize/imagecustomresizeui.js'; import { CS_CONFIG } from '@ckeditor/ckeditor5-cloud-services/tests/_utils/cloud-services-config.js'; @@ -27,6 +28,7 @@ const commonConfig = { CloudServices, EasyImage, ImageResizeEditing, + ImageCustomResizeUI, ImageResizeButtons ], toolbar: [ 'heading', '|', 'bold', 'italic', 'link', @@ -114,7 +116,14 @@ const imageConfig2 = { const config2 = { ...commonConfig, - image: imageConfig2 + image: imageConfig2, + toolbar: [ + ...commonConfig.toolbar, '|', + 'resizeImage:50', + 'resizeImage:75', + 'resizeImage:original', + 'resizeImage:custom' + ] }; ClassicEditor diff --git a/packages/ckeditor5-image/tests/manual/textalternative.js b/packages/ckeditor5-image/tests/manual/textalternative.js index 32771f39c78..178849c6202 100644 --- a/packages/ckeditor5-image/tests/manual/textalternative.js +++ b/packages/ckeditor5-image/tests/manual/textalternative.js @@ -18,7 +18,7 @@ import ImageToolbar from '../../src/imagetoolbar.js'; ClassicEditor .create( document.querySelector( '#editor' ), { plugins: [ EnterPlugin, TypingPlugin, ParagraphPlugin, HeadingPlugin, ImagePlugin, UndoPlugin, ClipboardPlugin, ImageToolbar ], - toolbar: [ 'heading', '|', 'undo', 'redo' ], + toolbar: [ 'heading', '|', 'undo', 'redo', '|', 'imageTextAlternative' ], image: { toolbar: [ 'imageTextAlternative' ] } diff --git a/packages/ckeditor5-image/theme/imagecustomresizeform.css b/packages/ckeditor5-image/theme/imagecustomresizeform.css index a19a4ff3dcc..e770d7e4759 100644 --- a/packages/ckeditor5-image/theme/imagecustomresizeform.css +++ b/packages/ckeditor5-image/theme/imagecustomresizeform.css @@ -2,32 +2,3 @@ * Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options */ - -@import "@ckeditor/ckeditor5-ui/theme/mixins/_rwd.css"; - -.ck.ck-image-custom-resize-form { - display: flex; - flex-direction: row; - flex-wrap: nowrap; - align-items: flex-start; - - & .ck-labeled-field-view { - display: inline-block; - } - - & .ck-label { - display: none; - } - - @mixin ck-media-phone { - flex-wrap: wrap; - - & .ck-labeled-field-view { - flex-basis: 100%; - } - - & .ck-button { - flex-basis: 50%; - } - } -} diff --git a/packages/ckeditor5-image/theme/textalternativeform.css b/packages/ckeditor5-image/theme/textalternativeform.css index 186244dffe9..e770d7e4759 100644 --- a/packages/ckeditor5-image/theme/textalternativeform.css +++ b/packages/ckeditor5-image/theme/textalternativeform.css @@ -2,31 +2,3 @@ * Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options */ - -@import "@ckeditor/ckeditor5-ui/theme/mixins/_rwd.css"; - -.ck.ck-text-alternative-form { - display: flex; - flex-direction: row; - flex-wrap: nowrap; - - & .ck-labeled-field-view { - display: inline-block; - } - - & .ck-label { - display: none; - } - - @mixin ck-media-phone { - flex-wrap: wrap; - - & .ck-labeled-field-view { - flex-basis: 100%; - } - - & .ck-button { - flex-basis: 50%; - } - } -} diff --git a/packages/ckeditor5-link/docs/_snippets/features/build-link-source.js b/packages/ckeditor5-link/docs/_snippets/features/build-link-source.js index 291df6a34c3..6343449bd7d 100644 --- a/packages/ckeditor5-link/docs/_snippets/features/build-link-source.js +++ b/packages/ckeditor5-link/docs/_snippets/features/build-link-source.js @@ -5,6 +5,10 @@ /* globals window */ +import Plugin from '@ckeditor/ckeditor5-core/src/plugin.js'; +import LinkUI from '@ckeditor/ckeditor5-link/src/linkui.js'; +import Link from '@ckeditor/ckeditor5-link/src/link.js'; +import linkIcon from '@ckeditor/ckeditor5-link/theme/icons/link.svg'; import { AutoLink, LinkImage } from '@ckeditor/ckeditor5-link'; import { Bookmark } from '@ckeditor/ckeditor5-bookmark'; import { CKBox, CKBoxImageEdit } from '@ckeditor/ckeditor5-ckbox'; @@ -14,7 +18,68 @@ import { CS_CONFIG } from '@ckeditor/ckeditor5-cloud-services/tests/_utils/cloud // Umberto combines all `packages/*/docs` into the `docs/` directory. The import path must be valid after merging all directories. import ClassicEditor from '../build-classic.js'; -window.CKEditorPlugins = { AutoLink, Bookmark, PictureEditing, ImageInsert, ImageResize, AutoImage, LinkImage, CKBox, CKBoxImageEdit }; +class SocialLinksPlugin extends Plugin { + static get requires() { + return [ Link ]; + } + + async init() { + const linkUI = this.editor.plugins.get( LinkUI ); + + linkUI.registerLinksListProvider( { + label: 'Social links', + getListItems: () => [ + { + id: 'facebook', + href: 'https://facebook.com', + label: 'Facebook', + icon: linkIcon + }, + { + id: 'twitter', + href: 'https://twitter.com', + label: 'Twitter', + icon: linkIcon + }, + { + id: 'linkedin', + href: 'https://linkedin.com', + label: 'LinkedIn', + icon: linkIcon + }, + { + id: 'instagram', + href: 'https://instagram.com', + label: 'Instagram', + icon: linkIcon + } + ], + + // Optionally: You can customize your link preview by custom implementation of link getter. + getItem: href => { + return { + href, + icon: linkIcon, + label: 'My custom label in link preview', + tooltip: 'My custom tooltip in link preview' + }; + } + } ); + } +} + +window.CKEditorPlugins = { + AutoLink, + Bookmark, + PictureEditing, + ImageInsert, + ImageResize, + AutoImage, + LinkImage, + CKBox, + CKBoxImageEdit, + SocialLinksPlugin +}; window.ClassicEditor = ClassicEditor; window.CS_CONFIG = CS_CONFIG; diff --git a/packages/ckeditor5-link/docs/_snippets/features/link-providers.html b/packages/ckeditor5-link/docs/_snippets/features/link-providers.html new file mode 100644 index 00000000000..417d03c751f --- /dev/null +++ b/packages/ckeditor5-link/docs/_snippets/features/link-providers.html @@ -0,0 +1,9 @@ + diff --git a/packages/ckeditor5-link/docs/_snippets/features/link-providers.js b/packages/ckeditor5-link/docs/_snippets/features/link-providers.js new file mode 100644 index 00000000000..fe429c134d9 --- /dev/null +++ b/packages/ckeditor5-link/docs/_snippets/features/link-providers.js @@ -0,0 +1,47 @@ +/** + * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options + */ + +/* globals console, window, document, ClassicEditor, CS_CONFIG, CKEditorPlugins */ + +import { TOKEN_URL } from '@ckeditor/ckeditor5-ckbox/tests/_utils/ckbox-config.js'; + +ClassicEditor + .create( document.querySelector( '#snippet-link-providers' ), { + cloudServices: CS_CONFIG, + extraPlugins: [ + CKEditorPlugins.AutoLink, + CKEditorPlugins.SocialLinksPlugin + ], + toolbar: { + items: [ + 'undo', 'redo', '|', 'heading', + '|', 'bold', 'italic', + '|', 'link', 'insertImage', 'insertTable', 'mediaEmbed', + '|', 'bulletedList', 'numberedList', 'outdent', 'indent' + ] + }, + ckbox: { + tokenUrl: TOKEN_URL, + forceDemoLabel: true + }, + ui: { + viewportOffset: { + top: window.getViewportTopOffsetConfig() + } + }, + licenseKey: 'GPL' + } ) + .then( editor => { + window.editor = editor; + + window.attachTourBalloon( { + target: window.findToolbarItem( editor.ui.view.toolbar, item => item.label && item.label === 'Link' ), + text: 'Use to access a link list.', + editor + } ); + } ) + .catch( err => { + console.error( err.stack ); + } ); diff --git a/packages/ckeditor5-link/docs/_snippets/features/link.js b/packages/ckeditor5-link/docs/_snippets/features/link.js index 90fcf117998..4c67a66d5ed 100644 --- a/packages/ckeditor5-link/docs/_snippets/features/link.js +++ b/packages/ckeditor5-link/docs/_snippets/features/link.js @@ -35,6 +35,26 @@ ClassicEditor allowExternalImagesEditing: [ /^data:/, 'origin', /ckbox/ ], forceDemoLabel: true }, + link: { + addTargetToExternalLinks: false, + decorators: [ + { + mode: 'manual', + label: 'Downloadable', + attributes: { + download: 'download' + } + }, + { + mode: 'manual', + label: 'Open in a new tab', + attributes: { + target: '_blank', + rel: 'noopener noreferrer' + } + } + ] + }, licenseKey: 'GPL' } ) .then( editor => { diff --git a/packages/ckeditor5-link/docs/features/link.md b/packages/ckeditor5-link/docs/features/link.md index c1269dad100..80ad2b6de62 100644 --- a/packages/ckeditor5-link/docs/features/link.md +++ b/packages/ckeditor5-link/docs/features/link.md @@ -6,11 +6,11 @@ category: features {@snippet features/build-link-source} -The link feature lets you insert hyperlinks into your content and provides a UI to create and edit them. Thanks to the [autolink](#autolink-feature) plugin, typed or pasted URLs and email addresses automatically turn into working links. +The link feature lets you insert hyperlinks into your content and provides a UI to create and edit them. Thanks to the [autolink](#autolink-feature) plugin, typed or pasted URLs and email addresses automatically become working links. ## Demo -Use the link toolbar button {@icon @ckeditor/ckeditor5-link/theme/icons/link.svg Link} or press Ctrl/Cmd+K to create a new link. Clicking a link opens a contextual toolbar. The toolbar lets you edit existing links {@icon @ckeditor/ckeditor5-core/theme/icons/pencil.svg Edit link} or unlink them {@icon @ckeditor/ckeditor5-link/theme/icons/unlink.svg Unlink} with a click. +Use the link toolbar button {@icon @ckeditor/ckeditor5-link/theme/icons/link.svg Link} or press Ctrl/Cmd+K to create a new link. Clicking a link opens a contextual toolbar. The toolbar lets you edit existing links {@icon @ckeditor/ckeditor5-core/theme/icons/pencil.svg Edit link}, change their properties {@icon @ckeditor/ckeditor5-core/theme/icons/settings.svg Link properties}, or unlink them {@icon @ckeditor/ckeditor5-link/theme/icons/unlink.svg Unlink} with a click. {@snippet features/link} @@ -20,18 +20,16 @@ Use the link toolbar button {@icon @ckeditor/ckeditor5-link/theme/icons/link.svg ## Typing around links -CKEditor 5 allows for typing both at the inner and outer boundaries of links to make editing easier for the users. +CKEditor 5 allows typing at the inner and outer link boundaries to make editing simpler for the users. **To type inside a link**, move the caret to its (start or end) boundary. As long as the link remains highlighted (by default: blue), typing and applying formatting happens within its boundaries: {@img assets/img/typing-inside.gif 770 The animation shows typing inside the link in CKEditor 5 rich text editor.} -**To type before or after a link**, move the caret to its boundary, then press the Arrow key ( or ) away from the link once. The link is no longer highlighted and whatever text you type or formatting you apply will not be inside the link: +**To type before or after a link**, move the caret to its boundary, then press the Arrow key ( or ) away from the link once. The link stops from being highlighted, and whatever text you type or formatting apply will not be inside the link: {@img assets/img/typing-before.gif 770 The animation shows typing before the link in CKEditor 5 rich text editor.} - - ## Installation @@ -75,7 +73,7 @@ There are two types of link decorators you can use: ### Demo -In the editor below, all **external** links get the `target="_blank"` and `rel="noopener noreferrer"` attributes ([automatic decorator](#adding-attributes-to-links-based-on-predefined-rules-automatic-decorators)). Click a link and edit it {@icon @ckeditor/ckeditor5-core/theme/icons/pencil.svg Edit link} to see that you can control the `download` attribute of specific links using the switch button in the editing balloon ([manual decorator](#adding-attributes-to-links-using-the-ui-manual-decorators)). Take a look at the editor data below (updated live) to see the extra link attributes. +In the editor below, all **external** links get the `target="_blank"` and `rel="noopener noreferrer"` attributes ([automatic decorator](#adding-attributes-to-links-based-on-predefined-rules-automatic-decorators)). Click a link and check its properties {@icon @ckeditor/ckeditor5-core/theme/icons/settings.svg Link properties} to see that you can control the `download` attribute of specific links using the switch button in the editing balloon ([manual decorator](#adding-attributes-to-links-using-the-ui-manual-decorators)). Take a look at the editor data below (updated live) to see the extra link attributes. {@snippet features/linkdecorators} @@ -233,7 +231,7 @@ ClassicEditor ``` - Please keep in mind that you customize this list at your own risk – adding unsafe protocols like `javascript` can lead to serious security vulnerabilities! + Please remember that you customize this list at your own risk – adding unsafe protocols like `javascript` can lead to serious security vulnerabilities! #### Adding attributes to links based on predefined rules (automatic decorators) @@ -310,11 +308,65 @@ The {@link module:link/autolink~AutoLink `AutoLink`} feature will automatically To use the autolink function, press Space, Enter, or Shift+Enter after a link. - You can always revert autolinking by the undo feature (Ctrl/Cmd+Z). + You can always revert autolinking by the undo feature (Ctrl/Cmd+Z) or the backspace () button. {@snippet features/autolink} +## Link providers + +### Demo + +The link feature integrates with {@link features/bookmarks bookmarks}, providing a smooth linking experience. Link providers allow adding predefined lists of links (similar to bookmarks). You can register any links you would like to use frequently in your content in the Link plugin using a custom method. These links will be available in the link UI form for easy access. + +{@snippet features/link-providers} + +### Configuration + +To define a list of links available in the link UI, you can use the `getListItems()` function in the link provider. Opening the group in the link UI calls the function every time. The function should return an array of objects, each representing a link. + +The `getItem()` function can resolve the link. Opening the link preview or navigating to the link from the editing view calls the function. It should return an object with the `href`, `icon`, `label`, and `tooltip` parameters. The properties are assigned to the link preview. It is useful when only part of the list items are shown in the list view (for example, in pagination), and the user interacts with a link that is not visible in the list view but is available in the editing. + +```js +class SocialLinksPlugin extends Plugin { + static get requires() { + return [ Link ]; + } + + async init() { + const linkUI = this.editor.plugins.get( LinkUI ); + + linkUI.registerLinksListProvider( { + label: 'Social links', + getListItems: () => [ + { + id: 'facebook', + href: 'https://facebook.com', + label: 'Facebook', + icon: linkIcon + }, + { + id: 'twitter', + href: 'https://twitter.com', + label: 'Twitter', + icon: linkIcon + } + ], + + // Optionally: You can customize your link preview by custom implementation of link getter. + getItem: href => { + return { + href, + icon: linkIcon, + label: 'My custom label in link preview', + tooltip: 'My custom tooltip in link preview' + }; + } + } ); + } +} +``` + ## Common API The {@link module:link/link~Link} plugin registers the UI button component (`'link'`) and the following commands: diff --git a/packages/ckeditor5-link/lang/contexts.json b/packages/ckeditor5-link/lang/contexts.json index 989e5c22248..8d91df7f0e2 100644 --- a/packages/ckeditor5-link/lang/contexts.json +++ b/packages/ckeditor5-link/lang/contexts.json @@ -4,6 +4,7 @@ "Link URL": "Label for the URL input in the Link URL editing balloon.", "Link URL must not be empty.": "An error text displayed when user attempted to enter an empty URL.", "Link image": "Label for the image link button.", + "Link properties": "Label for the link properties link balloon title.", "Edit link": "Button opening the Link URL editing balloon.", "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).", @@ -11,5 +12,6 @@ "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." + "Move out of a link": "Keystroke description for assistive technologies: keystroke for moving out of a link.", + "Displayed text": "The label of the input field for the displayed text of the link." } diff --git a/packages/ckeditor5-link/package.json b/packages/ckeditor5-link/package.json index 870df7334fd..48aa7c0bafc 100644 --- a/packages/ckeditor5-link/package.json +++ b/packages/ckeditor5-link/package.json @@ -26,7 +26,6 @@ "devDependencies": { "@ckeditor/ckeditor5-basic-styles": "44.1.0", "@ckeditor/ckeditor5-block-quote": "44.1.0", - "@ckeditor/ckeditor5-bookmark": "44.1.0", "@ckeditor/ckeditor5-cloud-services": "44.1.0", "@ckeditor/ckeditor5-code-block": "44.1.0", "@ckeditor/ckeditor5-dev-utils": "^45.0.0", diff --git a/packages/ckeditor5-link/src/index.ts b/packages/ckeditor5-link/src/index.ts index fc5d0fee2f8..2815a422f7f 100644 --- a/packages/ckeditor5-link/src/index.ts +++ b/packages/ckeditor5-link/src/index.ts @@ -9,12 +9,11 @@ export { default as Link } from './link.js'; export { default as LinkEditing } from './linkediting.js'; -export { default as LinkUI } from './linkui.js'; +export { default as LinkUI, type LinksProviderListItem, type LinksProvider, type LinksProviderDetailedItem } from './linkui.js'; export { default as LinkImage } from './linkimage.js'; export { default as LinkImageEditing } from './linkimageediting.js'; export { default as LinkImageUI } from './linkimageui.js'; export { default as AutoLink } from './autolink.js'; -export { default as LinkActionsView } from './ui/linkactionsview.js'; export { default as LinkFormView } from './ui/linkformview.js'; export { default as LinkCommand } from './linkcommand.js'; export { default as UnlinkCommand } from './unlinkcommand.js'; diff --git a/packages/ckeditor5-link/src/linkcommand.ts b/packages/ckeditor5-link/src/linkcommand.ts index ebf02ac8f15..ce6f9079d86 100644 --- a/packages/ckeditor5-link/src/linkcommand.ts +++ b/packages/ckeditor5-link/src/linkcommand.ts @@ -9,11 +9,11 @@ import { Command } from 'ckeditor5/src/core.js'; import { findAttributeRange } from 'ckeditor5/src/typing.js'; -import { Collection, first, toMap } from 'ckeditor5/src/utils.js'; -import type { Range, DocumentSelection, Model, Writer } from 'ckeditor5/src/engine.js'; +import { Collection, diff, first, toMap } from 'ckeditor5/src/utils.js'; +import { LivePosition, type Range, type Item } from 'ckeditor5/src/engine.js'; import AutomaticDecorators from './utils/automaticdecorators.js'; -import { isLinkableElement } from './utils.js'; +import { extractTextFromLinkRange, isLinkableElement } from './utils.js'; import type ManualDecorator from './utils/manualdecorator.js'; /** @@ -135,13 +135,33 @@ export default class LinkCommand extends Command { * **Note**: {@link module:link/unlinkcommand~UnlinkCommand#execute `UnlinkCommand#execute()`} removes all * decorator attributes. * + * An optional parameter called `displayedText` is to add or update text of the link that represents the `href`. For example: + * ```ts + * const linkCommand = editor.commands.get( 'link' ); + * + * // Adding a new link with `displayedText` attribute. + * linkCommand.execute( 'http://example.com', {}, 'Example' ); + * ``` + * + * The above code will create an anchor like this: + * + * ```html + * Example + * ``` + * * @fires execute * @param href Link destination. * @param manualDecoratorIds The information about manual decorator attributes to be applied or removed upon execution. + * @param displayedText Text of the link. */ - public override execute( href: string, manualDecoratorIds: Record = {} ): void { + public override execute( + href: string, + manualDecoratorIds: Record = {}, + displayedText?: string + ): void { const model = this.editor.model; const selection = model.document.selection; + // Stores information about manual decorators to turn them on/off when command is applied. const truthyManualDecorators: Array = []; const falsyManualDecorators: Array = []; @@ -155,32 +175,104 @@ export default class LinkCommand extends Command { } model.change( writer => { + const updateLinkAttributes = ( itemOrRange: Item | Range ): void => { + writer.setAttribute( 'linkHref', href, itemOrRange ); + + truthyManualDecorators.forEach( item => writer.setAttribute( item, true, itemOrRange ) ); + falsyManualDecorators.forEach( item => writer.removeAttribute( item, itemOrRange ) ); + }; + + const updateLinkTextIfNeeded = ( range: Range, linkHref?: string ): Range | undefined => { + const linkText = extractTextFromLinkRange( range ); + + if ( !linkText ) { + return range; + } + + // Make a copy not to override the command param value. + let newText = displayedText; + + if ( !newText ) { + // Replace the link text with the new href if previously href was equal to text. + // For example: `http://ckeditor.com/`. + newText = linkHref && linkHref == linkText ? href : linkText; + } + + // Only if needed. + if ( newText != linkText ) { + const changes = findChanges( linkText, newText ); + let insertsLength = 0; + + for ( const { offset, actual, expected } of changes ) { + const updatedOffset = offset + insertsLength; + const subRange = writer.createRange( + range.start.getShiftedBy( updatedOffset ), + range.start.getShiftedBy( updatedOffset + actual.length ) + ); + + // Collect formatting attributes from replaced text. + const textNode = getLinkPartTextNode( subRange, range )!; + const attributes = textNode.getAttributes(); + const formattingAttributes = Array + .from( attributes ) + .filter( ( [ key ] ) => model.schema.getAttributeProperties( key ).isFormatting ); + + // Create a new text node. + const newTextNode = writer.createText( expected, formattingAttributes ); + + // Set link attributes before inserting to document to avoid Differ attributes edge case. + updateLinkAttributes( newTextNode ); + + // Replace text with formatting. + model.insertContent( newTextNode, subRange ); + + // Sum of all previous inserts. + insertsLength += expected.length; + } + + return writer.createRange( range.start, range.start.getShiftedBy( newText.length ) ); + } + }; + + const collapseSelectionAtLinkEnd = ( linkRange: Range ): void => { + const { plugins } = this.editor; + + writer.setSelection( linkRange.end ); + + if ( plugins.has( 'TwoStepCaretMovement' ) ) { + // After replacing the text of the link, we need to move the caret to the end of the link, + // override it's gravity to forward to prevent keeping e.g. bold attribute on the caret + // which was previously inside the link. + // + // If the plugin is not available, the caret will be placed at the end of the link and the + // bold attribute will be kept even if command moved caret outside the link. + plugins.get( 'TwoStepCaretMovement' )._handleForwardMovement(); + } else { + // Remove the `linkHref` attribute and all link decorators from the selection. + // It stops adding a new content into the link element. + for ( const key of [ 'linkHref', ...truthyManualDecorators, ...falsyManualDecorators ] ) { + writer.removeSelectionAttribute( key ); + } + } + }; + // If selection is collapsed then update selected link or insert new one at the place of caret. if ( selection.isCollapsed ) { const position = selection.getFirstPosition()!; // When selection is inside text with `linkHref` attribute. if ( selection.hasAttribute( 'linkHref' ) ) { - const linkText = extractTextFromSelection( selection ); - // Then update `linkHref` value. - let linkRange = findAttributeRange( position, 'linkHref', selection.getAttribute( 'linkHref' ), model ); - - if ( selection.getAttribute( 'linkHref' ) === linkText ) { - linkRange = this._updateLinkContent( model, writer, linkRange, href ); - } - - writer.setAttribute( 'linkHref', href, linkRange ); + const linkHref = selection.getAttribute( 'linkHref' ) as string; + const linkRange = findAttributeRange( position, 'linkHref', linkHref, model ); + const newLinkRange = updateLinkTextIfNeeded( linkRange, linkHref ); - truthyManualDecorators.forEach( item => { - writer.setAttribute( item, true, linkRange ); - } ); + updateLinkAttributes( newLinkRange || linkRange ); - falsyManualDecorators.forEach( item => { - writer.removeAttribute( item, linkRange ); - } ); - - // Put the selection at the end of the updated link. - writer.setSelection( writer.createPositionAfter( linkRange.end.nodeBefore! ) ); + // Put the selection at the end of the updated link only when text was changed. + // When text was not altered we keep the original selection. + if ( newLinkRange ) { + collapseSelectionAtLinkEnd( newLinkRange ); + } } // If not then insert text node with `linkHref` attribute in place of caret. // However, since selection is collapsed, attribute value will be used as data for text node. @@ -194,22 +286,19 @@ export default class LinkCommand extends Command { attributes.set( item, true ); } ); - const { end: positionAfter } = model.insertContent( writer.createText( href, attributes ), position ); + const newLinkRange = model.insertContent( writer.createText( displayedText || href, attributes ), position ); // Put the selection at the end of the inserted link. // Using end of range returned from insertContent in case nodes with the same attributes got merged. - writer.setSelection( positionAfter ); + collapseSelectionAtLinkEnd( newLinkRange ); } - - // Remove the `linkHref` attribute and all link decorators from the selection. - // It stops adding a new content into the link element. - [ 'linkHref', ...truthyManualDecorators, ...falsyManualDecorators ].forEach( item => { - writer.removeSelectionAttribute( item ); - } ); } else { + // Non-collapsed selection. + // If selection has non-collapsed ranges, we change attribute on nodes inside those ranges // omitting nodes where the `linkHref` attribute is disallowed. - const ranges = model.schema.getValidRanges( selection.getRanges(), 'linkHref' ); + const selectionRanges = Array.from( selection.getRanges() ); + const ranges = model.schema.getValidRanges( selectionRanges, 'linkHref' ); // But for the first, check whether the `linkHref` attribute is allowed on selected blocks (e.g. the "image" element). const allowedRanges = []; @@ -231,29 +320,31 @@ export default class LinkCommand extends Command { } } - for ( const range of rangesToUpdate ) { - let linkRange = range; + // Store the selection ranges in a pseudo live range array (stickiness to the outside of the range). + const stickyPseudoRanges = selectionRanges.map( range => ( { + start: LivePosition.fromPosition( range.start, 'toPrevious' ), + end: LivePosition.fromPosition( range.end, 'toNext' ) + } ) ); - if ( rangesToUpdate.length === 1 ) { - // Current text of the link in the document. - const linkText = extractTextFromSelection( selection ); + // Update or set links (including text update if needed). + for ( let range of rangesToUpdate ) { + const linkHref = ( range.start.textNode || range.start.nodeAfter! ).getAttribute( 'linkHref' ) as string | undefined; - if ( selection.getAttribute( 'linkHref' ) === linkText ) { - linkRange = this._updateLinkContent( model, writer, range, href ); - writer.setSelection( writer.createSelection( linkRange ) ); - } - } + range = updateLinkTextIfNeeded( range, linkHref ) || range; - writer.setAttribute( 'linkHref', href, linkRange ); + updateLinkAttributes( range ); + } - truthyManualDecorators.forEach( item => { - writer.setAttribute( item, true, linkRange ); - } ); + // The original selection got trimmed by replacing content so we need to restore it. + writer.setSelection( stickyPseudoRanges.map( pseudoRange => { + const start = pseudoRange.start.toPosition(); + const end = pseudoRange.end.toPosition(); - falsyManualDecorators.forEach( item => { - writer.removeAttribute( item, linkRange ); - } ); - } + pseudoRange.start.detach(); + pseudoRange.end.detach(); + + return model.createRange( start, end ); + } ) ); } } ); } @@ -294,41 +385,106 @@ export default class LinkCommand extends Command { return true; } +} - /** - * Updates selected link with a new value as its content and as its href attribute. - * - * @param model Model is need to insert content. - * @param writer Writer is need to create text element in model. - * @param range A range where should be inserted content. - * @param href A link value which should be in the href attribute and in the content. - */ - private _updateLinkContent( model: Model, writer: Writer, range: Range, href: string ): Range { - const text = writer.createText( href, { linkHref: href } ); +/** + * Compares two strings and returns an array of changes needed to transform one into another. + * Uses the diff utility to find the differences and groups them into chunks containing information + * about the offset and actual/expected content. + * + * @param oldText The original text to compare. + * @param newText The new text to compare against. + * @returns Array of change objects containing offset and actual/expected content. + * + * @example + * findChanges( 'hello world', 'hi there' ); + * + * Returns: + * [ + * { + * "offset": 1, + * "actual": "ello", + * "expected": "i" + * }, + * { + * "offset": 2, + * "actual": "wo", + * "expected": "the" + * }, + * { + * "offset": 3, + * "actual": "ld", + * "expected": "e" + * } + * ] + */ +function findChanges( oldText: string, newText: string ): Array<{ offset: number; actual: string; expected: string }> { + // Get array of operations (insert/delete/equal) needed to transform oldText into newText. + // Example: diff('abc', 'abxc') returns ['equal', 'equal', 'insert', 'equal'] + const changes = diff( oldText, newText ); + + // Track position in both strings based on operation type. + const counter = { equal: 0, insert: 0, delete: 0 }; + const result = []; + + // Accumulate consecutive changes into slices before creating change objects. + let actualSlice = ''; + let expectedSlice = ''; + + // Adding null as sentinel value to handle final accumulated changes. + for ( const action of [ ...changes, null ] ) { + if ( action == 'insert' ) { + // Example: for 'abc' -> 'abxc', at insert position, adds 'x' to expectedSlice. + expectedSlice += newText[ counter.equal + counter.insert ]; + } + else if ( action == 'delete' ) { + // Example: for 'abc' -> 'ac', at delete position, adds 'b' to actualSlice. + actualSlice += oldText[ counter.equal + counter.delete ]; + } + else if ( actualSlice.length || expectedSlice.length ) { + // On 'equal' or end: bundle accumulated changes into a single change object. + // Example: { offset: 2, actual: "", expected: "x" } + result.push( { + offset: counter.equal, + actual: actualSlice, + expected: expectedSlice + } ); + + actualSlice = ''; + expectedSlice = ''; + } - return model.insertContent( text, range ); + // Increment appropriate counter for the current operation. + if ( action ) { + counter[ action ]++; + } } -} -// Returns a text of a link under the collapsed selection or a selection that contains the entire link. -function extractTextFromSelection( selection: DocumentSelection ): string | null { - if ( selection.isCollapsed ) { - const firstPosition = selection.getFirstPosition(); - - return firstPosition!.textNode && firstPosition!.textNode.data; - } else { - const rangeItems = Array.from( selection.getFirstRange()!.getItems() ); + return result; +} - if ( rangeItems.length > 1 ) { - return null; - } +/** + * Returns text node withing the link range that should be updated. + * + * @param range Partial link range. + * @param linkRange Range of the entire link. + * @returns Text node. + */ +function getLinkPartTextNode( range: Range, linkRange: Range ): Item | null { + if ( !range.isCollapsed ) { + return first( range.getItems() ); + } - const firstNode = rangeItems[ 0 ]; + const position = range.start; - if ( firstNode.is( '$text' ) || firstNode.is( '$textProxy' ) ) { - return firstNode.data; - } + if ( position.textNode ) { + return position.textNode; + } - return null; + // If the range is at the start of a link range then prefer node inside a link range. + if ( !position.nodeBefore || position.isEqual( linkRange.start ) ) { + return position.nodeAfter; + } else { + return position.nodeBefore; } } diff --git a/packages/ckeditor5-link/src/linkconfig.ts b/packages/ckeditor5-link/src/linkconfig.ts index 4688a13f560..c2097e3e3f5 100644 --- a/packages/ckeditor5-link/src/linkconfig.ts +++ b/packages/ckeditor5-link/src/linkconfig.ts @@ -180,6 +180,35 @@ export interface LinkConfig { * See also the {@glink features/link#custom-link-attributes-decorators link feature guide} for more information. */ decorators?: Record; + + /** + * Items to be placed in the link contextual toolbar. + * + * Assuming that you use the {@link module:link/linkui~LinkUI} feature, the following toolbar items will be available + * in {@link module:ui/componentfactory~ComponentFactory}: + * + * * `'linkPreview'`, + * * `'editLink'`, + * * `'linkProperties'` + * * `'unlink'`. + * + * The default configuration for link toolbar is: + * + * ```ts + * const linkConfig = { + * toolbar: [ 'linkPreview', '|', 'editLink', 'linkProperties', 'unlink' ] + * }; + * ``` + * + * The `linkProperties` toolbar item is only available when at least one manual decorator is defined in the + * {@link module:link/linkconfig~LinkConfig#decorators decorators configuration}. + * + * Of course, the same buttons can also be used in the + * {@link module:core/editor/editorconfig~EditorConfig#toolbar main editor toolbar}. + * + * Read more about configuring the toolbar in {@link module:core/editor/editorconfig~EditorConfig#toolbar}. + */ + toolbar?: Array; } /** diff --git a/packages/ckeditor5-link/src/linkediting.ts b/packages/ckeditor5-link/src/linkediting.ts index f8d98755138..c300ffcdd6c 100644 --- a/packages/ckeditor5-link/src/linkediting.ts +++ b/packages/ckeditor5-link/src/linkediting.ts @@ -39,7 +39,6 @@ import { getLocalizedDecorators, normalizeDecorators, addLinkProtocolIfApplicable, - createBookmarkCallbacks, openLink, type NormalizedLinkDecoratorAutomaticDefinition, type NormalizedLinkDecoratorManualDefinition @@ -59,6 +58,11 @@ const EXTERNAL_LINKS_REGEXP = /^(https?:)?\/\//; * as well as `'link'` and `'unlink'` commands. */ export default class LinkEditing extends Plugin { + /** + * A list of functions that handles opening links. If any of them returns `true`, the link is considered to be opened. + */ + private readonly _linkOpeners: Array = []; + /** * @inheritDoc */ @@ -89,7 +93,8 @@ export default class LinkEditing extends Plugin { editor.config.define( 'link', { allowCreatingEmptyLinks: false, - addTargetToExternalLinks: false + addTargetToExternalLinks: false, + toolbar: [ 'linkPreview', '|', 'editLink', 'linkProperties', 'unlink' ] } ); } @@ -153,6 +158,16 @@ export default class LinkEditing extends Plugin { this._enableClipboardIntegration(); } + /** + * Registers a function that opens links in a new browser tab. + * + * @param linkOpener The function that opens a link in a new browser tab. + * @internal + */ + public _registerLinkOpener( linkOpener: LinkOpener ): void { + this._linkOpeners.push( linkOpener ); + } + /** * Processes an array of configured {@link module:link/linkconfig~LinkDecoratorAutomaticDefinition automatic decorators} * and registers a {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher downcast dispatcher} @@ -261,15 +276,12 @@ 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 { + const handleLinkOpening = ( url: string ): void => { + if ( !this._linkOpeners.some( opener => opener( url ) ) ) { openLink( url ); } - } + }; this.listenTo( viewDocument, 'click', ( evt, data ) => { const shouldOpen = env.isMac ? data.domEvent.metaKey : data.domEvent.ctrlKey; @@ -365,6 +377,13 @@ export default class LinkEditing extends Plugin { } } +/** + * A function that handles opening links. It may be used to define custom link handlers. + * + * @returns `true` if the link was opened successfully. + */ +type LinkOpener = ( url: string ) => boolean; + /** * Make the selection free of link-related model attributes. * All link-related model attributes start with "link". That includes not only "linkHref" diff --git a/packages/ckeditor5-link/src/linkimageui.ts b/packages/ckeditor5-link/src/linkimageui.ts index 929a667c742..1ccd533f60f 100644 --- a/packages/ckeditor5-link/src/linkimageui.ts +++ b/packages/ckeditor5-link/src/linkimageui.ts @@ -78,7 +78,7 @@ export default class LinkImageUI extends Plugin { * Creates a `LinkImageUI` button view. * * Clicking this button shows a {@link module:link/linkui~LinkUI#_balloon} attached to the selection. - * When an image is already linked, the view shows {@link module:link/linkui~LinkUI#actionsView} or + * When an image is already linked, the view shows {@link module:link/linkui~LinkUI#toolbarView} or * {@link module:link/linkui~LinkUI#formView} if it is not. */ private _createToolbarLinkImageButton(): void { @@ -106,7 +106,7 @@ export default class LinkImageUI extends Plugin { // Show the actionsView or formView (both from LinkUI) on button click depending on whether the image is linked already. this.listenTo( button, 'execute', () => { if ( this._isSelectedLinkedImage( editor.model.document.selection ) ) { - plugin._addActionsView(); + plugin._addToolbarView(); } else { plugin._showUI( true ); } diff --git a/packages/ckeditor5-link/src/linkui.ts b/packages/ckeditor5-link/src/linkui.ts index cdecb4877d3..6b86476e4c1 100644 --- a/packages/ckeditor5-link/src/linkui.ts +++ b/packages/ckeditor5-link/src/linkui.ts @@ -7,7 +7,7 @@ * @module link/linkui */ -import { Plugin, type Editor } from 'ckeditor5/src/core.js'; +import { Plugin, icons, type Editor } from 'ckeditor5/src/core.js'; import { ClickObserver, type ViewAttributeElement, @@ -17,27 +17,42 @@ import { } from 'ckeditor5/src/engine.js'; import { ButtonView, + SwitchButtonView, ContextualBalloon, clickOutsideHandler, CssTransitionDisablerMixin, MenuBarMenuListItemButtonView, - type ViewWithCssTransitionDisabler + ToolbarView, + type ViewWithCssTransitionDisabler, + type ButtonExecuteEvent } from 'ckeditor5/src/ui.js'; -import type { PositionOptions } from 'ckeditor5/src/utils.js'; + +import { Collection, type ObservableChangeEvent, type PositionOptions } from 'ckeditor5/src/utils.js'; import { isWidget } from 'ckeditor5/src/widget.js'; +import LinkEditing from './linkediting.js'; + +import LinkPreviewButtonView, { type LinkPreviewButtonNavigateEvent } from './ui/linkpreviewbuttonview.js'; import LinkFormView, { type LinkFormValidatorCallback } from './ui/linkformview.js'; -import LinkActionsView from './ui/linkactionsview.js'; +import LinkProviderItemsView from './ui/linkprovideritemsview.js'; +import LinkPropertiesView from './ui/linkpropertiesview.js'; +import LinkButtonView from './ui/linkbuttonview.js'; + import type LinkCommand from './linkcommand.js'; import type UnlinkCommand from './unlinkcommand.js'; + import { addLinkProtocolIfApplicable, + ensureSafeUrl, isLinkElement, - createBookmarkCallbacks, + extractTextFromLinkRange, LINK_KEYSTROKE } from './utils.js'; import linkIcon from '../theme/icons/link.svg'; +import unlinkIcon from '../theme/icons/unlink.svg'; + +import '../theme/linktoolbar.css'; const VISUAL_SELECTION_MARKER_NAME = 'link-ui'; @@ -49,25 +64,51 @@ const VISUAL_SELECTION_MARKER_NAME = 'link-ui'; */ export default class LinkUI extends Plugin { /** - * The actions view displayed inside of the balloon. + * The toolbar view displayed inside of the balloon. */ - public actionsView: LinkActionsView | null = null; + public toolbarView: ToolbarView | null = null; /** * The form view displayed inside the balloon. */ public formView: LinkFormView & ViewWithCssTransitionDisabler | null = null; + /** + * The view displaying links list. + */ + public linkProviderItemsView: LinkProviderItemsView | null = null; + + /** + * The form view displaying properties link settings. + */ + public propertiesView: LinkPropertiesView & ViewWithCssTransitionDisabler | null = null; + + /** + * The selected text of the link or text that is selected and can become a link. + * + * Note: It is `undefined` when the current selection does not allow for text, + * for example any non text node is selected or multiple blocks are selected. + * + * @observable + * @readonly + */ + declare public selectedLinkableText: string | undefined; + /** * The contextual balloon plugin instance. */ private _balloon!: ContextualBalloon; + /** + * The collection of the link providers. + */ + private _linksProviders: Collection = new Collection(); + /** * @inheritDoc */ public static get requires() { - return [ ContextualBalloon ] as const; + return [ ContextualBalloon, LinkEditing ] as const; } /** @@ -91,12 +132,15 @@ export default class LinkUI extends Plugin { const editor = this.editor; const t = this.editor.t; + this.set( 'selectedLinkableText', undefined ); + editor.editing.view.addObserver( ClickObserver ); this._balloon = editor.plugins.get( ContextualBalloon ); // Create toolbar buttons. - this._createToolbarLinkButton(); + this._registerComponents(); + this._registerEditingOpeners(); this._enableBalloonActivators(); // Renders a fake visual selection marker on an expanded selection. @@ -151,67 +195,99 @@ export default class LinkUI extends Plugin { super.destroy(); // Destroy created UI components as they are not automatically destroyed (see ckeditor5#1341). + if ( this.propertiesView ) { + this.propertiesView.destroy(); + } + if ( this.formView ) { this.formView.destroy(); } - if ( this.actionsView ) { - this.actionsView.destroy(); + if ( this.toolbarView ) { + this.toolbarView.destroy(); } + + if ( this.linkProviderItemsView ) { + this.linkProviderItemsView.destroy(); + } + } + + /** + * Registers list of buttons below the link form view that + * open a list of links provided by the clicked provider. + */ + public registerLinksListProvider( provider: LinksProvider ): void { + const insertIndex = this._linksProviders + .filter( existing => ( existing.order || 0 ) <= ( provider.order || 0 ) ) + .length; + + this._linksProviders.add( provider, insertIndex ); } /** * Creates views. */ private _createViews() { - this.actionsView = this._createActionsView(); + const linkCommand: LinkCommand = this.editor.commands.get( 'link' )!; + + this.toolbarView = this._createToolbarView(); this.formView = this._createFormView(); + if ( linkCommand.manualDecorators.length ) { + this.propertiesView = this._createPropertiesView(); + } + // Attach lifecycle actions to the the balloon. this._enableUserBalloonInteractions(); } /** - * Creates the {@link module:link/ui/linkactionsview~LinkActionsView} instance. + * Creates the ToolbarView instance. */ - private _createActionsView(): LinkActionsView { + private _createToolbarView(): ToolbarView { const editor = this.editor; - const actionsView = new LinkActionsView( - editor.locale, - editor.config.get( 'link' ), - createBookmarkCallbacks( editor ) - ); + const toolbarView = new ToolbarView( editor.locale ); const linkCommand: LinkCommand = editor.commands.get( 'link' )!; - const unlinkCommand: UnlinkCommand = editor.commands.get( 'unlink' )!; - actionsView.bind( 'href' ).to( linkCommand, 'value' ); - actionsView.editButtonView.bind( 'isEnabled' ).to( linkCommand ); - actionsView.unlinkButtonView.bind( 'isEnabled' ).to( unlinkCommand ); + toolbarView.class = 'ck-link-toolbar'; - // Execute unlink command after clicking on the "Edit" button. - this.listenTo( actionsView, 'edit', () => { - this._addFormView(); - } ); + // Remove the linkProperties button if there are no manual decorators, as it would be useless. + let toolbarItems = editor.config.get( 'link.toolbar' )!; - // Execute unlink command after clicking on the "Unlink" button. - this.listenTo( actionsView, 'unlink', () => { - editor.execute( 'unlink' ); - this._hideUI(); - } ); + if ( !linkCommand.manualDecorators.length ) { + toolbarItems = toolbarItems.filter( item => item !== 'linkProperties' ); + } + + toolbarView.fillFromConfig( toolbarItems, editor.ui.componentFactory ); - // Close the panel on esc key press when the **actions have focus**. - actionsView.keystrokes.set( 'Esc', ( data, cancel ) => { + // Close the panel on esc key press when the **link toolbar have focus**. + toolbarView.keystrokes.set( 'Esc', ( data, cancel ) => { this._hideUI(); cancel(); } ); - // Open the form view on Ctrl+K when the **actions have focus**.. - actionsView.keystrokes.set( LINK_KEYSTROKE, ( data, cancel ) => { + // Open the form view on Ctrl+K when the **link toolbar have focus**.. + toolbarView.keystrokes.set( LINK_KEYSTROKE, ( data, cancel ) => { this._addFormView(); + cancel(); } ); - return actionsView; + // Register the toolbar, so it becomes available for Alt+F10 and Esc navigation. + // TODO this should be registered earlier to be able to open this toolbar without previously opening it by click or Ctrl+K + editor.ui.addToolbar( toolbarView, { + isContextual: true, + beforeFocus: () => { + if ( this._getSelectedLinkElement() && !this._isToolbarVisible ) { + this._showUI( true ); + } + }, + afterBlur: () => { + this._hideUI( false ); + } + } ); + + return toolbarView; } /** @@ -219,12 +295,13 @@ export default class LinkUI extends Plugin { */ private _createFormView(): LinkFormView & ViewWithCssTransitionDisabler { const editor = this.editor; + const t = editor.locale.t; const linkCommand: LinkCommand = editor.commands.get( 'link' )!; const defaultProtocol = editor.config.get( 'link.defaultProtocol' ); - const formView = new ( CssTransitionDisablerMixin( LinkFormView ) )( editor.locale, linkCommand, getFormValidators( editor ) ); + const formView = new ( CssTransitionDisablerMixin( LinkFormView ) )( editor.locale, getFormValidators( editor ) ); - formView.urlInputView.fieldView.bind( 'value' ).to( linkCommand, 'value' ); + formView.displayedTextInputView.bind( 'isEnabled' ).to( this, 'selectedLinkableText', value => value !== undefined ); // Form elements should be read-only when corresponding commands are disabled. formView.urlInputView.bind( 'isEnabled' ).to( linkCommand, 'isEnabled' ); @@ -232,12 +309,23 @@ export default class LinkUI extends Plugin { // Disable the "save" button if the command is disabled. formView.saveButtonView.bind( 'isEnabled' ).to( linkCommand, 'isEnabled' ); + // Change the "Save" button label depending on the command state. + formView.saveButtonView.bind( 'label' ).to( linkCommand, 'value', value => value ? t( 'Update' ) : t( 'Insert' ) ); + // Execute link command after clicking the "Save" button. this.listenTo( formView, 'submit', () => { if ( formView.isValid() ) { - const { value } = formView.urlInputView.fieldView.element!; - const parsedUrl = addLinkProtocolIfApplicable( value, defaultProtocol ); - editor.execute( 'link', parsedUrl, formView.getDecoratorSwitchesState() ); + const url = formView.urlInputView.fieldView.element!.value; + const parsedUrl = addLinkProtocolIfApplicable( url, defaultProtocol ); + const displayedText = formView.displayedTextInputView.fieldView.element!.value; + + editor.execute( + 'link', + parsedUrl, + this._getDecoratorSwitchesState(), + displayedText !== this.selectedLinkableText ? displayedText : undefined + ); + this._closeFormView(); } } ); @@ -258,14 +346,161 @@ export default class LinkUI extends Plugin { cancel(); } ); + // Watch adding new link providers and add them to the buttons list. + formView.providersListChildren.bindTo( this._linksProviders ).using( + provider => this._createLinksListProviderButton( provider ) + ); + return formView; } /** - * Creates a toolbar Link button. Clicking this button will show - * a {@link #_balloon} attached to the selection. + * Creates a sorted array of buttons with link names. */ - private _createToolbarLinkButton(): void { + private _createLinkProviderListView( provider: LinksProvider ): Array { + return provider.getListItems().map( ( { href, label, icon } ) => { + const buttonView = new ButtonView(); + + buttonView.set( { + label, + icon, + tooltip: false, + withText: true + } ); + + buttonView.on( 'execute', () => { + this.formView!.resetFormStatus(); + this.formView!.urlInputView.fieldView.value = href; + + // Set focus to the editing view to prevent from losing it while current view is removed. + this.editor.editing.view.focus(); + + this._removeLinksProviderView(); + + // Set the focus to the URL input field. + this.formView!.focus(); + } ); + + return buttonView; + } ); + } + + /** + * Creates a view for links provider. + */ + private _createLinkProviderItemsView( provider: LinksProvider ): LinkProviderItemsView { + const editor = this.editor; + const t = editor.locale.t; + + const view = new LinkProviderItemsView( editor.locale ); + const { emptyListPlaceholder, label } = provider; + + view.emptyListPlaceholder = emptyListPlaceholder || t( 'No links available' ); + view.title = label; + + // Hide the panel after clicking the "Cancel" button. + this.listenTo( view, 'cancel', () => { + // Set focus to the editing view to prevent from losing it while current view is removed. + editor.editing.view.focus(); + + this._removeLinksProviderView(); + + // Set the focus to the URL input field. + this.formView!.focus(); + } ); + + return view; + } + + /** + * Creates the {@link module:link/ui/linkpropertiesview~LinkPropertiesView} instance. + */ + private _createPropertiesView(): LinkPropertiesView & ViewWithCssTransitionDisabler { + const editor = this.editor; + const linkCommand: LinkCommand = this.editor.commands.get( 'link' )!; + + const view = new ( CssTransitionDisablerMixin( LinkPropertiesView ) )( editor.locale ); + + // Hide the panel after clicking the back button. + this.listenTo( view, 'back', () => { + // Move focus back to the editing view to prevent from losing it while current view is removed. + editor.editing.view.focus(); + + this._removePropertiesView(); + } ); + + view.listChildren.bindTo( linkCommand.manualDecorators ).using( manualDecorator => { + const button: SwitchButtonView = new SwitchButtonView( editor.locale ); + + button.set( { + label: manualDecorator.label, + withText: true + } ); + + button.bind( 'isOn' ).toMany( [ manualDecorator, linkCommand ], 'value', ( decoratorValue, commandValue ) => { + return commandValue === undefined && decoratorValue === undefined ? + !!manualDecorator.defaultValue : + !!decoratorValue; + } ); + + button.on( 'execute', () => { + manualDecorator.set( 'value', !button.isOn ); + editor.execute( 'link', linkCommand.value!, this._getDecoratorSwitchesState() ); + } ); + + return button; + } ); + + return view; + } + + /** + * Obtains the state of the manual decorators. + */ + private _getDecoratorSwitchesState(): Record { + const linkCommand: LinkCommand = this.editor.commands.get( 'link' )!; + + return Array + .from( linkCommand.manualDecorators ) + .reduce( ( accumulator, manualDecorator ) => { + const value = linkCommand.value === undefined && manualDecorator.value === undefined ? + manualDecorator.defaultValue : + manualDecorator.value; + + return { + ...accumulator, + [ manualDecorator.id ]: !!value + }; + }, {} as Record ); + } + + /** + * Registers listeners used in editing plugin, used to open links. + */ + private _registerEditingOpeners() { + const linkEditing = this.editor.plugins.get( LinkEditing ); + + linkEditing._registerLinkOpener( href => { + const match = this._getLinkProviderLinkByHref( href ); + + if ( !match ) { + return false; + } + + const { item, provider } = match; + + if ( provider.navigate ) { + return provider.navigate( item ); + } + + return false; + } ); + } + + /** + * Registers components in the ComponentFactory. + */ + private _registerComponents(): void { const editor = this.editor; editor.ui.componentFactory.add( 'link', () => { @@ -287,6 +522,150 @@ export default class LinkUI extends Plugin { return button; } ); + + editor.ui.componentFactory.add( 'linkPreview', locale => { + const button = new LinkPreviewButtonView( locale ); + const allowedProtocols = editor.config.get( 'link.allowedProtocols' )!; + const linkCommand: LinkCommand = editor.commands.get( 'link' )!; + const t = locale.t; + + button.bind( 'isEnabled' ).to( linkCommand, 'value', href => !!href ); + + button.bind( 'href' ).to( linkCommand, 'value', href => { + return href && ensureSafeUrl( href, allowedProtocols ); + } ); + + const setHref = ( href: string | undefined ) => { + if ( !href ) { + button.label = undefined; + button.icon = undefined; + button.tooltip = t( 'Open link in new tab' ); + return; + } + + const selectedLinksProviderLink = this._getLinkProviderLinkByHref( href ); + + if ( selectedLinksProviderLink ) { + const { label, tooltip, icon } = selectedLinksProviderLink.item; + + button.label = label; + button.tooltip = tooltip || false; + button.icon = icon; + } else { + button.label = href; + button.icon = undefined; + button.tooltip = t( 'Open link in new tab' ); + } + }; + + setHref( linkCommand.value ); + + this.listenTo>( linkCommand, 'change:value', ( evt, name, href ) => { + setHref( href ); + } ); + + this.listenTo( button, 'navigate', ( evt, href, cancel ) => { + const selectedLinksProviderLink = this._getLinkProviderLinkByHref( href ); + + if ( !selectedLinksProviderLink ) { + return; + } + + const { provider, item } = selectedLinksProviderLink!; + const { navigate } = provider; + + if ( navigate && navigate( item! ) ) { + evt.stop(); + cancel(); + } + } ); + + return button; + } ); + + editor.ui.componentFactory.add( 'unlink', locale => { + const unlinkCommand: UnlinkCommand = editor.commands.get( 'unlink' )!; + const button = new ButtonView( locale ); + const t = locale.t; + + button.set( { + label: t( 'Unlink' ), + icon: unlinkIcon, + tooltip: true + } ); + + button.bind( 'isEnabled' ).to( unlinkCommand ); + + this.listenTo( button, 'execute', () => { + editor.execute( 'unlink' ); + this._hideUI(); + } ); + + return button; + } ); + + editor.ui.componentFactory.add( 'editLink', locale => { + const linkCommand: LinkCommand = editor.commands.get( 'link' )!; + const button = new ButtonView( locale ); + const t = locale.t; + + button.set( { + label: t( 'Edit link' ), + icon: icons.pencil, + tooltip: true + } ); + + button.bind( 'isEnabled' ).to( linkCommand ); + + this.listenTo( button, 'execute', () => { + this._addFormView(); + } ); + + return button; + } ); + + editor.ui.componentFactory.add( 'linkProperties', locale => { + const linkCommand: LinkCommand = editor.commands.get( 'link' )!; + const button = new ButtonView( locale ); + const t = locale.t; + + button.set( { + label: t( 'Link properties' ), + icon: icons.settings, + tooltip: true + } ); + + button.bind( 'isEnabled' ).to( + linkCommand, 'isEnabled', + linkCommand, 'value', + linkCommand, 'manualDecorators', + ( isEnabled, href, manualDecorators ) => isEnabled && !!href && manualDecorators.length > 0 + ); + + this.listenTo( button, 'execute', () => { + this._addPropertiesView(); + } ); + + return button; + } ); + } + + /** + * Creates a links button view. + */ + private _createLinksListProviderButton( linkProvider: LinksProvider ): LinkButtonView { + const locale = this.editor.locale!; + const linksButton = new LinkButtonView( locale ); + + linksButton.set( { + label: linkProvider.label + } ); + + this.listenTo( linksButton, 'execute', () => { + this._showLinksProviderView( linkProvider ); + } ); + + return linksButton; } /** @@ -310,7 +689,15 @@ export default class LinkUI extends Plugin { view.bind( 'isOn' ).to( command, 'value', value => !!value ); // Show the panel on button click. - this.listenTo( view, 'execute', () => this._showUI( true ) ); + this.listenTo( view, 'execute', () => { + this._showUI( true ); + + // Open the form view on-top of the toolbar view if it's already visible. + // It should be visible every time the link is selected. + if ( this._getSelectedLinkElement() ) { + this._addFormView(); + } + } ); return view; } @@ -352,8 +739,8 @@ export default class LinkUI extends Plugin { private _enableUserBalloonInteractions(): void { // Focus the form if the balloon is visible and the Tab key has been pressed. this.editor.keystrokes.set( 'Tab', ( data, cancel ) => { - if ( this._areActionsVisible && !this.actionsView!.focusTracker.isFocused ) { - this.actionsView!.focus(); + if ( this._isToolbarVisible && !this.toolbarView!.focusTracker.isFocused ) { + this.toolbarView!.focus(); cancel(); } }, { @@ -381,22 +768,23 @@ export default class LinkUI extends Plugin { } /** - * Adds the {@link #actionsView} to the {@link #_balloon}. + * Adds the {@link #toolbarView} to the {@link #_balloon}. * * @internal */ - public _addActionsView(): void { - if ( !this.actionsView ) { + public _addToolbarView(): void { + if ( !this.toolbarView ) { this._createViews(); } - if ( this._areActionsInPanel ) { + if ( this._isToolbarInPanel ) { return; } this._balloon.add( { - view: this.actionsView!, - position: this._getBalloonPositionData() + view: this.toolbarView!, + position: this._getBalloonPositionData(), + balloonClassName: 'ck-toolbar-container' } ); } @@ -412,23 +800,27 @@ export default class LinkUI extends Plugin { return; } - const editor = this.editor; - const linkCommand: LinkCommand = editor.commands.get( 'link' )!; + const linkCommand: LinkCommand = this.editor.commands.get( 'link' )!; this.formView!.disableCssTransitions(); this.formView!.resetFormStatus(); + this.formView!.backButtonView.isVisible = linkCommand.isEnabled && !!linkCommand.value; this._balloon.add( { view: this.formView!, position: this._getBalloonPositionData() } ); - // Make sure that each time the panel shows up, the URL field remains in sync with the value of + // Make sure that each time the panel shows up, the fields remains in sync with the value of // the command. If the user typed in the input, then canceled the balloon (`urlInputView.fieldView#value` stays // unaltered) and re-opened it without changing the value of the link command (e.g. because they // clicked the same link), they would see the old value instead of the actual value of the command. // https://github.com/ckeditor/ckeditor5-link/issues/78 // https://github.com/ckeditor/ckeditor5-link/issues/123 + + this.selectedLinkableText = this._getSelectedLinkableText(); + + this.formView!.displayedTextInputView.fieldView.value = this.selectedLinkableText || ''; this.formView!.urlInputView.fieldView.value = linkCommand.value || ''; // Select input when form view is currently visible. @@ -439,19 +831,68 @@ export default class LinkUI extends Plugin { this.formView!.enableCssTransitions(); } + /** + * Adds the {@link #propertiesView} to the {@link #_balloon}. + */ + private _addPropertiesView(): void { + if ( !this.propertiesView ) { + this._createViews(); + } + + if ( this._arePropertiesInPanel ) { + return; + } + + this.propertiesView!.disableCssTransitions(); + + this._balloon.add( { + view: this.propertiesView!, + position: this._getBalloonPositionData() + } ); + + this.propertiesView!.enableCssTransitions(); + this.propertiesView!.focus(); + } + + /** + * Shows the view with links provided by the given provider. + */ + private _showLinksProviderView( provider: LinksProvider ): void { + if ( this.linkProviderItemsView ) { + this._removeLinksProviderView(); + } + + this.linkProviderItemsView = this._createLinkProviderItemsView( provider ); + + this._addLinkProviderItemsView( provider ); + } + + /** + * Adds the {@link #linkProviderItemsView} to the {@link #_balloon}. + */ + private _addLinkProviderItemsView( provider: LinksProvider ): void { + // Clear the collection of links. + this.linkProviderItemsView!.listChildren.clear(); + + // Add links to the collection. + this.linkProviderItemsView!.listChildren.addMany( this._createLinkProviderListView( provider ) ); + + this._balloon.add( { + view: this.linkProviderItemsView!, + position: this._getBalloonPositionData() + } ); + + this.linkProviderItemsView!.focus(); + } + /** * Closes the form view. Decides whether the balloon should be hidden completely or if the action view should be shown. This is * decided upon the link command value (which has a value if the document selection is in the link). - * - * Additionally, if any {@link module:link/linkconfig~LinkConfig#decorators} are defined in the editor configuration, the state of - * switch buttons responsible for manual decorator handling is restored. */ private _closeFormView(): void { const linkCommand: LinkCommand = this.editor.commands.get( 'link' )!; - // Restore manual decorator states to represent the current model state. This case is important to reset the switch buttons - // when the user cancels the editing form. - linkCommand.restoreManualDecoratorStates(); + this.selectedLinkableText = undefined; if ( linkCommand.value !== undefined ) { this._removeFormView(); @@ -460,6 +901,24 @@ export default class LinkUI extends Plugin { } } + /** + * Removes the {@link #propertiesView} from the {@link #_balloon}. + */ + private _removePropertiesView(): void { + if ( this._arePropertiesInPanel ) { + this._balloon.remove( this.propertiesView! ); + } + } + + /** + * Removes the {@link #linkProviderItemsView} from the {@link #_balloon}. + */ + private _removeLinksProviderView(): void { + if ( this._isLinksListInPanel ) { + this._balloon.remove( this.linkProviderItemsView! ); + } + } + /** * Removes the {@link #formView} from the {@link #_balloon}. */ @@ -469,7 +928,8 @@ export default class LinkUI extends Plugin { // See https://github.com/ckeditor/ckeditor5/issues/1501. this.formView!.saveButtonView.focus(); - // Reset the URL field to update the state of the submit button. + // Reset fields to update the state of the submit button. + this.formView!.displayedTextInputView.fieldView.reset(); this.formView!.urlInputView.fieldView.reset(); this._balloon.remove( this.formView! ); @@ -483,7 +943,7 @@ export default class LinkUI extends Plugin { } /** - * Shows the correct UI type. It is either {@link #formView} or {@link #actionsView}. + * Shows the correct UI type. It is either {@link #formView} or {@link #toolbarView}. * * @internal */ @@ -498,7 +958,7 @@ export default class LinkUI extends Plugin { // See https://github.com/ckeditor/ckeditor5/issues/4721. this._showFakeVisualSelection(); - this._addActionsView(); + this._addToolbarView(); // Be sure panel with link is visible. if ( forceVisible ) { @@ -509,13 +969,13 @@ export default class LinkUI extends Plugin { } // If there's a link under the selection... else { - // Go to the editing UI if actions are already visible. - if ( this._areActionsVisible ) { + // Go to the editing UI if toolbar is already visible. + if ( this._isToolbarVisible ) { this._addFormView(); } - // Otherwise display just the actions UI. + // Otherwise display just the toolbar. else { - this._addActionsView(); + this._addToolbarView(); } // Be sure panel with link is visible. @@ -531,27 +991,37 @@ export default class LinkUI extends Plugin { /** * Removes the {@link #formView} from the {@link #_balloon}. * - * See {@link #_addFormView}, {@link #_addActionsView}. + * See {@link #_addFormView}, {@link #_addToolbarView}. */ - private _hideUI(): void { + private _hideUI( updateFocus: boolean = true ): void { + const editor = this.editor; + if ( !this._isUIInPanel ) { return; } - const editor = this.editor; - this.stopListening( editor.ui, 'update' ); this.stopListening( this._balloon, 'change:visibleView' ); // Make sure the focus always gets back to the editable _before_ removing the focused form view. // Doing otherwise causes issues in some browsers. See https://github.com/ckeditor/ckeditor5-link/issues/193. - editor.editing.view.focus(); + if ( updateFocus ) { + editor.editing.view.focus(); + } + + // If the links view is visible, remove it because it can be on top of the stack. + this._removeLinksProviderView(); - // Remove form first because it's on top of the stack. + // If the properties form view is visible, remove it because it can be on top of the stack. + this._removePropertiesView(); + + // Then remove the form view because it's beneath the properties form. this._removeFormView(); - // Then remove the actions view because it's beneath the form. - this._balloon.remove( this.actionsView! ); + // Finally, remove the link toolbar view because it's last in the stack. + if ( this._isToolbarInPanel ) { + this._balloon.remove( this.toolbarView! ); + } this._hideFakeVisualSelection(); } @@ -579,7 +1049,7 @@ export default class LinkUI extends Plugin { // of the link, // * the selection went to a different parent when creating a NEW link. E.g. someone // else modified the document. - // * the selection has expanded (e.g. displaying link actions then pressing SHIFT+Right arrow). + // * the selection has expanded (e.g. displaying link toolbar then pressing SHIFT+Right arrow). // // Note: #_getSelectedLinkElement will return a link for a non-collapsed selection only // when fully selected. @@ -612,6 +1082,20 @@ export default class LinkUI extends Plugin { this.listenTo( this._balloon, 'change:visibleView', update ); } + /** + * Returns `true` when {@link #propertiesView} is in the {@link #_balloon}. + */ + private get _arePropertiesInPanel(): boolean { + return !!this.propertiesView && this._balloon.hasView( this.propertiesView ); + } + + /** + * Returns `true` when {@link #linkProviderItemsView} is in the {@link #_balloon}. + */ + private get _isLinksListInPanel(): boolean { + return !!this.linkProviderItemsView && this._balloon.hasView( this.linkProviderItemsView ); + } + /** * Returns `true` when {@link #formView} is in the {@link #_balloon}. */ @@ -620,35 +1104,50 @@ export default class LinkUI extends Plugin { } /** - * Returns `true` when {@link #actionsView} is in the {@link #_balloon}. + * Returns `true` when {@link #toolbarView} is in the {@link #_balloon}. + */ + private get _isToolbarInPanel(): boolean { + return !!this.toolbarView && this._balloon.hasView( this.toolbarView ); + } + + /** + * Returns `true` when {@link #propertiesView} is in the {@link #_balloon} and it is + * currently visible. */ - private get _areActionsInPanel(): boolean { - return !!this.actionsView && this._balloon.hasView( this.actionsView ); + private get _isPropertiesVisible(): boolean { + return !!this.propertiesView && this._balloon.visibleView === this.propertiesView; } /** - * Returns `true` when {@link #actionsView} is in the {@link #_balloon} and it is + * Returns `true` when {@link #formView} is in the {@link #_balloon} and it is * currently visible. */ - private get _areActionsVisible(): boolean { - return !!this.actionsView && this._balloon.visibleView === this.actionsView; + private get _isFormVisible(): boolean { + return !!this.formView && this._balloon.visibleView == this.formView; } /** - * Returns `true` when {@link #actionsView} or {@link #formView} is in the {@link #_balloon}. + * Returns `true` when {@link #toolbarView} is in the {@link #_balloon} and it is + * currently visible. + */ + private get _isToolbarVisible(): boolean { + return !!this.toolbarView && this._balloon.visibleView === this.toolbarView; + } + + /** + * Returns `true` when {@link #propertiesView}, {@link #toolbarView}, {@link #linkProviderItemsView} + * or {@link #formView} is in the {@link #_balloon}. */ private get _isUIInPanel(): boolean { - return this._isFormInPanel || this._areActionsInPanel; + return this._arePropertiesInPanel || this._isLinksListInPanel || this._isFormInPanel || this._isToolbarInPanel; } /** - * Returns `true` when {@link #actionsView} or {@link #formView} is in the {@link #_balloon} and it is - * currently visible. + * Returns `true` when {@link #propertiesView}, {@link #linkProviderItemsView}, {@link #toolbarView} + * or {@link #formView} is in the {@link #_balloon} and it is currently visible. */ private get _isUIVisible(): boolean { - const visibleView = this._balloon.visibleView; - - return !!this.formView && visibleView == this.formView || this._areActionsVisible; + return this._isPropertiesVisible || this._isLinksListInPanel || this._isFormVisible || this._isToolbarVisible; } /** @@ -660,25 +1159,33 @@ export default class LinkUI extends Plugin { */ private _getBalloonPositionData(): Partial { const view = this.editor.editing.view; - const model = this.editor.model; const viewDocument = view.document; - let target: PositionOptions[ 'target' ]; + const model = this.editor.model; if ( model.markers.has( VISUAL_SELECTION_MARKER_NAME ) ) { // There are cases when we highlight selection using a marker (#7705, #4721). - const markerViewElements = Array.from( this.editor.editing.mapper.markerNameToElements( VISUAL_SELECTION_MARKER_NAME )! ); - const newRange = view.createRange( - view.createPositionBefore( markerViewElements[ 0 ] ), - view.createPositionAfter( markerViewElements[ markerViewElements.length - 1 ] ) - ); + const markerViewElements = this.editor.editing.mapper.markerNameToElements( VISUAL_SELECTION_MARKER_NAME ); + + // Marker could be removed by link text override and end up in the graveyard. + if ( markerViewElements ) { + const markerViewElementsArray = Array.from( markerViewElements ); + const newRange = view.createRange( + view.createPositionBefore( markerViewElementsArray[ 0 ] ), + view.createPositionAfter( markerViewElementsArray[ markerViewElementsArray.length - 1 ] ) + ); - target = view.domConverter.viewRangeToDom( newRange ); - } else { - // Make sure the target is calculated on demand at the last moment because a cached DOM range - // (which is very fragile) can desynchronize with the state of the editing view if there was - // any rendering done in the meantime. This can happen, for instance, when an inline widget - // gets unlinked. - target = () => { + return { + target: view.domConverter.viewRangeToDom( newRange ) + }; + } + } + + // Make sure the target is calculated on demand at the last moment because a cached DOM range + // (which is very fragile) can desynchronize with the state of the editing view if there was + // any rendering done in the meantime. This can happen, for instance, when an inline widget + // gets unlinked. + return { + target: () => { const targetLink = this._getSelectedLinkElement(); return targetLink ? @@ -686,10 +1193,8 @@ export default class LinkUI extends Plugin { view.domConverter.mapViewToDom( targetLink )! : // Otherwise attach panel to the selection. view.domConverter.viewRangeToDom( viewDocument.selection.getFirstRange()! ); - }; - } - - return { target }; + } + }; } /** @@ -729,6 +1234,50 @@ export default class LinkUI extends Plugin { } } + /** + * Returns selected link text content. + * If link is not selected it returns the selected text. + * If selection or link includes non text node (inline object or block) then returns undefined. + */ + private _getSelectedLinkableText(): string | undefined { + const model = this.editor.model; + const editing = this.editor.editing; + const selectedLink = this._getSelectedLinkElement(); + + if ( !selectedLink ) { + return extractTextFromLinkRange( model.document.selection.getFirstRange()! ); + } + + const viewLinkRange = editing.view.createRangeOn( selectedLink ); + const linkRange = editing.mapper.toModelRange( viewLinkRange ); + + return extractTextFromLinkRange( linkRange ); + } + + /** + * Returns a provider by its URL. + * + * @param href URL of the link. + * @returns Link provider and item or `null` if not found. + */ + private _getLinkProviderLinkByHref( href: string ): { provider: LinksProvider; item: LinksProviderDetailedItem } | null { + if ( !href ) { + return null; + } + + for ( const provider of this._linksProviders ) { + const item = provider.getItem ? + provider.getItem( href ) : + provider.getListItems().find( item => item.href === href ); + + if ( item ) { + return { provider, item }; + } + } + + return null; + } + /** * Displays a fake visual selection when the contextual balloon is displayed. * @@ -779,6 +1328,112 @@ export default class LinkUI extends Plugin { } } +/** + * Link list item that represents a single link in the provider's list. + * It's displayed after the user clicks the button that opens the list in the link form view. + */ +export type LinksProviderListItem = { + + /** + * Unique identifier of the item. Avoids collection malfunction when there are links with the same labels. + */ + id: string; + + /** + * URL of the link. + */ + href: string; + + /** + * Label that is used as a text for the list item. + */ + label: string; + + /** + * Optional icon displayed for the item. + */ + icon?: string; +}; + +/** + * Link list item with additional attributes that will be used when: + * + * * The item is selected and the preview of the item is displayed. + * * The user selects the item and the link is created. + * * The user navigates to the item using editing. + * + * It can be used to perform additional lookups in the database or to provide additional information about the link. + */ +export type LinksProviderDetailedItem = { + + /** + * URL of the link. + */ + href: string; + + /** + * Optional icon displayed when the user opens toolbar with the item preview. + */ + icon?: string; + + /** + * Optional label shown in the link preview. If not passed then the `href` is used as the label. + */ + label?: string; + + /** + * Optional tooltip shown in the link preview. + */ + tooltip?: string; +}; + +/** + * Interface for a provider that provides a list of links to be displayed in the link form view. + */ +export type LinksProvider = { + + /** + * Label that serves two purposes: + * + * * As a text for the button that opens this link list from within link form view. + * * As a text for the header when the list of links from this provider is displayed. + */ + label: string; + + /** + * Message to be displayed when there are no items in the list. + * It's optional and if not provided, a default message will be displayed. + */ + emptyListPlaceholder?: string; + + /** + * Weight used for ordering providers in the list. Higher weight means the provider will be displayed lower in the list. + * + * @default 0 + */ + order?: number; + + /** + * Callback for retrieving an static array of items which is being called every time the list is displayed. + * It's not required to provide all links at once, it's possible to pass only slice of links. + */ + getListItems(): Array; + + /** + * Optional callback for retrieving an item by its URL. + * If not provided the item from the list will be used. + */ + getItem?( href: string ): LinksProviderDetailedItem | null; + + /** + * Callback called when user clicked the link in the list. + * + * @param item Item that was clicked. + * @returns `true` if the link was handled by the provider, `false` otherwise. It'll prevent the default action if `true`. + */ + navigate?( item: LinksProviderDetailedItem ): boolean; +}; + /** * Returns a link element if there's one among the ancestors of the provided `Position`. * diff --git a/packages/ckeditor5-link/src/ui/linkactionsview.ts b/packages/ckeditor5-link/src/ui/linkactionsview.ts deleted file mode 100644 index 4d12ae31125..00000000000 --- a/packages/ckeditor5-link/src/ui/linkactionsview.ts +++ /dev/null @@ -1,282 +0,0 @@ -/** - * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. - * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options - */ - -/** - * @module link/ui/linkactionsview - */ - -import { ButtonView, View, ViewCollection, FocusCycler, type FocusableView } from 'ckeditor5/src/ui.js'; -import { FocusTracker, KeystrokeHandler, type LocaleTranslate, type Locale } from 'ckeditor5/src/utils.js'; -import { icons } from 'ckeditor5/src/core.js'; - -import { ensureSafeUrl, openLink } from '../utils.js'; - -// See: #8833. -// eslint-disable-next-line ckeditor5-rules/ckeditor-imports -import '@ckeditor/ckeditor5-ui/theme/components/responsive-form/responsiveform.css'; -import '../../theme/linkactions.css'; - -import unlinkIcon from '../../theme/icons/unlink.svg'; -import type { LinkConfig } from '../linkconfig.js'; - -/** - * The link actions view class. This view displays the link preview, allows - * unlinking or editing the link. - */ -export default class LinkActionsView extends View { - /** - * Tracks information about DOM focus in the actions. - */ - public readonly focusTracker = new FocusTracker(); - - /** - * An instance of the {@link module:utils/keystrokehandler~KeystrokeHandler}. - */ - public readonly keystrokes = new KeystrokeHandler(); - - /** - * The href preview view. - */ - public previewButtonView: ButtonView; - - /** - * The unlink button view. - */ - public unlinkButtonView: ButtonView; - - /** - * The edit link button view. - */ - public editButtonView: ButtonView; - - /** - * The value of the "href" attribute of the link to use in the {@link #previewButtonView}. - * - * @observable - */ - declare public href: string | undefined; - - /** - * A collection of views that can be focused in the view. - */ - private readonly _focusables = new ViewCollection(); - - /** - * Helps cycling over {@link #_focusables} in the view. - */ - private readonly _focusCycler: FocusCycler; - - private readonly _linkConfig: LinkConfig; - - private readonly _options?: LinkActionsViewOptions; - - declare public t: LocaleTranslate; - - /** - * @inheritDoc - */ - 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' ); - - this.set( 'href', undefined ); - - this._linkConfig = linkConfig; - - this._focusCycler = new FocusCycler( { - focusables: this._focusables, - focusTracker: this.focusTracker, - keystrokeHandler: this.keystrokes, - actions: { - // Navigate fields backwards using the Shift + Tab keystroke. - focusPrevious: 'shift + tab', - - // Navigate fields forwards using the Tab key. - focusNext: 'tab' - } - } ); - - this.setTemplate( { - tag: 'div', - - attributes: { - class: [ - 'ck', - 'ck-link-actions', - 'ck-responsive-form' - ], - - // https://github.com/ckeditor/ckeditor5-link/issues/90 - tabindex: '-1' - }, - - children: [ - this.previewButtonView, - this.editButtonView, - this.unlinkButtonView - ] - } ); - } - - /** - * @inheritDoc - */ - public override render(): void { - super.render(); - - const childViews = [ - this.previewButtonView, - this.editButtonView, - this.unlinkButtonView - ]; - - childViews.forEach( v => { - // Register the view as focusable. - this._focusables.add( v ); - - // Register the view in the focus tracker. - this.focusTracker.add( v.element! ); - } ); - - // Start listening for the keystrokes coming from #element. - this.keystrokes.listenTo( this.element! ); - } - - /** - * @inheritDoc - */ - public override destroy(): void { - super.destroy(); - - this.focusTracker.destroy(); - this.keystrokes.destroy(); - } - - /** - * Focuses the fist {@link #_focusables} in the actions. - */ - public focus(): void { - this._focusCycler.focusFirst(); - } - - /** - * Creates a button view. - * - * @param label The button label. - * @param icon The button icon. - * @param eventName An event name that the `ButtonView#execute` event will be delegated to. - * @returns The button view instance. - */ - private _createButton( label: string, icon: string, eventName?: string ): ButtonView { - const button = new ButtonView( this.locale ); - - button.set( { - label, - icon, - tooltip: true - } ); - - button.delegate( 'execute' ).to( this, eventName ); - - return button; - } - - /** - * Creates a link href preview button. - * - * @returns The button view instance. - */ - private _createPreviewButton(): ButtonView { - const button = new ButtonView( this.locale ); - const bind = this.bindTemplate; - const t = this.t; - - button.set( { - withText: true - } ); - - button.extendTemplate( { - attributes: { - class: [ - 'ck', - 'ck-link-actions__preview' - ], - 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 => { - return href || t( 'This link has no URL' ); - } ); - - button.bind( 'isEnabled' ).to( this, 'href', href => !!href ); - - button.template!.tag = 'a'; - - return button; - } -} - -/** - * Fired when the {@link ~LinkActionsView#editButtonView} is clicked. - * - * @eventName ~LinkActionsView#edit - */ -export type EditEvent = { - name: 'edit'; - args: []; -}; - -/** - * Fired when the {@link ~LinkActionsView#unlinkButtonView} is clicked. - * - * @eventName ~LinkActionsView#unlink - */ -export type UnlinkEvent = { - name: 'unlink'; - args: []; -}; - -/** - * The options that are passed to the {@link ~LinkActionsView#constructor} 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/ui/linkbuttonview.ts b/packages/ckeditor5-link/src/ui/linkbuttonview.ts new file mode 100644 index 00000000000..0785ad8c976 --- /dev/null +++ b/packages/ckeditor5-link/src/ui/linkbuttonview.ts @@ -0,0 +1,67 @@ +/** + * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options + */ + +/** + * @module link/ui/linkbuttonview + */ + +import { icons } from 'ckeditor5/src/core.js'; +import { ButtonView, IconView } from 'ckeditor5/src/ui.js'; +import type { Locale } from 'ckeditor5/src/utils.js'; + +/** + * Represents a view for a dropdown menu button. + */ +export default class LinkButtonView extends ButtonView { + /** + * An icon that displays an arrow to indicate a direction of the menu. + */ + public readonly arrowView: IconView; + + /** + * Creates an instance of the dropdown menu button view. + * + * @param locale The localization services instance. + */ + constructor( locale?: Locale ) { + super( locale ); + + this.set( { + withText: true + } ); + + this.arrowView = this._createArrowView(); + + this.extendTemplate( { + attributes: { + class: [ + 'ck-link__button' + ] + } + } ); + } + + /** + * @inheritDoc + */ + public override render(): void { + super.render(); + + this.children.add( this.arrowView ); + } + + /** + * Creates the arrow view instance. + * + * @private + */ + private _createArrowView() { + const arrowView = new IconView(); + + arrowView.content = icons.nextArrow; + + return arrowView; + } +} diff --git a/packages/ckeditor5-link/src/ui/linkformview.ts b/packages/ckeditor5-link/src/ui/linkformview.ts index 88f24fe0be7..394d62f5975 100644 --- a/packages/ckeditor5-link/src/ui/linkformview.ts +++ b/packages/ckeditor5-link/src/ui/linkformview.ts @@ -9,9 +9,12 @@ import { ButtonView, + ListView, + ListItemView, FocusCycler, LabeledFieldView, - SwitchButtonView, + FormHeaderView, + FormRowView, View, ViewCollection, createLabeledInputText, @@ -22,23 +25,20 @@ import { import { FocusTracker, KeystrokeHandler, - type Collection, type Locale } from 'ckeditor5/src/utils.js'; import { icons } from 'ckeditor5/src/core.js'; -import type LinkCommand from '../linkcommand.js'; -import type ManualDecorator from '../utils/manualdecorator.js'; - // See: #8833. // eslint-disable-next-line ckeditor5-rules/ckeditor-imports import '@ckeditor/ckeditor5-ui/theme/components/responsive-form/responsiveform.css'; +// eslint-disable-next-line ckeditor5-rules/ckeditor-imports +import '@ckeditor/ckeditor5-ui/theme/components/form/form.css'; + import '../../theme/linkform.css'; /** - * The link form view controller class. - * - * See {@link module:link/ui/linkformview~LinkFormView}. + * The link form view. */ export default class LinkFormView extends View { /** @@ -52,9 +52,9 @@ export default class LinkFormView extends View { public readonly keystrokes = new KeystrokeHandler(); /** - * The URL input view. + * The Back button view displayed in the header. */ - public urlInputView: LabeledFieldView; + public backButtonView: ButtonView; /** * The Save button view. @@ -62,22 +62,25 @@ export default class LinkFormView extends View { public saveButtonView: ButtonView; /** - * The Cancel button view. + * The "Displayed text" input view. */ - public cancelButtonView: ButtonView; + public displayedTextInputView: LabeledFieldView; /** - * A collection of {@link module:ui/button/switchbuttonview~SwitchButtonView}, - * which corresponds to {@link module:link/linkcommand~LinkCommand#manualDecorators manual decorators} - * configured in the editor. + * The URL input view. */ - private readonly _manualDecoratorSwitches: ViewCollection; + public urlInputView: LabeledFieldView; /** - * A collection of child views in the form. + * A collection of child views. */ public readonly children: ViewCollection; + /** + * A collection of child views in the providers list. + */ + public readonly providersListChildren: ViewCollection; + /** * An array of form validators used by {@link #isValid}. */ @@ -99,21 +102,37 @@ export default class LinkFormView extends View { * Also see {@link #render}. * * @param locale The localization services instance. - * @param linkCommand Reference to {@link module:link/linkcommand~LinkCommand}. * @param validators Form validators used by {@link #isValid}. */ - constructor( locale: Locale, linkCommand: LinkCommand, validators: Array ) { + constructor( + locale: Locale, + validators: Array + ) { super( locale ); - const t = locale.t; - this._validators = validators; + + // Create buttons. + this.backButtonView = this._createBackButton(); + this.saveButtonView = this._createSaveButton(); + + // Create input fields. + this.displayedTextInputView = this._createDisplayedTextInput(); this.urlInputView = this._createUrlInput(); - this.saveButtonView = this._createButton( t( 'Save' ), icons.check, 'ck-button-save' ); - this.saveButtonView.type = 'submit'; - this.cancelButtonView = this._createButton( t( 'Cancel' ), icons.cancel, 'ck-button-cancel', 'cancel' ); - this._manualDecoratorSwitches = this._createManualDecoratorSwitches( linkCommand ); - this.children = this._createFormChildren( linkCommand.manualDecorators ); + + this.providersListChildren = this.createCollection(); + this.children = this.createCollection( [ + this._createHeaderView() + ] ); + + this._createFormChildren(); + + // Add providers list view to the children when the first item is added to the list. + // This is to avoid adding the list view when the form is empty. + this.listenTo( this.providersListChildren, 'add', () => { + this.stopListening( this.providersListChildren, 'add' ); + this.children.add( this._createProvidersListView() ); + } ); this._focusCycler = new FocusCycler( { focusables: this._focusables, @@ -128,17 +147,16 @@ export default class LinkFormView extends View { } } ); - const classList = [ 'ck', 'ck-link-form', 'ck-responsive-form' ]; - - if ( linkCommand.manualDecorators.length ) { - classList.push( 'ck-link-form_layout-vertical', 'ck-vertical-form' ); - } - this.setTemplate( { tag: 'form', attributes: { - class: classList, + class: [ + 'ck', + 'ck-form', + 'ck-link-form', + 'ck-responsive-form' + ], // https://github.com/ckeditor/ckeditor5-link/issues/90 tabindex: '-1' @@ -148,22 +166,6 @@ export default class LinkFormView extends View { } ); } - /** - * Obtains the state of the {@link module:ui/button/switchbuttonview~SwitchButtonView switch buttons} representing - * {@link module:link/linkcommand~LinkCommand#manualDecorators manual link decorators} - * in the {@link module:link/ui/linkformview~LinkFormView}. - * - * @returns Key-value pairs, where the key is the name of the decorator and the value is its state. - */ - public getDecoratorSwitchesState(): Record { - return Array - .from( this._manualDecoratorSwitches as Iterable ) - .reduce( ( accumulator, switchButton ) => { - accumulator[ switchButton.name ] = switchButton.isOn; - return accumulator; - }, {} as Record ); - } - /** * @inheritDoc */ @@ -176,9 +178,10 @@ export default class LinkFormView extends View { const childViews = [ this.urlInputView, - ...this._manualDecoratorSwitches, this.saveButtonView, - this.cancelButtonView + ...this.providersListChildren, + this.backButtonView, + this.displayedTextInputView ]; childViews.forEach( v => { @@ -242,130 +245,135 @@ export default class LinkFormView extends View { } /** - * Creates a labeled input view. - * - * @returns Labeled field view instance. + * Creates a back button view that cancels the form. */ - private _createUrlInput(): LabeledFieldView { + private _createBackButton(): ButtonView { const t = this.locale!.t; - const labeledInput = new LabeledFieldView( this.locale, createLabeledInputText ); + const backButton = new ButtonView( this.locale ); - labeledInput.fieldView.inputMode = 'url'; - labeledInput.label = t( 'Link URL' ); + backButton.set( { + class: 'ck-button-back', + label: t( 'Back' ), + icon: icons.previousArrow, + tooltip: true + } ); - return labeledInput; + backButton.delegate( 'execute' ).to( this, 'cancel' ); + + return backButton; } /** - * Creates a button view. - * - * @param label The button label. - * @param icon The button icon. - * @param className The additional button CSS class name. - * @param eventName An event name that the `ButtonView#execute` event will be delegated to. - * @returns The button view instance. + * Creates a save button view that inserts the link. */ - private _createButton( label: string, icon: string, className: string, eventName?: string ): ButtonView { - const button = new ButtonView( this.locale ); - - button.set( { - label, - icon, - tooltip: true + private _createSaveButton(): ButtonView { + const t = this.locale!.t; + const saveButton = new ButtonView( this.locale ); + + saveButton.set( { + label: t( 'Insert' ), + tooltip: false, + withText: true, + type: 'submit', + class: 'ck-button-action ck-button-bold' } ); - button.extendTemplate( { - attributes: { - class: className - } + return saveButton; + } + + /** + * Creates a header view for the form. + */ + private _createHeaderView(): FormHeaderView { + const t = this.locale!.t; + + const header = new FormHeaderView( this.locale, { + label: t( 'Link' ) } ); - if ( eventName ) { - button.delegate( 'execute' ).to( this, eventName ); - } + header.children.add( this.backButtonView, 0 ); - return button; + return header; } /** - * Populates {@link module:ui/viewcollection~ViewCollection} of {@link module:ui/button/switchbuttonview~SwitchButtonView} - * made based on {@link module:link/linkcommand~LinkCommand#manualDecorators}. - * - * @param linkCommand A reference to the link command. - * @returns ViewCollection of switch buttons. + * Creates a view for the providers list. */ - private _createManualDecoratorSwitches( linkCommand: LinkCommand ): ViewCollection { - const switches = this.createCollection(); + private _createProvidersListView(): ListView { + const providersListView = new ListView( this.locale ); - for ( const manualDecorator of linkCommand.manualDecorators ) { - const switchButton: SwitchButtonView & { name?: string } = new SwitchButtonView( this.locale ); + providersListView.extendTemplate( { + attributes: { + class: [ + 'ck-link-form__providers-list' + ] + } + } ); - switchButton.set( { - name: manualDecorator.id, - label: manualDecorator.label, - withText: true - } ); + providersListView.items.bindTo( this.providersListChildren ).using( def => { + const listItemView = new ListItemView( this.locale ); - switchButton.bind( 'isOn' ).toMany( [ manualDecorator, linkCommand ], 'value', ( decoratorValue, commandValue ) => { - return commandValue === undefined && decoratorValue === undefined ? !!manualDecorator.defaultValue : !!decoratorValue; - } ); + listItemView.children.add( def ); - switchButton.on( 'execute', () => { - manualDecorator.set( 'value', !switchButton.isOn ); - } ); + return listItemView; + } ); - switches.add( switchButton ); - } + return providersListView; + } - return switches; + /** + * Creates a labeled input view for the "Displayed text" field. + */ + private _createDisplayedTextInput(): LabeledFieldView { + const t = this.locale!.t; + const labeledInput = new LabeledFieldView( this.locale, createLabeledInputText ); + + labeledInput.label = t( 'Displayed text' ); + labeledInput.class = 'ck-labeled-field-view_full-width'; + + return labeledInput; } /** - * Populates the {@link #children} collection of the form. + * Creates a labeled input view for the URL field. * - * If {@link module:link/linkcommand~LinkCommand#manualDecorators manual decorators} are configured in the editor, it creates an - * additional `View` wrapping all {@link #_manualDecoratorSwitches} switch buttons corresponding - * to these decorators. - * - * @param manualDecorators A reference to - * the collection of manual decorators stored in the link command. - * @returns The children of link form view. + * @returns Labeled field view instance. */ - private _createFormChildren( manualDecorators: Collection ): ViewCollection { - const children = this.createCollection(); - - children.add( this.urlInputView ); - - if ( manualDecorators.length ) { - const additionalButtonsView = new View(); - - additionalButtonsView.setTemplate( { - tag: 'ul', - children: this._manualDecoratorSwitches.map( switchButton => ( { - tag: 'li', - children: [ switchButton ], - attributes: { - class: [ - 'ck', - 'ck-list__item' - ] - } - } ) ), - attributes: { - class: [ - 'ck', - 'ck-reset', - 'ck-list' - ] - } - } ); - children.add( additionalButtonsView ); - } + private _createUrlInput(): LabeledFieldView { + const t = this.locale!.t; + const labeledInput = new LabeledFieldView( this.locale, createLabeledInputText ); - children.add( this.saveButtonView ); - children.add( this.cancelButtonView ); + labeledInput.fieldView.inputMode = 'url'; + labeledInput.label = t( 'Link URL' ); + labeledInput.class = 'ck-labeled-field-view_full-width'; - return children; + return labeledInput; + } + + /** + * Populates the {@link #children} collection of the form. + */ + private _createFormChildren(): void { + this.children.add( new FormRowView( this.locale!, { + children: [ + this.displayedTextInputView + ], + class: [ + 'ck-form__row_large-top-padding' + ] + } ) ); + + this.children.add( new FormRowView( this.locale!, { + children: [ + this.urlInputView, + this.saveButtonView + ], + class: [ + 'ck-form__row_with-submit', + 'ck-form__row_large-top-padding', + 'ck-form__row_large-bottom-padding' + ] + } ) ); } /** @@ -405,7 +413,7 @@ export type SubmitEvent = { }; /** - * Fired when the form view is canceled, for example with a click on {@link ~LinkFormView#cancelButtonView}. + * Fired when the form view is canceled, for example with a click on {@link ~LinkFormView#backButtonView}. * * @eventName ~LinkFormView#cancel */ diff --git a/packages/ckeditor5-link/src/ui/linkpreviewbuttonview.ts b/packages/ckeditor5-link/src/ui/linkpreviewbuttonview.ts new file mode 100644 index 00000000000..04649def674 --- /dev/null +++ b/packages/ckeditor5-link/src/ui/linkpreviewbuttonview.ts @@ -0,0 +1,70 @@ +/** + * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options + */ + +/** + * @module link/ui/linkpreviewbuttonview + */ + +import { ButtonView } from 'ckeditor5/src/ui.js'; +import type { Locale } from 'ckeditor5/src/utils.js'; + +/** + * The link button class. Rendered as an `` tag with link opening in a new tab. + * + * Provides a custom `navigate` cancelable event. + */ +export default class LinkPreviewButtonView extends ButtonView { + /** + * The value of the "href" attribute of the link. + * + * @observable + */ + declare public href: string | undefined; + + /** + * @inheritDoc + */ + constructor( locale: Locale ) { + super( locale ); + + const bind = this.bindTemplate; + + this.set( { + href: undefined, + withText: true + } ); + + this.extendTemplate( { + attributes: { + class: [ 'ck-link-toolbar__preview' ], + href: bind.to( 'href' ), + target: '_blank', + rel: 'noopener noreferrer' + }, + + on: { + click: bind.to( evt => { + if ( this.href ) { + const cancel = () => evt.preventDefault(); + + this.fire( 'navigate', this.href, cancel ); + } + } ) + } + } ); + + this.template!.tag = 'a'; + } +} + +/** + * Fired when the button view is clicked. + * + * @eventName ~LinkPreviewButtonView#navigate + */ +export type LinkPreviewButtonNavigateEvent = { + name: 'navigate'; + args: [ href: string, cancel: () => void ]; +}; diff --git a/packages/ckeditor5-link/src/ui/linkpropertiesview.ts b/packages/ckeditor5-link/src/ui/linkpropertiesview.ts new file mode 100644 index 00000000000..d1a541e39d0 --- /dev/null +++ b/packages/ckeditor5-link/src/ui/linkpropertiesview.ts @@ -0,0 +1,236 @@ +/** + * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options + */ + +/** + * @module link/ui/linkpropertiesview + */ + +import { + ButtonView, + FocusCycler, + FormHeaderView, + View, + ViewCollection, + ListView, + ListItemView, + type SwitchButtonView, + type FocusableView +} from 'ckeditor5/src/ui.js'; +import { + FocusTracker, + KeystrokeHandler, + type Locale +} from 'ckeditor5/src/utils.js'; +import { icons } from 'ckeditor5/src/core.js'; + +import '../../theme/linkproperties.css'; + +/** + * The link properties view controller class. + * + * See {@link module:link/ui/linkpropertiesview~LinkPropertiesView}. + */ +export default class LinkPropertiesView extends View { + /** + * Tracks information about DOM focus in the form. + */ + public readonly focusTracker = new FocusTracker(); + + /** + * An instance of the {@link module:utils/keystrokehandler~KeystrokeHandler}. + */ + public readonly keystrokes = new KeystrokeHandler(); + + /** + * The Back button view displayed in the header. + */ + public backButtonView: ButtonView; + + /** + * A collection of child views. + */ + public readonly children: ViewCollection; + + /** + * A collection of {@link module:ui/button/switchbuttonview~SwitchButtonView}, + * which corresponds to {@link module:link/linkcommand~LinkCommand#manualDecorators manual decorators} + * configured in the editor. + */ + public readonly listChildren: ViewCollection; + + /** + * A collection of views that can be focused in the form. + */ + private readonly _focusables = new ViewCollection(); + + /** + * Helps cycling over {@link #_focusables} in the form. + */ + private readonly _focusCycler: FocusCycler; + + /** + * Creates an instance of the {@link module:link/ui/linkpropertiesview~LinkPropertiesView} class. + * + * Also see {@link #render}. + * + * @param locale The localization services instance. + */ + constructor( locale: Locale ) { + super( locale ); + + this.backButtonView = this._createBackButton(); + this.listChildren = this.createCollection(); + + this.children = this.createCollection( [ + this._createHeaderView(), + this._createListView() + ] ); + + this._focusCycler = new FocusCycler( { + focusables: this._focusables, + focusTracker: this.focusTracker, + keystrokeHandler: this.keystrokes, + actions: { + // Navigate form fields backwards using the Shift + Tab keystroke. + focusPrevious: 'shift + tab', + + // Navigate form fields forwards using the Tab key. + focusNext: 'tab' + } + } ); + + this.setTemplate( { + tag: 'div', + + attributes: { + class: [ + 'ck', + 'ck-link-properties' + ], + + // https://github.com/ckeditor/ckeditor5-link/issues/90 + tabindex: '-1' + }, + + children: this.children + } ); + + // Close the panel on esc key press when the **form has focus**. + this.keystrokes.set( 'Esc', ( data, cancel ) => { + this.fire( 'back' ); + cancel(); + } ); + } + + /** + * @inheritDoc + */ + public override render(): void { + super.render(); + + const childViews = [ + ...this.listChildren, + this.backButtonView + ]; + + childViews.forEach( v => { + // Register the view as focusable. + this._focusables.add( v ); + + // Register the view in the focus tracker. + this.focusTracker.add( v.element! ); + } ); + + // Start listening for the keystrokes coming from #element. + this.keystrokes.listenTo( this.element! ); + } + + /** + * @inheritDoc + */ + public override destroy(): void { + super.destroy(); + + this.focusTracker.destroy(); + this.keystrokes.destroy(); + } + + /** + * Focuses the fist {@link #_focusables} in the form. + */ + public focus(): void { + this._focusCycler.focusFirst(); + } + + /** + * Creates a back button view. + */ + private _createBackButton(): ButtonView { + const t = this.locale!.t; + const backButton = new ButtonView( this.locale ); + + // TODO: maybe we should have a dedicated BackButtonView in the UI library. + backButton.set( { + class: 'ck-button-back', + label: t( 'Back' ), + icon: icons.previousArrow, + tooltip: true + } ); + + backButton.delegate( 'execute' ).to( this, 'back' ); + + return backButton; + } + + /** + * Creates a header view for the form. + */ + private _createHeaderView(): FormHeaderView { + const t = this.locale!.t; + + const header = new FormHeaderView( this.locale, { + label: t( 'Link properties' ) + } ); + + header.children.add( this.backButtonView, 0 ); + + return header; + } + + /** + * Creates a form view that displays the {@link #listChildren} collection. + */ + private _createListView(): ListView { + const listView = new ListView( this.locale ); + + listView.extendTemplate( { + attributes: { + class: [ + 'ck-link__list' + ] + } + } ); + + listView.items.bindTo( this.listChildren ).using( item => { + const listItemView = new ListItemView( this.locale ); + + listItemView.children.add( item ); + + return listItemView; + } ); + + return listView; + } +} + +/** + * Fired when the {@link ~LinkPropertiesView#backButtonView} is pressed. + * + * @eventName ~LinkPropertiesView#back + */ +export type BackEvent = { + name: 'back'; + args: []; +}; diff --git a/packages/ckeditor5-link/src/ui/linkprovideritemsview.ts b/packages/ckeditor5-link/src/ui/linkprovideritemsview.ts new file mode 100644 index 00000000000..37a9c3ff94a --- /dev/null +++ b/packages/ckeditor5-link/src/ui/linkprovideritemsview.ts @@ -0,0 +1,302 @@ +/** + * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options + */ + +/** + * @module link/ui/linkprovideritemsview + */ + +import { + ButtonView, + FocusCycler, + FormHeaderView, + View, + ListView, + ListItemView, + ViewCollection, + type FocusableView +} from 'ckeditor5/src/ui.js'; + +import { + FocusTracker, + KeystrokeHandler, + type Locale +} from 'ckeditor5/src/utils.js'; + +import { icons } from 'ckeditor5/src/core.js'; + +import '../../theme/linkprovideritems.css'; + +/** + * The link provider items view. + */ +export default class LinkProviderItemsView extends View { + /** + * Tracks information about the list of links. + * + * @observable + */ + declare public hasItems: boolean; + + /** + * The header label of the view. + * + * @observable + */ + declare public title: string; + + /** + * The text displayed when no links are available. + * + * @observable + */ + declare public emptyListPlaceholder: string; + + /** + * Tracks information about DOM focus in the form. + */ + public readonly focusTracker = new FocusTracker(); + + /** + * An instance of the {@link module:utils/keystrokehandler~KeystrokeHandler}. + */ + public readonly keystrokes = new KeystrokeHandler(); + + /** + * The Back button view displayed in the header. + */ + public backButtonView: ButtonView; + + /** + * The List view of links buttons. + */ + public listView: ListView; + + /** + * The collection of child views, which is bind with the `listView`. + */ + public readonly listChildren: ViewCollection; + + /** + * The view displayed when the list is empty. + */ + public emptyListInformation: View; + + /** + * A collection of child views. + */ + public children: ViewCollection; + + /** + * A collection of views that can be focused in the form. + */ + private readonly _focusables = new ViewCollection(); + + /** + * Helps cycling over {@link #_focusables} in the form. + */ + private readonly _focusCycler: FocusCycler; + + /** + * Creates an instance of the {@link module:link/ui/linkprovideritemsview~LinkProviderItemsView} class. + * + * Also see {@link #render}. + * + * @param locale The localization services instance. + */ + constructor( locale: Locale ) { + super( locale ); + + this.listChildren = this.createCollection(); + + this.backButtonView = this._createBackButton(); + this.listView = this._createListView(); + this.emptyListInformation = this._createEmptyLinksListItemView(); + + this.children = this.createCollection( [ + this._createHeaderView(), + this.emptyListInformation + ] ); + + this.set( 'title', '' ); + this.set( 'emptyListPlaceholder', '' ); + this.set( 'hasItems', false ); + + this.listenTo( this.listChildren, 'change', () => { + this.hasItems = this.listChildren.length > 0; + } ); + + this.on( 'change:hasItems', ( evt, propName, hasItems ) => { + if ( hasItems ) { + this.children.remove( this.emptyListInformation ); + this.children.add( this.listView ); + } else { + this.children.remove( this.listView ); + this.children.add( this.emptyListInformation ); + } + } ); + + // Close the panel on esc key press when the **form has focus**. + this.keystrokes.set( 'Esc', ( data, cancel ) => { + this.fire( 'cancel' ); + cancel(); + } ); + + this._focusCycler = new FocusCycler( { + focusables: this._focusables, + focusTracker: this.focusTracker, + keystrokeHandler: this.keystrokes, + actions: { + // Navigate form fields backwards using the Shift + Tab keystroke. + focusPrevious: 'shift + tab', + + // Navigate form fields forwards using the Tab key. + focusNext: 'tab' + } + } ); + + this.setTemplate( { + tag: 'div', + + attributes: { + class: [ + 'ck', + 'ck-link-providers' + ], + + // https://github.com/ckeditor/ckeditor5-link/issues/90 + tabindex: '-1' + }, + + children: this.children + } ); + } + + /** + * @inheritDoc + */ + public override render(): void { + super.render(); + + const childViews = [ + this.listView, + this.backButtonView + ]; + + childViews.forEach( v => { + // Register the view as focusable. + this._focusables.add( v ); + + // Register the view in the focus tracker. + this.focusTracker.add( v.element! ); + } ); + + // Start listening for the keystrokes coming from #element. + this.keystrokes.listenTo( this.element! ); + } + + /** + * @inheritDoc + */ + public override destroy(): void { + super.destroy(); + + this.focusTracker.destroy(); + this.keystrokes.destroy(); + } + + /** + * Focuses the fist {@link #_focusables} in the form. + */ + public focus(): void { + this._focusCycler.focusFirst(); + } + + /** + * Creates a view for the list at the bottom. + */ + private _createListView(): ListView { + const listView = new ListView( this.locale ); + + listView.extendTemplate( { + attributes: { + class: [ + 'ck-link-providers__list' + ] + } + } ); + + listView.items.bindTo( this.listChildren ).using( button => { + const listItemView = new ListItemView( this.locale ); + + listItemView.children.add( button ); + + return listItemView; + } ); + + return listView; + } + + /** + * Creates a back button view that cancels the form. + */ + private _createBackButton(): ButtonView { + const t = this.locale!.t; + const backButton = new ButtonView( this.locale ); + + backButton.set( { + class: 'ck-button-back', + label: t( 'Back' ), + icon: icons.previousArrow, + tooltip: true + } ); + + backButton.delegate( 'execute' ).to( this, 'cancel' ); + + return backButton; + } + + /** + * Creates a header view for the form. + */ + private _createHeaderView(): FormHeaderView { + const header = new FormHeaderView( this.locale ); + + header.bind( 'label' ).to( this, 'title' ); + header.children.add( this.backButtonView, 0 ); + + return header; + } + + /** + * Creates an info view for an empty list. + */ + private _createEmptyLinksListItemView(): View { + const view = new View( this.locale ); + + view.setTemplate( { + tag: 'p', + attributes: { + class: [ 'ck', 'ck-link__empty-list-info' ] + }, + children: [ + { + text: this.bindTemplate.to( 'emptyListPlaceholder' ) + } + ] + } ); + + return view; + } +} + +/** + * Fired when the links view is canceled, for example with a click on {@link ~LinkProviderItemsView#backButtonView}. + * + * @eventName ~LinkProviderItemsView#cancel + */ +export type CancelEvent = { + name: 'cancel'; + args: []; +}; diff --git a/packages/ckeditor5-link/src/utils.ts b/packages/ckeditor5-link/src/utils.ts index ccab2fe1192..a8e299bc726 100644 --- a/packages/ckeditor5-link/src/utils.ts +++ b/packages/ckeditor5-link/src/utils.ts @@ -15,12 +15,11 @@ import type { Schema, ViewAttributeElement, ViewNode, - ViewDocumentFragment + ViewDocumentFragment, + Range } 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, @@ -28,8 +27,6 @@ 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 @@ -200,44 +197,22 @@ export function openLink( link: string ): void { } /** - * Creates the bookmark callbacks for handling link opening experience. + * Returns a text of a link range. + * + * If the returned value is `undefined`, it means that the range contains elements other than text nodes. */ -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 ) ); - } +export function extractTextFromLinkRange( range: Range ): string | undefined { + let text = ''; - /** - * 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 - } ); + for ( const item of range.getItems() ) { + if ( !item.is( '$text' ) && !item.is( '$textProxy' ) ) { + return; + } + + text += item.data; } - return { - isScrollableToTarget, - scrollToTarget - }; + return text; } export type NormalizedLinkDecoratorAutomaticDefinition = LinkDecoratorAutomaticDefinition & { id: string }; diff --git a/packages/ckeditor5-link/tests/linkcommand.js b/packages/ckeditor5-link/tests/linkcommand.js index d7fa3a7b1ed..8c9d5a0e928 100644 --- a/packages/ckeditor5-link/tests/linkcommand.js +++ b/packages/ckeditor5-link/tests/linkcommand.js @@ -8,12 +8,13 @@ import LinkCommand from '../src/linkcommand.js'; import ManualDecorator from '../src/utils/manualdecorator.js'; import { setData, getData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model.js'; import AutomaticDecorators from '../src/utils/automaticdecorators.js'; +import LinkEditing from '../src/linkediting.js'; describe( 'LinkCommand', () => { let editor, model, command; beforeEach( () => { - return ModelTestEditor.create() + return ModelTestEditor.create( { plugins: [ LinkEditing ] } ) .then( newEditor => { editor = newEditor; model = editor.model; @@ -21,7 +22,15 @@ describe( 'LinkCommand', () => { model.schema.extend( '$text', { allowIn: '$root', - allowAttributes: [ 'linkHref', 'bold' ] + allowAttributes: [ 'linkHref', 'bold', 'italic' ] + } ); + + editor.model.schema.setAttributeProperties( 'bold', { + isFormatting: true + } ); + + editor.model.schema.setAttributeProperties( 'italic', { + isFormatting: true } ); model.schema.register( 'paragraph', { inheritAllFrom: '$block' } ); @@ -293,6 +302,17 @@ describe( 'LinkCommand', () => { expect( command.value ).to.equal( 'url' ); } ); + it( 'should set `linkHref` attribute to selected text and change the selected text', () => { + setData( model, 'f[ooba]r' ); + + expect( command.value ).to.be.undefined; + + command.execute( 'url', {}, 'xyz' ); + + expect( getData( model ) ).to.equal( 'f[<$text linkHref="url">xyz]r' ); + expect( command.value ).to.equal( 'url' ); + } ); + it( 'should set `linkHref` attribute to selected text when text already has attributes', () => { setData( model, 'f[o<$text bold="true">oba]r' ); @@ -361,6 +381,18 @@ describe( 'LinkCommand', () => { expect( command.value ).to.equal( 'url' ); } ); + it( 'should overwrite existing `linkHref` attribute and displayed text' + + 'when whole text with `linkHref` attribute is selected', () => { + setData( model, 'fo[<$text linkHref="other url">o]bar' ); + + expect( command.value ).to.equal( 'other url' ); + + command.execute( 'url', {}, 'xyz' ); + + expect( getData( model ) ).to.equal( 'fo[<$text linkHref="url">xyz]bar' ); + expect( command.value ).to.equal( 'url' ); + } ); + it( 'should set `linkHref` attribute to selected text when text is split by $block element', () => { setData( model, 'f[ooba]r' ); @@ -374,6 +406,38 @@ describe( 'LinkCommand', () => { expect( command.value ).to.equal( 'url' ); } ); + it( 'should update all links which is equal its href if selection is on more than one element', () => { + setData( model, + '' + + 'abc<$text linkHref="foo">[foo' + + '' + + '' + + '<$text linkHref="foo">foo' + + '' + + '' + + '<$text linkHref="foo">www' + + '' + + 'baz]xyz' + ); + + command.execute( 'bar123' ); + + expect( getData( model ) ).to.equal( + '' + + 'abc[<$text linkHref="bar123">bar123' + + '' + + '' + + '<$text linkHref="bar123">bar123' + + '' + + '' + + '<$text linkHref="bar123">www' + + '' + + '' + + '<$text linkHref="bar123">baz]xyz' + + '' + ); + } ); + describe( 'for block elements allowing linkHref', () => { it( 'should set `linkHref` attribute to allowed elements', () => { model.schema.register( 'linkableBlock', { @@ -548,6 +612,87 @@ describe( 'LinkCommand', () => { '' ); } ); + + it( 'should set `linkHref` attribute to allowed elements and not allow to change the displayed text', () => { + model.schema.register( 'linkableInline', { + isObject: true, + isInline: true, + allowWhere: '$text', + allowAttributes: [ 'linkHref' ] + } ); + + setData( model, 'f[ooba]r' ); + + expect( command.value ).to.be.undefined; + + command.execute( 'url', {}, 'xyz' ); + + expect( getData( model ) ).to.equal( + '' + + 'f[<$text linkHref="url">oo' + + '' + + '<$text linkHref="url">ba]r' + + '' + ); + + expect( command.value ).to.equal( 'url' ); + } ); + + describe( 'keep formatting attributes', () => { + it( 'should correctly replace selected text with keeping the text attributes', () => { + setData( model, 'foo[<$text bold="true" linkHref="url">textBar]baz' ); + + command.execute( 'url2', {}, 'abc' ); + + expect( getData( model ) ).to.equal( 'foo[<$text bold="true" linkHref="url2">abc]baz' ); + } ); + + it( 'should keep formatting attributes in a partially replaced link text (changed link)', () => { + setData( model, 'f<$text linkHref="foo" bold="true">o[ob]ar' ); + + command.execute( 'url', {}, 'xyz' ); + + expect( getData( model ) ).to.equal( + 'f<$text bold="true" linkHref="foo">o' + + '[<$text bold="true" linkHref="url">xyz]' + + '<$text bold="true" linkHref="foo">ar' + ); + } ); + + it( 'should keep formatting attributes in a partially replaced link text (the same link)', () => { + setData( model, 'f<$text linkHref="foo" bold="true">o[ob]ar' ); + + command.execute( 'foo', {}, 'xyz' ); + + expect( getData( model ) ).to.equal( 'f<$text bold="true" linkHref="foo">o[xyz]ar' ); + } ); + + it( 'should keep formatting attributes of a entirely deleted link text', () => { + setData( model, 'f<$text linkHref="foo" bold="true">[ooba]r' ); + + command.execute( 'url', {}, 'xyz' ); + + expect( getData( model ) ).to.equal( 'f[<$text bold="true" linkHref="url">xyz]r' ); + } ); + + it( 'should not keep selection attributes if link is removed and selection starts on unformatted text', () => { + setData( model, 'f[oo<$text linkHref="foo" bold="true">ba]r' ); + + command.execute( 'url', {}, 'xyz' ); + + expect( getData( model ) ).to.equal( 'f[<$text linkHref="url">xyz]r' ); + } ); + + it( 'should preserve the position of formatting attributes in complex text replacement', () => { + setData( model, '[<$text linkHref="url">Hello <$text bold="true" linkHref="url">World]' ); + + command.execute( 'url2', {}, 'Replacement Text' ); + + expect( getData( model ) ).to.equal( + '[<$text linkHref="url2">Replacement <$text bold="true" linkHref="url2">Text]' + ); + } ); + } ); } ); } ); @@ -572,12 +717,12 @@ describe( 'LinkCommand', () => { ); } ); - it( 'should update `linkHref` attribute (text with `linkHref` attribute) and put the selection after the node', () => { + it( 'should update `linkHref` attribute (text with `linkHref` attribute) and keep the selection as was', () => { setData( model, '<$text linkHref="other url">foo[]bar' ); command.execute( 'url' ); - expect( getData( model ) ).to.equal( '<$text linkHref="url">foobar[]' ); + expect( getData( model ) ).to.equal( '<$text linkHref="url">foo[]bar' ); } ); it( 'should not insert text with `linkHref` attribute when is not allowed in parent', () => { @@ -597,7 +742,7 @@ describe( 'LinkCommand', () => { it( 'should not insert text node if link is empty', () => { setData( model, 'foo[]bar' ); - command.execute( '' ); + command.execute( '', {}, '' ); expect( getData( model ) ).to.equal( 'foo[]bar' ); } ); @@ -612,17 +757,244 @@ describe( 'LinkCommand', () => { expect( getData( model ) ).to.equal( '<$text linkHref="url">foourl[]bar' ); } ); + + it( 'should overwrite existing `linkHref` attribute and displayed text', () => { + setData( model, 'fo<$text linkHref="other url">o[]bar' ); + + expect( command.value ).to.equal( 'other url' ); + + command.execute( 'url', {}, 'xyz' ); + + expect( getData( model ) ).to.equal( 'fo<$text linkHref="url">xyz[]ar' ); + } ); + + describe( 'keep formatting attributes (with TwoStepCaretMovement plugin available)', () => { + it( 'should maintain all formatting when changing only text inside a link', () => { + setData( model, 'foo<$text bold="true" linkHref="url" italic="true">te[]xtbar' ); + + command.execute( 'url', {}, 'new text' ); + + expect( getData( model ) ).to.equal( + 'foo<$text bold="true" italic="true" linkHref="url">new text[]bar' + ); + } ); + + it( 'should preserve mixed formatting before and after link text change', () => { + setData( model, + 'foo' + + '<$text bold="true" linkHref="url">bo[]ld' + + '<$text italic="true" linkHref="url">italic' + + 'bar' + ); + + command.execute( 'url', {}, 'new text' ); + + expect( getData( model ) ).to.equal( + 'foo<$text bold="true" linkHref="url">new ' + + '<$text italic="true" linkHref="url">text' + + '[]bar' + ); + } ); + + it( 'should keep formatting attributes when changing URL but keeping the same text', () => { + setData( model, 'foo<$text bold="true" linkHref="url" italic="true">te[]xtbar' ); + + command.execute( 'url2' ); + + expect( getData( model ) ).to.equal( + 'foo<$text bold="true" italic="true" linkHref="url2">te[]xtbar' + ); + } ); + + it( 'should properly handle adjacent text nodes with different formatting', () => { + setData( model, + 'foo' + + '<$text bold="true" linkHref="url">bold' + + '<$text italic="true" linkHref="url">ita[]lic' + + 'bar' + ); + + command.execute( 'url', {}, 'new' ); + + expect( getData( model ) ).to.equal( + 'foo<$text bold="true" linkHref="url">new[]bar' + ); + } ); + + it( 'should update link range in text with mixed formatting attributes', () => { + setData( model, + 'foo' + + '<$text bold="true">bo' + + '<$text bold="true" linkHref="url">ld []text' + + '<$text italic="true" linkHref="url">in link' + + 'bar' + ); + + command.execute( 'url2', {}, 'replacement' ); + + expect( getData( model ) ).to.equal( + 'foo<$text bold="true">bo' + + '<$text bold="true" linkHref="url2">replaceme' + + '<$text italic="true" linkHref="url2">nt' + + '[]bar' + ); + } ); + + it( 'should be possible to prepend single character to the display text of the link' + + 'if the selection is in the middle', () => { + setData( model, '<$text linkHref="url" bold="true">Lin[]k text' ); + + command.execute( 'url', {}, 'LLink text' ); + + expect( getData( model ) ).to.equal( '<$text bold="true" linkHref="url">LLink text[]' ); + } ); + + it( 'should be possible to prepend single character to the display text of the link' + + 'if the selection is in the end', () => { + setData( model, '<$text linkHref="url" bold="true">Link text[]' ); + + command.execute( 'url', {}, 'LLink text' ); + + expect( getData( model ) ).to.equal( '<$text bold="true" linkHref="url">LLink text[]' ); + } ); + + it( 'should be possible to append single character to the display text of the link' + + 'if the selection is in the middle', () => { + setData( model, '<$text linkHref="url" bold="true">Lin[]k text' ); + + command.execute( 'url', {}, 'Link textL' ); + + expect( getData( model ) ).to.equal( '<$text bold="true" linkHref="url">Link textL[]' ); + } ); + + it( 'should be possible to append single character to the display text of the link' + + 'if the selection is in the start', () => { + setData( model, '<$text linkHref="url" bold="true">[]Link text' ); + + command.execute( 'url', {}, 'LLink text' ); + + expect( getData( model ) ).to.equal( '<$text bold="true" linkHref="url">LLink text[]' ); + } ); + } ); + + describe( 'keep formatting attributes (without TwoStepCaretMovement plugin)', () => { + beforeEach( async () => { + await editor.destroy(); + + return ModelTestEditor.create() + .then( newEditor => { + editor = newEditor; + model = editor.model; + command = new LinkCommand( editor ); + + model.schema.extend( '$text', { + allowIn: '$root', + allowAttributes: [ 'linkHref', 'bold', 'italic' ] + } ); + + editor.model.schema.setAttributeProperties( 'bold', { + isFormatting: true + } ); + + editor.model.schema.setAttributeProperties( 'italic', { + isFormatting: true + } ); + + model.schema.register( 'paragraph', { inheritAllFrom: '$block' } ); + } ); + } ); + + afterEach( () => { + return editor.destroy(); + } ); + + it( 'should maintain all formatting when changing only text inside a link', () => { + setData( model, 'foo<$text bold="true" linkHref="url" italic="true">te[]xtbar' ); + + command.execute( 'url', {}, 'new text' ); + + expect( getData( model ) ).to.equal( + 'foo<$text bold="true" italic="true" linkHref="url">new text' + + '<$text bold="true" italic="true">[]bar' + ); + } ); + + it( 'should preserve mixed formatting before and after link text change', () => { + setData( model, + 'foo' + + '<$text bold="true" linkHref="url">bo[]ld' + + '<$text italic="true" linkHref="url">italic' + + 'bar' + ); + + command.execute( 'url', {}, 'new text' ); + + expect( getData( model ) ).to.equal( + 'foo<$text bold="true" linkHref="url">new ' + + '<$text italic="true" linkHref="url">text' + + '<$text italic="true">[]' + + 'bar' + ); + } ); + + it( 'should keep formatting attributes when changing URL but keeping the same text', () => { + setData( model, 'foo<$text bold="true" linkHref="url" italic="true">te[]xtbar' ); + + command.execute( 'url2' ); + + expect( getData( model ) ).to.equal( + 'foo<$text bold="true" italic="true" linkHref="url2">te[]xtbar' + ); + } ); + + it( 'should properly handle adjacent text nodes with different formatting', () => { + setData( model, + 'foo' + + '<$text bold="true" linkHref="url">bold' + + '<$text italic="true" linkHref="url">ita[]lic' + + 'bar' + ); + + command.execute( 'url', {}, 'new' ); + + expect( getData( model ) ).to.equal( + 'foo<$text bold="true" linkHref="url">new' + + '<$text bold="true">[]' + + 'bar' + ); + } ); + + it( 'should update link range in text with mixed formatting attributes', () => { + setData( model, + 'foo' + + '<$text bold="true">bo' + + '<$text bold="true" linkHref="url">ld []text' + + '<$text italic="true" linkHref="url">in link' + + 'bar' + ); + + command.execute( 'url2', {}, 'replacement' ); + + expect( getData( model ) ).to.equal( + 'foo<$text bold="true">bo' + + '<$text bold="true" linkHref="url2">replaceme' + + '<$text italic="true" linkHref="url2">nt' + + '<$text italic="true">[]' + + 'bar' + ); + } ); + } ); } ); } ); describe( 'manual decorators', () => { beforeEach( async () => { await editor.destroy(); - return ModelTestEditor.create() + return ModelTestEditor.create( { plugins: [ LinkEditing ] } ) .then( newEditor => { editor = newEditor; - model = editor.model; - command = new LinkCommand( editor ); + model = newEditor.model; + command = new LinkCommand( newEditor ); command.manualDecorators.add( new ManualDecorator( { id: 'linkIsFoo', @@ -690,7 +1062,7 @@ describe( 'LinkCommand', () => { command.execute( 'url', { linkIsFoo: true, linkIsBar: true, linkIsSth: true } ); expect( getData( model ) ).to - .equal( 'f<$text linkHref="url" linkIsBar="true" linkIsFoo="true" linkIsSth="true">ooba[]r' ); + .equal( 'f<$text linkHref="url" linkIsBar="true" linkIsFoo="true" linkIsSth="true">o[]obar' ); } ); it( 'should remove additional attributes to link if those are falsy', () => { @@ -698,7 +1070,7 @@ describe( 'LinkCommand', () => { command.execute( 'url', { linkIsFoo: false, linkIsBar: false } ); - expect( getData( model ) ).to.equal( 'foo<$text linkHref="url">url[]bar' ); + expect( getData( model ) ).to.equal( 'foo<$text linkHref="url">u[]rlbar' ); } ); it( 'should update content if href is equal to content', () => { @@ -768,7 +1140,7 @@ describe( 'LinkCommand', () => { ); } ); - it( 'should update content if href is equal to content', () => { + it( 'should not update content even if href is equal to content', () => { setData( model, '[<$text linkHref="url">url]' ); command.execute( 'url2', { linkIsFoo: true, linkIsBar: true, linkIsSth: true } ); @@ -807,30 +1179,6 @@ describe( 'LinkCommand', () => { expect( getData( model ) ).to .equal( '[<$text linkHref="url2">url2]' ); } ); - - it( 'should not update link which is equal its href if selection is on more than one element', () => { - setData( model, - '' + - '<$text linkHref="foo">[foo' + - '' + - 'bar' + - 'baz]' - ); - - command.execute( 'foooo' ); - - expect( getData( model ) ).to - .equal( '' + - '[<$text linkHref="foooo">foo' + - '' + - '' + - '<$text linkHref="foooo">bar' + - '' + - '' + - '<$text linkHref="foooo">baz]' + - '' - ); - } ); } ); describe( 'restoreManualDecoratorStates()', () => { diff --git a/packages/ckeditor5-link/tests/linkediting.js b/packages/ckeditor5-link/tests/linkediting.js index a65dbe29d91..f0cf5b7c82b 100644 --- a/packages/ckeditor5-link/tests/linkediting.js +++ b/packages/ckeditor5-link/tests/linkediting.js @@ -21,7 +21,6 @@ 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'; @@ -1049,101 +1048,37 @@ describe( 'LinkEditing', () => { } ); 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[]' + - '' - ); - - 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[]' + - '' - ); + let view, editor, model, element; - fireClickEvent( { metaKey: true, ctrlKey: false }, editor, view ); + beforeEach( async () => { + element = document.createElement( 'div' ); + document.body.appendChild( element ); - 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; + editor = await ClassicTestEditor.create( element, { + plugins: [ Essentials, Paragraph, LinkEditing ] } ); - it( 'should open link when there is none of them', () => { - setModelData( model, - '<$text linkHref="#foo">Bar[]' - ); - - 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; - } ); + model = editor.model; + view = editor.editing.view; } ); - 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(); + afterEach( () => { + element.remove(); - return editor.destroy(); - } ); + return editor.destroy(); + } ); - it( 'should open link', () => { - setModelData( model, - '<$text linkHref="#foo">Bar[]' - ); + it( 'should open link', () => { + setModelData( model, + '<$text linkHref="#foo">Bar[]' + ); - fireClickEvent( { metaKey: true, ctrlKey: false }, editor, view ); + 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; - } ); + 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; } ); } ); } ); @@ -1180,101 +1115,37 @@ describe( 'LinkEditing', () => { } ); 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[]' + - '' - ); - - 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[]' + - '' - ); + let view, editor, model, element; - fireClickEvent( { metaKey: false, ctrlKey: true }, editor, view ); + beforeEach( async () => { + element = document.createElement( 'div' ); + document.body.appendChild( element ); - 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; + editor = await ClassicTestEditor.create( element, { + plugins: [ Essentials, Paragraph, LinkEditing ] } ); - it( 'should open link when there is none of them', () => { - setModelData( model, - '<$text linkHref="#foo">Bar[]' - ); - - 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; - } ); + model = editor.model; + view = editor.editing.view; } ); - 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(); + afterEach( () => { + element.remove(); - return editor.destroy(); - } ); + return editor.destroy(); + } ); - it( 'should open link', () => { - setModelData( model, - '<$text linkHref="#foo">Bar[]' - ); + it( 'should open link', () => { + setModelData( model, + '<$text linkHref="#foo">Bar[]' + ); - fireClickEvent( { metaKey: false, ctrlKey: true }, editor, view ); + 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; - } ); + 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; } ); } ); } ); @@ -1317,18 +1188,6 @@ describe( 'LinkEditing', () => { expect( stub.notCalled ).to.be.true; expect( eventPreventDefault.calledOnce ).to.be.false; } ); - - function fireClickEvent( options, editor, view, tagName = 'a' ) { - const linkElement = editor.ui.getEditableElement().getElementsByTagName( tagName )[ 0 ]; - - eventPreventDefault = sinon.spy(); - - view.document.fire( 'click', { - domTarget: linkElement, - domEvent: options, - preventDefault: eventPreventDefault - } ); - } } ); describe( 'using keyboard', () => { @@ -1388,117 +1247,190 @@ describe( 'LinkEditing', () => { } ); describe( 'when href starts with `#`', () => { - describe( 'and Bookmark plugin is loaded', () => { - let view, editor, model, element; + 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 ] - } ); + beforeEach( async () => { + element = document.createElement( 'div' ); + document.body.appendChild( element ); - model = editor.model; - view = editor.editing.view; + editor = await ClassicTestEditor.create( element, { + plugins: [ Essentials, Paragraph, LinkEditing ] } ); - afterEach( () => { - element.remove(); + model = editor.model; + view = editor.editing.view; + } ); - return editor.destroy(); - } ); + afterEach( () => { + element.remove(); - it( 'should scroll to bookmark when bookmark `id` matches hash `url`', () => { - setModelData( model, - '<$text linkHref="#foo">Bar[]' + - '' - ); + return editor.destroy(); + } ); - fireEnterPressedEvent( { altKey: true }, view ); + it( 'should open link', () => { + setModelData( model, + '<$text linkHref="#foo">Bar[]' + ); - expect( stub.notCalled ).to.be.true; - expect( stub.calledOn( window ) ).to.be.false; - expect( eventPreventDefault.calledOnce ).to.be.true; - } ); + fireEnterPressedEvent( { altKey: true }, view ); - it( 'should open link when bookmark `id` does not matches hash `url`', () => { - setModelData( model, - '<$text linkHref="#foo">Bar[]' + - '' - ); + 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; + } ); + } ); + } ); - fireEnterPressedEvent( { altKey: true }, view ); + describe( 'custom link custom openers', () => { + let editing; - 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; - } ); + beforeEach( () => { + editing = editor.plugins.get( LinkEditing ); + env.isMac = false; + } ); - it( 'should open link when there is none of them', () => { - setModelData( model, - '<$text linkHref="#foo">Bar[]' - ); + describe( 'using mouse', () => { + it( 'should use default opener if there are no custom openers', () => { + setModelData( model, '<$text linkHref="http://www.ckeditor.com">Bar[]' ); + fireClickEvent( { metaKey: false, ctrlKey: true }, editor, view ); - fireEnterPressedEvent( { altKey: true }, view ); + expect( stub.calledOnce ).to.be.true; + expect( stub.calledWith( 'http://www.ckeditor.com', '_blank', 'noopener' ) ).to.be.true; + } ); - 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 not open link with custom opener that returns false', () => { + const opener = sinon.stub().returns( false ); + + editing._registerLinkOpener( opener ); + + setModelData( model, '<$text linkHref="http://www.ckeditor.com">Bar[]' ); + fireClickEvent( { metaKey: false, ctrlKey: true }, editor, view ); + + expect( opener.calledOnce ).to.be.true; + expect( opener.calledWith( 'http://www.ckeditor.com' ) ).to.be.true; + expect( stub ).to.be.called; } ); - describe( 'and Bookmark plugin is not loaded', () => { - let view, editor, model, element; + it( 'should open link with custom opener that returns true', () => { + const opener = sinon.stub().returns( true ); - beforeEach( async () => { - element = document.createElement( 'div' ); - document.body.appendChild( element ); + editing._registerLinkOpener( opener ); - editor = await ClassicTestEditor.create( element, { - plugins: [ Essentials, Paragraph, LinkEditing ] - } ); + setModelData( model, '<$text linkHref="http://www.ckeditor.com">Bar[]' ); + fireClickEvent( { metaKey: false, ctrlKey: true }, editor, view ); - model = editor.model; - view = editor.editing.view; - } ); + expect( opener.calledOnce ).to.be.true; + expect( opener.calledWith( 'http://www.ckeditor.com' ) ).to.be.true; + expect( stub ).not.to.be.called; + } ); - afterEach( () => { - element.remove(); + it( 'should pick the first opener that returns true', () => { + const openers = [ + sinon.stub().returns( false ), + sinon.stub().returns( true ), + sinon.stub().returns( true ) + ]; - return editor.destroy(); - } ); + for ( const opener of openers ) { + editing._registerLinkOpener( opener ); + } - it( 'should open link', () => { - setModelData( model, - '<$text linkHref="#foo">Bar[]' - ); + setModelData( model, '<$text linkHref="http://www.ckeditor.com">Bar[]' ); + fireClickEvent( { metaKey: false, ctrlKey: true }, editor, view ); - fireEnterPressedEvent( { altKey: true }, view ); + expect( openers[ 0 ] ).to.be.calledOnce; + expect( openers[ 1 ] ).to.be.calledOnce; + expect( openers[ 2 ] ).not.to.be.called; - 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; - } ); + expect( openers[ 0 ].calledBefore( openers[ 1 ] ) ).to.be.true; + expect( stub ).not.to.be.called; } ); } ); - function fireEnterPressedEvent( options, view ) { - view.document.fire( 'keydown', { - keyCode: keyCodes.enter, - domEvent: { - keyCode: keyCodes.enter, - preventDefault: () => {}, - target: document.body, - ...options - }, - ...options + describe( 'using keyboard', () => { + it( 'should use default opener if there are no custom openers', () => { + setModelData( model, '<$text linkHref="http://www.ckeditor.com">Bar[]' ); + fireEnterPressedEvent( { altKey: true }, view ); + + expect( stub.calledOnce ).to.be.true; + expect( stub.calledWith( 'http://www.ckeditor.com', '_blank', 'noopener' ) ).to.be.true; } ); - } + + it( 'should not open link with custom opener that returns false', () => { + const opener = sinon.stub().returns( false ); + + editing._registerLinkOpener( opener ); + + setModelData( model, '<$text linkHref="http://www.ckeditor.com">Bar[]' ); + fireEnterPressedEvent( { altKey: true }, view ); + + expect( opener.calledOnce ).to.be.true; + expect( opener.calledWith( 'http://www.ckeditor.com' ) ).to.be.true; + expect( stub ).to.be.called; + } ); + + it( 'should open link with custom opener that returns true', () => { + const opener = sinon.stub().returns( true ); + + editing._registerLinkOpener( opener ); + + setModelData( model, '<$text linkHref="http://www.ckeditor.com">Bar[]' ); + fireEnterPressedEvent( { altKey: true }, view ); + + expect( opener.calledOnce ).to.be.true; + expect( opener.calledWith( 'http://www.ckeditor.com' ) ).to.be.true; + expect( stub ).not.to.be.called; + } ); + + it( 'should pick the first opener that returns true', () => { + const openers = [ + sinon.stub().returns( false ), + sinon.stub().returns( true ), + sinon.stub().returns( true ) + ]; + + for ( const opener of openers ) { + editing._registerLinkOpener( opener ); + } + + setModelData( model, '<$text linkHref="http://www.ckeditor.com">Bar[]' ); + fireEnterPressedEvent( { altKey: true }, view ); + + expect( openers[ 0 ] ).to.be.calledOnce; + expect( openers[ 1 ] ).to.be.calledOnce; + expect( openers[ 2 ] ).not.to.be.called; + + expect( openers[ 0 ].calledBefore( openers[ 1 ] ) ).to.be.true; + expect( stub ).not.to.be.called; + } ); + } ); } ); + + function fireClickEvent( options, editor, view, tagName = 'a' ) { + const linkElement = editor.ui.getEditableElement().getElementsByTagName( tagName )[ 0 ]; + + eventPreventDefault = sinon.spy(); + + view.document.fire( 'click', { + domTarget: linkElement, + domEvent: options, + preventDefault: eventPreventDefault + } ); + } + + function fireEnterPressedEvent( options, view ) { + view.document.fire( 'keydown', { + keyCode: keyCodes.enter, + domEvent: { + keyCode: keyCodes.enter, + preventDefault: () => {}, + target: document.body, + ...options + }, + ...options + } ); + } } ); // https://github.com/ckeditor/ckeditor5/issues/1016 diff --git a/packages/ckeditor5-link/tests/linkimageui.js b/packages/ckeditor5-link/tests/linkimageui.js index 98816f47d3a..dd706ccbddf 100644 --- a/packages/ckeditor5-link/tests/linkimageui.js +++ b/packages/ckeditor5-link/tests/linkimageui.js @@ -203,7 +203,7 @@ describe( 'LinkImageUI', () => { } ); describe( 'when a block image is selected', () => { - it( 'should show plugin#actionsView after "execute" if an image is already linked', () => { + it( 'should show plugin#toolbarView after "execute" if an image is already linked', () => { const linkUIPlugin = editor.plugins.get( 'LinkUI' ); editor.setData( '
' ); @@ -217,7 +217,7 @@ describe( 'LinkImageUI', () => { linkButton.fire( 'execute' ); expect( linkUIPlugin._balloon.visibleView ).to.be.not.null; - expect( linkUIPlugin._balloon.visibleView ).to.equals( linkUIPlugin.actionsView ); + expect( linkUIPlugin._balloon.visibleView ).to.equals( linkUIPlugin.toolbarView ); } ); it( 'should show plugin#formView after "execute" if image is not linked', () => { @@ -236,7 +236,7 @@ describe( 'LinkImageUI', () => { } ); describe( 'when an inline image is selected', () => { - it( 'should show plugin#actionsView after "execute" if an image is already linked', () => { + it( 'should show plugin#toolbarView after "execute" if an image is already linked', () => { const linkUIPlugin = editor.plugins.get( 'LinkUI' ); editor.setData( '

' ); @@ -247,7 +247,7 @@ describe( 'LinkImageUI', () => { linkButton.fire( 'execute' ); - expect( linkUIPlugin._balloon.visibleView ).to.equals( linkUIPlugin.actionsView ); + expect( linkUIPlugin._balloon.visibleView ).to.equals( linkUIPlugin.toolbarView ); } ); it( 'should show plugin#formView after "execute" if image is not linked', () => { diff --git a/packages/ckeditor5-link/tests/linkui.js b/packages/ckeditor5-link/tests/linkui.js index 468d469d183..c4af915f366 100644 --- a/packages/ckeditor5-link/tests/linkui.js +++ b/packages/ckeditor5-link/tests/linkui.js @@ -3,9 +3,10 @@ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options */ -/* globals document, Event */ +/* globals document, window, Event */ import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor.js'; +import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor.js'; import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils.js'; import indexOf from '@ckeditor/ckeditor5-utils/src/dom/indexof.js'; @@ -17,6 +18,7 @@ import env from '@ckeditor/ckeditor5-utils/src/env.js'; import Essentials from '@ckeditor/ckeditor5-essentials/src/essentials.js'; import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph.js'; import BlockQuote from '@ckeditor/ckeditor5-block-quote/src/blockquote.js'; +import BoldEditing from '@ckeditor/ckeditor5-basic-styles/src/bold/boldediting.js'; import ClickObserver from '@ckeditor/ckeditor5-engine/src/view/observer/clickobserver.js'; import ContextualBalloon from '@ckeditor/ckeditor5-ui/src/panel/balloon/contextualballoon.js'; import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview.js'; @@ -26,35 +28,35 @@ import { toWidget } from '@ckeditor/ckeditor5-widget'; import LinkEditing from '../src/linkediting.js'; import LinkUI from '../src/linkui.js'; import LinkFormView from '../src/ui/linkformview.js'; -import LinkActionsView from '../src/ui/linkactionsview.js'; -import { MenuBarMenuListItemButtonView } from '@ckeditor/ckeditor5-ui'; +import LinkPreviewButtonView from '../src/ui/linkpreviewbuttonview.js'; +import LinkPropertiesView from '../src/ui/linkpropertiesview.js'; +import ManualDecorator from '../src/utils/manualdecorator.js'; +import { MenuBarMenuListItemButtonView, ToolbarView } from '@ckeditor/ckeditor5-ui'; import linkIcon from '../theme/icons/link.svg'; +import { icons } from '@ckeditor/ckeditor5-core'; describe( 'LinkUI', () => { - let editor, linkUIFeature, linkButton, balloon, formView, actionsView, editorElement; + let editor, model, linkUIFeature, linkButton, balloon, formView, toolbarView, editorElement, propertiesView; testUtils.createSinonSandbox(); - beforeEach( () => { + beforeEach( async () => { editorElement = document.createElement( 'div' ); document.body.appendChild( editorElement ); - return ClassicTestEditor - .create( editorElement, { - plugins: [ Essentials, LinkEditing, LinkUI, Paragraph, BlockQuote ] - } ) - .then( newEditor => { - editor = newEditor; + editor = await ClassicTestEditor.create( editorElement, { + plugins: [ Essentials, LinkEditing, LinkUI, Paragraph, BlockQuote, BoldEditing ] + } ); - linkUIFeature = editor.plugins.get( LinkUI ); - linkButton = editor.ui.componentFactory.create( 'link' ); - balloon = editor.plugins.get( ContextualBalloon ); + model = editor.model; + linkUIFeature = editor.plugins.get( LinkUI ); + linkButton = editor.ui.componentFactory.create( 'link' ); + balloon = editor.plugins.get( ContextualBalloon ); - // There is no point to execute BalloonPanelView attachTo and pin methods so lets override it. - testUtils.sinon.stub( balloon.view, 'attachTo' ).returns( {} ); - testUtils.sinon.stub( balloon.view, 'pin' ).returns( {} ); - } ); + // There is no point to execute BalloonPanelView attachTo and pin methods so lets override it. + testUtils.sinon.stub( balloon.view, 'attachTo' ).returns( {} ); + testUtils.sinon.stub( balloon.view, 'pin' ).returns( {} ); } ); afterEach( () => { @@ -99,8 +101,8 @@ describe( 'LinkUI', () => { expect( editor.editing.view.getObserver( ClickObserver ) ).to.be.instanceOf( ClickObserver ); } ); - it( 'should not create #actionsView', () => { - expect( linkUIFeature.actionsView ).to.be.null; + it( 'should not create #toolbarView', () => { + expect( linkUIFeature.toolbarView ).to.be.null; } ); it( 'should not create #formView', () => { @@ -162,7 +164,253 @@ describe( 'LinkUI', () => { command.isEnabled = !initState; expect( linkButton.isEnabled ).to.equal( !initState ); } ); + + it( 'should toggle the link UI with hidden back button', () => { + linkButton.fire( 'execute' ); + + expect( linkUIFeature.formView.backButtonView.isVisible ).to.be.false; + } ); + + it( 'should open on-top of the toolbar if the link is selected', () => { + setModelData( editor.model, '<$text linkHref="url">f[]oo' ); + + linkButton.fire( 'execute' ); + + expect( balloon.visibleView ).to.equal( linkUIFeature.formView ); + } ); } + + describe( 'the "linkPreview" toolbar button', () => { + let button; + + beforeEach( () => { + button = editor.ui.componentFactory.create( 'linkPreview' ); + } ); + + it( 'should be a LinkPreviewButtonView instance', () => { + expect( button ).to.be.instanceOf( LinkPreviewButtonView ); + } ); + + it( 'should bind "href" to link command value', () => { + const linkCommand = editor.commands.get( 'link' ); + + linkCommand.value = 'foo'; + expect( button.href ).to.equal( 'foo' ); + + linkCommand.value = 'bar'; + expect( button.href ).to.equal( 'bar' ); + } ); + + it( 'should not use unsafe href', () => { + const linkCommand = editor.commands.get( 'link' ); + + linkCommand.value = 'javascript:alert(1)'; + + expect( button.href ).to.equal( '#' ); + } ); + + it( 'should be enabled when command has a value', () => { + const linkCommand = editor.commands.get( 'link' ); + + linkCommand.value = null; + expect( button.isEnabled ).to.be.false; + + linkCommand.value = 'foo'; + expect( button.isEnabled ).to.be.true; + } ); + + it( 'should use tooltip text depending on the command value', () => { + const linkCommand = editor.commands.get( 'link' ); + + linkCommand.value = 'foo'; + expect( button.tooltip ).to.equal( 'Open link in new tab' ); + + linkCommand.value = '#foo'; + expect( button.tooltip ).to.equal( 'Open link in new tab' ); + } ); + + it( 'should not use icon', () => { + const linkCommand = editor.commands.get( 'link' ); + + linkCommand.value = 'foo'; + expect( button.icon ).to.equal( undefined ); + + linkCommand.value = '#foo'; + expect( button.icon ).to.equal( undefined ); + } ); + + it( 'should reset button labels when command is empty', () => { + const linkCommand = editor.commands.get( 'link' ); + + linkCommand.value = 'foo'; + expect( button.icon ).to.equal( undefined ); + + linkCommand.value = ''; + expect( button.icon ).to.equal( undefined ); + expect( button.label ).to.be.undefined; + expect( button.tooltip ).to.equal( 'Open link in new tab' ); + } ); + } ); + + describe( 'the "unlink" toolbar button', () => { + let button; + + beforeEach( () => { + button = editor.ui.componentFactory.create( 'unlink' ); + } ); + + it( 'should be a ButtonView instance', () => { + expect( button ).to.be.instanceOf( ButtonView ); + } ); + + it( 'should set button properties', () => { + expect( button.label ).to.equal( 'Unlink' ); + expect( button.tooltip ).to.be.true; + expect( button.icon ).to.not.be.undefined; + } ); + + it( 'should bind enabled state to unlink command', () => { + const unlinkCommand = editor.commands.get( 'unlink' ); + + unlinkCommand.isEnabled = true; + expect( button.isEnabled ).to.be.true; + + unlinkCommand.isEnabled = false; + expect( button.isEnabled ).to.be.false; + } ); + + it( 'should trigger unlink command and hide UI on execute', () => { + const unlinkCommand = editor.commands.get( 'unlink' ); + const stubCommand = sinon.stub( unlinkCommand, 'execute' ); + const stubHideUI = sinon.stub( linkUIFeature, '_hideUI' ); + + button.fire( 'execute' ); + + sinon.assert.calledOnce( stubCommand ); + sinon.assert.calledOnce( stubHideUI ); + } ); + } ); + + describe( 'the "editLink" toolbar button', () => { + let button; + + beforeEach( () => { + button = editor.ui.componentFactory.create( 'editLink' ); + } ); + + it( 'should be a ButtonView instance', () => { + expect( button ).to.be.instanceOf( ButtonView ); + } ); + + it( 'should set button properties', () => { + expect( button.label ).to.equal( 'Edit link' ); + expect( button.tooltip ).to.be.true; + expect( button.icon ).to.not.be.undefined; + } ); + + it( 'should bind enabled state to link command', () => { + const linkCommand = editor.commands.get( 'link' ); + + linkCommand.isEnabled = true; + expect( button.isEnabled ).to.be.true; + + linkCommand.isEnabled = false; + expect( button.isEnabled ).to.be.false; + } ); + + it( 'should add form view to the balloon on execute', () => { + const stubAddForm = sinon.stub( linkUIFeature, '_addFormView' ); + + button.fire( 'execute' ); + + sinon.assert.calledOnce( stubAddForm ); + } ); + + it( 'should open link form view with back button', () => { + const linkCommand = editor.commands.get( 'link' ); + + // Simulate link selection. + linkCommand.isEnabled = true; + linkCommand.value = 'http://ckeditor.com'; + + button.fire( 'execute' ); + + expect( linkUIFeature.formView.backButtonView.isVisible ).to.be.true; + } ); + } ); + + describe( 'the "linkProperties" toolbar button', () => { + let button; + + beforeEach( () => { + button = editor.ui.componentFactory.create( 'linkProperties' ); + editor.commands.get( 'link' ).manualDecorators.add( new ManualDecorator( { + id: 'linkIsBar', + label: 'Bar', + attributes: { + target: '_blank' + } + } ) ); + } ); + + it( 'should be a ButtonView instance', () => { + expect( button ).to.be.instanceOf( ButtonView ); + } ); + + it( 'should set button properties', () => { + expect( button.label ).to.equal( 'Link properties' ); + expect( button.tooltip ).to.be.true; + expect( button.icon ).to.not.be.undefined; + } ); + + it( 'should be disabled if link value is empty or command is disabled', () => { + const linkCommand = editor.commands.get( 'link' ); + + linkCommand.value = 'http://ckeditor.com'; + expect( button.isEnabled ).to.be.true; + + linkCommand.isEnabled = false; + expect( button.isEnabled ).to.be.false; + + linkCommand.isEnabled = true; + linkCommand.value = ''; + expect( button.isEnabled ).to.be.false; + + linkCommand.value = null; + expect( button.isEnabled ).to.be.false; + } ); + + it( 'should be disabled if there are no manual decorators', () => { + const linkCommand = editor.commands.get( 'link' ); + + linkCommand.isEnabled = false; + + expect( button.isEnabled ).to.be.false; + + linkCommand.manualDecorators.clear(); + linkCommand.isEnabled = true; + + expect( button.isEnabled ).to.be.false; + } ); + + it( 'should add properties view to the balloon on execute', () => { + const stubAddProperties = sinon.stub( linkUIFeature, '_addPropertiesView' ); + + button.fire( 'execute' ); + + sinon.assert.calledOnce( stubAddProperties ); + } ); + + it( 'should not be available in the toolbar if there are no manual decorators', () => { + let items = Array.from( linkUIFeature._createToolbarView().items ).map( item => item.label ); + + expect( items ).to.include( 'Link properties' ); + + editor.commands.get( 'link' ).manualDecorators.clear(); + items = Array.from( linkUIFeature._createToolbarView().items ).map( item => item.label ); + expect( items ).not.to.include( 'Link properties' ); + } ); + } ); } ); describe( '_showUI()', () => { @@ -173,12 +421,12 @@ describe( 'LinkUI', () => { editor.editing.view.document.isFocused = true; } ); - it( 'should create #actionsView', () => { + it( 'should create #toolbarView', () => { setModelData( editor.model, 'f[o]o' ); linkUIFeature._showUI(); - expect( linkUIFeature.actionsView ).to.be.instanceOf( LinkActionsView ); + expect( linkUIFeature.toolbarView ).to.be.instanceOf( ToolbarView ); } ); it( 'should create #formView', () => { @@ -223,7 +471,7 @@ describe( 'LinkUI', () => { setModelData( editor.model, 'f[]oo' ); linkUIFeature._showUI(); formView = linkUIFeature.formView; - actionsView = linkUIFeature.actionsView; + toolbarView = linkUIFeature.toolbarView; const expectedRange = getMarkersRange( editor ); @@ -237,7 +485,7 @@ describe( 'LinkUI', () => { assertDomRange( expectedRange, balloonAddSpy.args[ 0 ][ 0 ].position.target ); } ); - it( 'should add #actionsView to the balloon and attach the balloon to the link element when collapsed selection is inside ' + + it( 'should add #toolbarView to the balloon and attach the balloon to the link element when collapsed selection is inside ' + 'that link', () => { setModelData( editor.model, '<$text linkHref="url">f[]oo' ); @@ -245,31 +493,31 @@ describe( 'LinkUI', () => { linkUIFeature._showUI(); formView = linkUIFeature.formView; - actionsView = linkUIFeature.actionsView; + toolbarView = linkUIFeature.toolbarView; - expect( balloon.visibleView ).to.equal( actionsView ); + expect( balloon.visibleView ).to.equal( toolbarView ); const addSpyCallArgs = balloonAddSpy.firstCall.args[ 0 ]; - expect( addSpyCallArgs.view ).to.equal( actionsView ); + expect( addSpyCallArgs.view ).to.equal( toolbarView ); expect( addSpyCallArgs.position.target ).to.be.a( 'function' ); expect( addSpyCallArgs.position.target() ).to.equal( linkElement ); } ); // #https://github.com/ckeditor/ckeditor5-link/issues/181 - it( 'should add #formView to the balloon when collapsed selection is inside the link and #actionsView is already visible', () => { + it( 'should add #formView to the balloon when collapsed selection is inside the link and #toolbarView is already visible', () => { setModelData( editor.model, '<$text linkHref="url">f[]oo' ); const linkElement = editor.editing.view.getDomRoot().querySelector( 'a' ); linkUIFeature._showUI(); formView = linkUIFeature.formView; - actionsView = linkUIFeature.actionsView; + toolbarView = linkUIFeature.toolbarView; - expect( balloon.visibleView ).to.equal( actionsView ); + expect( balloon.visibleView ).to.equal( toolbarView ); const addSpyFirstCallArgs = balloonAddSpy.firstCall.args[ 0 ]; - expect( addSpyFirstCallArgs.view ).to.equal( actionsView ); + expect( addSpyFirstCallArgs.view ).to.equal( toolbarView ); expect( addSpyFirstCallArgs.position.target ).to.be.a( 'function' ); expect( addSpyFirstCallArgs.position.target() ).to.equal( linkElement ); @@ -282,12 +530,15 @@ describe( 'LinkUI', () => { expect( addSpyCallSecondCallArgs.position.target() ).to.equal( linkElement ); } ); - it( 'should disable #formView and #actionsView elements when link and unlink commands are disabled', () => { + it( 'should disable #formView and #toolbarView elements when link and unlink commands are disabled', () => { setModelData( editor.model, 'f[o]o' ); linkUIFeature._showUI(); formView = linkUIFeature.formView; - actionsView = linkUIFeature.actionsView; + toolbarView = linkUIFeature.toolbarView; + + const editButtonView = toolbarView.items.get( 2 ); + const unlinkButtonView = toolbarView.items.get( 3 ); formView.urlInputView.fieldView.value = 'ckeditor.com'; @@ -297,10 +548,9 @@ describe( 'LinkUI', () => { expect( formView.urlInputView.isEnabled ).to.be.true; expect( formView.urlInputView.fieldView.isReadOnly ).to.be.false; expect( formView.saveButtonView.isEnabled ).to.be.true; - expect( formView.cancelButtonView.isEnabled ).to.be.true; - expect( actionsView.unlinkButtonView.isEnabled ).to.be.true; - expect( actionsView.editButtonView.isEnabled ).to.be.true; + expect( unlinkButtonView.isEnabled ).to.be.true; + expect( editButtonView.isEnabled ).to.be.true; editor.commands.get( 'link' ).isEnabled = false; editor.commands.get( 'unlink' ).isEnabled = false; @@ -308,17 +558,16 @@ describe( 'LinkUI', () => { expect( formView.urlInputView.isEnabled ).to.be.false; expect( formView.urlInputView.fieldView.isReadOnly ).to.be.true; expect( formView.saveButtonView.isEnabled ).to.be.false; - expect( formView.cancelButtonView.isEnabled ).to.be.true; - expect( actionsView.unlinkButtonView.isEnabled ).to.be.false; - expect( actionsView.editButtonView.isEnabled ).to.be.false; + expect( unlinkButtonView.isEnabled ).to.be.false; + expect( editButtonView.isEnabled ).to.be.false; } ); // https://github.com/ckeditor/ckeditor5-link/issues/78 it( 'should make sure the URL input in the #formView always stays in sync with the value of the command (selected link)', () => { linkUIFeature._createViews(); formView = linkUIFeature.formView; - actionsView = linkUIFeature.actionsView; + toolbarView = linkUIFeature.toolbarView; formView.render(); setModelData( editor.model, '<$text linkHref="url">f[]oo' ); @@ -327,7 +576,7 @@ describe( 'LinkUI', () => { linkUIFeature._showUI(); // Simulate clicking the "edit" button. - actionsView.fire( 'edit' ); + toolbarView.items.get( 2 ).fire( 'execute' ); // Change text in the URL field. formView.urlInputView.fieldView.element.value = 'to-be-discarded'; @@ -336,7 +585,7 @@ describe( 'LinkUI', () => { formView.fire( 'cancel' ); // Open the editing panel again. - actionsView.fire( 'edit' ); + toolbarView.items.get( 2 ).fire( 'execute' ); // Expect original value in the URL field. expect( formView.urlInputView.fieldView.element.value ).to.equal( 'url' ); @@ -353,7 +602,7 @@ describe( 'LinkUI', () => { it( 'should make sure the URL input in the #formView always stays in sync with the value of the command (no link selected)', () => { linkUIFeature._createViews(); formView = linkUIFeature.formView; - actionsView = linkUIFeature.actionsView; + toolbarView = linkUIFeature.toolbarView; formView.render(); setModelData( editor.model, 'f[]oo' ); @@ -365,7 +614,7 @@ describe( 'LinkUI', () => { it( 'should optionally force `main` stack to be visible', () => { linkUIFeature._createViews(); formView = linkUIFeature.formView; - actionsView = linkUIFeature.actionsView; + toolbarView = linkUIFeature.toolbarView; formView.render(); setModelData( editor.model, 'f[o]o' ); @@ -384,7 +633,7 @@ describe( 'LinkUI', () => { it( 'should update balloon position when is switched in rotator to a visible panel', () => { linkUIFeature._createViews(); formView = linkUIFeature.formView; - actionsView = linkUIFeature.actionsView; + toolbarView = linkUIFeature.toolbarView; formView.render(); setModelData( editor.model, 'fo<$text linkHref="foo">o[] bar' ); @@ -394,7 +643,7 @@ describe( 'LinkUI', () => { const linkViewElement = editor.editing.view.document.getRoot().getChild( 0 ).getChild( 1 ); const linkDomElement = editor.editing.view.domConverter.mapViewToDom( linkViewElement ); - expect( balloon.visibleView ).to.equal( actionsView ); + expect( balloon.visibleView ).to.equal( toolbarView ); expect( balloon.view.pin.lastCall.args[ 0 ].target() ).to.equal( linkDomElement ); balloon.add( { @@ -406,12 +655,12 @@ describe( 'LinkUI', () => { balloon.showStack( 'custom' ); expect( balloon.visibleView ).to.equal( customView ); - expect( balloon.hasView( actionsView ) ).to.equal( true ); + expect( balloon.hasView( toolbarView ) ).to.equal( true ); editor.execute( 'blockQuote' ); balloon.showStack( 'main' ); - expect( balloon.visibleView ).to.equal( actionsView ); + expect( balloon.visibleView ).to.equal( toolbarView ); expect( balloon.hasView( customView ) ).to.equal( true ); expect( balloon.view.pin.lastCall.args[ 0 ].target() ).to.not.equal( linkDomElement ); @@ -422,12 +671,12 @@ describe( 'LinkUI', () => { } ); describe( 'form status', () => { - it( 'should update ui on error due to change ballon position', () => { + it( 'should update ui on error due to change balloon position', () => { const updateSpy = sinon.spy( editor.ui, 'update' ); linkUIFeature._createViews(); formView = linkUIFeature.formView; - actionsView = linkUIFeature.actionsView; + toolbarView = linkUIFeature.toolbarView; formView.render(); setModelData( editor.model, '[foo]' ); @@ -442,7 +691,7 @@ describe( 'LinkUI', () => { it( 'should show error form status if passed empty link', () => { linkUIFeature._createViews(); formView = linkUIFeature.formView; - actionsView = linkUIFeature.actionsView; + toolbarView = linkUIFeature.toolbarView; formView.render(); setModelData( editor.model, '[foo]' ); @@ -456,7 +705,7 @@ describe( 'LinkUI', () => { it( 'should reset error form status after filling empty link', () => { linkUIFeature._createViews(); formView = linkUIFeature.formView; - actionsView = linkUIFeature.actionsView; + toolbarView = linkUIFeature.toolbarView; formView.render(); setModelData( editor.model, '[foo]' ); @@ -475,7 +724,7 @@ describe( 'LinkUI', () => { it( 'should reset form status on show', () => { linkUIFeature._createViews(); formView = linkUIFeature.formView; - actionsView = linkUIFeature.actionsView; + toolbarView = linkUIFeature.toolbarView; formView.render(); setModelData( editor.model, '[foo]' ); @@ -564,7 +813,7 @@ describe( 'LinkUI', () => { it( 'not update the position when is in not visible stack', () => { linkUIFeature._createViews(); formView = linkUIFeature.formView; - actionsView = linkUIFeature.actionsView; + toolbarView = linkUIFeature.toolbarView; formView.render(); setModelData( editor.model, '<$text linkHref="url">f[]oo' ); @@ -582,7 +831,7 @@ describe( 'LinkUI', () => { balloon.showStack( 'custom' ); expect( balloon.visibleView ).to.equal( customView ); - expect( balloon.hasView( actionsView ) ).to.equal( true ); + expect( balloon.hasView( toolbarView ) ).to.equal( true ); const spy = testUtils.sinon.spy( balloon, 'updatePosition' ); @@ -907,28 +1156,28 @@ describe( 'LinkUI', () => { } } ); - describe( '_addActionsView()', () => { + describe( '_addToolbarView()', () => { beforeEach( () => { editor.editing.view.document.isFocused = true; } ); - it( 'should create #actionsView', () => { + it( 'should create #toolbarView', () => { setModelData( editor.model, 'f[o]o' ); - linkUIFeature._addActionsView(); + linkUIFeature._addToolbarView(); - expect( linkUIFeature.actionsView ).to.be.instanceOf( LinkActionsView ); + expect( linkUIFeature.toolbarView ).to.be.instanceOf( ToolbarView ); } ); - it( 'should add #actionsView to the balloon and attach the balloon to the link element when collapsed selection is inside ' + + it( 'should add #toolbarView to the balloon and attach the balloon to the link element when collapsed selection is inside ' + 'that link', () => { setModelData( editor.model, '<$text linkHref="url">f[]oo' ); - linkUIFeature._addActionsView(); - actionsView = linkUIFeature.actionsView; + linkUIFeature._addToolbarView(); + toolbarView = linkUIFeature.toolbarView; - expect( balloon.visibleView ).to.equal( actionsView ); + expect( balloon.visibleView ).to.equal( toolbarView ); } ); } ); @@ -963,22 +1212,66 @@ describe( 'LinkUI', () => { } ); } ); + describe( '_addPropertiesView()', () => { + beforeEach( () => { + editor.editing.view.document.isFocused = true; + editor.commands.get( 'link' ).manualDecorators.add( new ManualDecorator( { + id: 'linkIsBar', + label: 'Bar', + attributes: { + target: '_blank' + } + } ) ); + } ); + + it( 'should create #propertiesView', () => { + setModelData( editor.model, 'f[o]o' ); + + linkUIFeature._showUI(); + + expect( linkUIFeature.propertiesView ).to.be.instanceOf( LinkPropertiesView ); + } ); + + it( 'should add #propertiesView to the balloon and attach the balloon', () => { + setModelData( editor.model, 'f[o]o' ); + + linkUIFeature._addPropertiesView(); + propertiesView = linkUIFeature.propertiesView; + + expect( balloon.visibleView ).to.equal( propertiesView ); + } ); + + it( 'should not add #propertiesView to the balloon again when it is already added', () => { + setModelData( editor.model, 'f[o]o' ); + + linkUIFeature._addPropertiesView(); + propertiesView = linkUIFeature.propertiesView; + + const addSpy = sinon.spy( balloon, 'add' ); + + linkUIFeature._addPropertiesView(); + + expect( addSpy ).not.to.be.called; + expect( balloon.visibleView ).to.equal( propertiesView ); + } ); + } ); + describe( '_hideUI()', () => { beforeEach( () => { linkUIFeature._showUI(); formView = linkUIFeature.formView; - actionsView = linkUIFeature.actionsView; + toolbarView = linkUIFeature.toolbarView; } ); it( 'should remove the UI from the balloon', () => { expect( balloon.hasView( formView ) ).to.be.true; - expect( balloon.hasView( actionsView ) ).to.be.true; + expect( balloon.hasView( toolbarView ) ).to.be.true; linkUIFeature._hideUI(); expect( balloon.hasView( formView ) ).to.be.false; - expect( balloon.hasView( actionsView ) ).to.be.false; + expect( balloon.hasView( toolbarView ) ).to.be.false; } ); it( 'should focus the `editable` by default', () => { @@ -1043,11 +1336,11 @@ describe( 'LinkUI', () => { beforeEach( () => { // Make sure that forms are lazy initiated. expect( linkUIFeature.formView ).to.be.null; - expect( linkUIFeature.actionsView ).to.be.null; + expect( linkUIFeature.toolbarView ).to.be.null; linkUIFeature._createViews(); formView = linkUIFeature.formView; - actionsView = linkUIFeature.actionsView; + toolbarView = linkUIFeature.toolbarView; formView.render(); } ); @@ -1138,7 +1431,7 @@ describe( 'LinkUI', () => { stopPropagation: sinon.spy() } ); - expect( balloon.visibleView ).to.equal( actionsView ); + expect( balloon.visibleView ).to.equal( toolbarView ); editor.keystrokes.press( { keyCode: keyCodes.k, @@ -1151,7 +1444,7 @@ describe( 'LinkUI', () => { expect( balloon.visibleView ).to.equal( formView ); } ); - it( 'should focus the the #actionsView on `Tab` key press when #actionsView is visible', () => { + it( 'should focus the the #toolbarView on `Tab` key press when #toolbarView is visible', () => { const keyEvtData = { keyCode: keyCodes.tab, preventDefault: sinon.spy(), @@ -1164,9 +1457,9 @@ describe( 'LinkUI', () => { editor.keystrokes.set( 'Tab', highestPriorityTabCallbackSpy, { priority: 'highest' } ); // Balloon is invisible, form not focused. - actionsView.focusTracker.isFocused = false; + toolbarView.focusTracker.isFocused = false; - const spy = sinon.spy( actionsView, 'focus' ); + const spy = sinon.spy( toolbarView, 'focus' ); editor.keystrokes.press( keyEvtData ); sinon.assert.notCalled( keyEvtData.preventDefault ); @@ -1177,9 +1470,9 @@ describe( 'LinkUI', () => { // Balloon is visible, form focused. linkUIFeature._showUI(); - testUtils.sinon.stub( linkUIFeature, '_areActionsVisible' ).value( true ); + testUtils.sinon.stub( linkUIFeature, '_isToolbarVisible' ).value( true ); - actionsView.focusTracker.isFocused = true; + toolbarView.focusTracker.isFocused = true; editor.keystrokes.press( keyEvtData ); sinon.assert.notCalled( keyEvtData.preventDefault ); @@ -1189,7 +1482,7 @@ describe( 'LinkUI', () => { sinon.assert.calledTwice( highestPriorityTabCallbackSpy ); // Balloon is still visible, form not focused. - actionsView.focusTracker.isFocused = false; + toolbarView.focusTracker.isFocused = false; editor.keystrokes.press( keyEvtData ); sinon.assert.calledOnce( keyEvtData.preventDefault ); @@ -1199,6 +1492,57 @@ describe( 'LinkUI', () => { sinon.assert.calledThrice( highestPriorityTabCallbackSpy ); } ); + describe( 'toolbar cycling on Alt+F10', () => { + let editor, editorElement; + + beforeEach( async () => { + editorElement = document.createElement( 'div' ); + document.body.appendChild( editorElement ); + + editor = await ClassicEditor.create( editorElement, { + plugins: [ Essentials, LinkEditing, LinkUI, Paragraph, BlockQuote ], + toolbar: [ 'link' ] + } ); + + linkUIFeature = editor.plugins.get( LinkUI ); + linkButton = editor.ui.componentFactory.create( 'link' ); + balloon = editor.plugins.get( ContextualBalloon ); + } ); + + afterEach( async () => { + await editor.destroy(); + editorElement.remove(); + } ); + + it( 'should focus the link toolbar on Alt+F10', () => { + linkUIFeature._createViews(); + + setModelData( editor.model, '<$text linkHref="foo">b[]ar' ); + editor.ui.focusTracker.isFocused = true; + + const focusSpy = sinon.spy( linkUIFeature.toolbarView, 'focus' ); + + expect( linkUIFeature._isToolbarVisible ).to.be.false; + pressAltF10(); + + expect( linkUIFeature._isToolbarVisible ).to.be.true; + sinon.assert.calledOnce( focusSpy ); + + pressAltF10(); + expect( linkUIFeature._isToolbarVisible ).to.be.false; + sinon.assert.calledOnce( focusSpy ); + } ); + + function pressAltF10() { + editor.keystrokes.press( { + keyCode: keyCodes.f10, + altKey: true, + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + } ); + } + } ); + it( 'should hide the UI after Esc key press (from editor) and not focus the editable', () => { const spy = testUtils.sinon.spy( linkUIFeature, '_hideUI' ); const keyEvtData = { @@ -1242,11 +1586,11 @@ describe( 'LinkUI', () => { beforeEach( () => { // Make sure that forms are lazy initiated. expect( linkUIFeature.formView ).to.be.null; - expect( linkUIFeature.actionsView ).to.be.null; + expect( linkUIFeature.toolbarView ).to.be.null; linkUIFeature._createViews(); formView = linkUIFeature.formView; - actionsView = linkUIFeature.actionsView; + toolbarView = linkUIFeature.toolbarView; formView.render(); } ); @@ -1271,7 +1615,7 @@ describe( 'LinkUI', () => { linkUIFeature._showUI(); // Be sure any of link view is not currently visible/ - expect( balloon.visibleView ).to.not.equal( actionsView ); + expect( balloon.visibleView ).to.not.equal( toolbarView ); expect( balloon.visibleView ).to.not.equal( formView ); document.body.dispatchEvent( new Event( 'mousedown', { bubbles: true } ) ); @@ -1415,42 +1759,42 @@ describe( 'LinkUI', () => { } ); } ); - describe( 'actions view', () => { + describe( 'actions/toolbar view', () => { let focusEditableSpy; beforeEach( () => { // Make sure that forms are lazy initiated. expect( linkUIFeature.formView ).to.be.null; - expect( linkUIFeature.actionsView ).to.be.null; + expect( linkUIFeature.toolbarView ).to.be.null; linkUIFeature._createViews(); formView = linkUIFeature.formView; - actionsView = linkUIFeature.actionsView; + toolbarView = linkUIFeature.toolbarView; formView.render(); focusEditableSpy = testUtils.sinon.spy( editor.editing.view, 'focus' ); } ); - it( 'should mark the editor UI as focused when the #actionsView is focused', () => { + it( 'should mark the editor UI as focused when the #toolbarView is focused', () => { linkUIFeature._showUI(); linkUIFeature._removeFormView(); - expect( balloon.visibleView ).to.equal( actionsView ); + expect( balloon.visibleView ).to.equal( toolbarView ); editor.ui.focusTracker.isFocused = false; - actionsView.element.dispatchEvent( new Event( 'focus' ) ); + toolbarView.element.dispatchEvent( new Event( 'focus' ) ); expect( editor.ui.focusTracker.isFocused ).to.be.true; } ); describe( 'binding', () => { - it( 'should show the #formView on #edit event and select the URL input field', () => { + it( 'should show the #formView on edit button click and select the URL input field', () => { linkUIFeature._showUI(); linkUIFeature._removeFormView(); const selectSpy = testUtils.sinon.spy( formView.urlInputView.fieldView, 'select' ); - actionsView.fire( 'edit' ); + toolbarView.items.get( 2 ).fire( 'execute' ); expect( balloon.visibleView ).to.equal( formView ); sinon.assert.calledOnce( selectSpy ); @@ -1463,27 +1807,27 @@ describe( 'LinkUI', () => { const enableCssTransitionsSpy = sinon.spy( formView, 'enableCssTransitions' ); const selectSpy = sinon.spy( formView.urlInputView.fieldView, 'select' ); - actionsView.fire( 'edit' ); + toolbarView.items.get( 2 ).fire( 'execute' ); sinon.assert.callOrder( disableCssTransitionsSpy, addSpy, selectSpy, enableCssTransitionsSpy ); } ); - it( 'should execute unlink command on actionsView#unlink event', () => { + it( 'should execute unlink command on link edit button click', () => { const executeSpy = testUtils.sinon.spy( editor, 'execute' ); - actionsView.fire( 'unlink' ); + toolbarView.items.get( 3 ).fire( 'execute' ); expect( executeSpy.calledOnce ).to.be.true; expect( executeSpy.calledWithExactly( 'unlink' ) ).to.be.true; } ); - it( 'should hide and focus editable on actionsView#unlink event', () => { + it( 'should hide and focus editable on unlink button click', () => { linkUIFeature._showUI(); linkUIFeature._removeFormView(); // Removing the form would call the focus spy. focusEditableSpy.resetHistory(); - actionsView.fire( 'unlink' ); + toolbarView.items.get( 3 ).fire( 'execute' ); expect( balloon.visibleView ).to.be.null; expect( focusEditableSpy.calledOnce ).to.be.true; @@ -1502,7 +1846,7 @@ describe( 'LinkUI', () => { // Removing the form would call the focus spy. focusEditableSpy.resetHistory(); - actionsView.keystrokes.press( keyEvtData ); + toolbarView.keystrokes.press( keyEvtData ); expect( balloon.visibleView ).to.equal( null ); expect( focusEditableSpy.calledOnce ).to.be.true; } ); @@ -1519,9 +1863,9 @@ describe( 'LinkUI', () => { linkUIFeature._showUI(); linkUIFeature._removeFormView(); - expect( balloon.visibleView ).to.equal( actionsView ); + expect( balloon.visibleView ).to.equal( toolbarView ); - actionsView.keystrokes.press( keyEvtData ); + toolbarView.keystrokes.press( keyEvtData ); expect( balloon.visibleView ).to.equal( formView ); } ); } ); @@ -1530,62 +1874,36 @@ describe( 'LinkUI', () => { describe( 'link form view', () => { let focusEditableSpy; - const createEditorWithDefaultProtocol = defaultProtocol => { - return ClassicTestEditor - .create( editorElement, { - plugins: [ LinkEditing, LinkUI, Paragraph, BlockQuote ], - link: { defaultProtocol } - } ) - .then( editor => { - const linkUIFeature = editor.plugins.get( LinkUI ); - - linkUIFeature._createViews(); - - const formView = linkUIFeature.formView; - - formView.render(); - - editor.model.schema.extend( '$text', { - allowIn: '$root', - allowAttributes: 'linkHref' - } ); - - return { editor, formView }; - } ); - }; + const createEditorWithLinkConfig = async link => { + const editor = await ClassicTestEditor.create( editorElement, { + plugins: [ LinkEditing, LinkUI, Paragraph, BlockQuote ], + link + } ); - const createEditorWithEmptyLinks = allowCreatingEmptyLinks => { - return ClassicTestEditor - .create( editorElement, { - plugins: [ LinkEditing, LinkUI, Paragraph, BlockQuote ], - link: { allowCreatingEmptyLinks } - } ) - .then( editor => { - const linkUIFeature = editor.plugins.get( LinkUI ); + const linkUIFeature = editor.plugins.get( LinkUI ); - linkUIFeature._createViews(); + linkUIFeature._createViews(); - const formView = linkUIFeature.formView; + const formView = linkUIFeature.formView; - formView.render(); + formView.render(); - editor.model.schema.extend( '$text', { - allowIn: '$root', - allowAttributes: 'linkHref' - } ); + editor.model.schema.extend( '$text', { + allowIn: '$root', + allowAttributes: 'linkHref' + } ); - return { editor, formView }; - } ); + return { editor, formView }; }; beforeEach( () => { // Make sure that forms are lazy initiated. expect( linkUIFeature.formView ).to.be.null; - expect( linkUIFeature.actionsView ).to.be.null; + expect( linkUIFeature.toolbarView ).to.be.null; linkUIFeature._createViews(); formView = linkUIFeature.formView; - actionsView = linkUIFeature.actionsView; + toolbarView = linkUIFeature.toolbarView; formView.render(); @@ -1609,52 +1927,48 @@ describe( 'LinkUI', () => { expect( allowCreatingEmptyLinks ).to.equal( false ); } ); - it( 'should allow enabling empty links', () => { - return createEditorWithEmptyLinks( true ).then( ( { editor } ) => { - const allowCreatingEmptyLinks = editor.config.get( 'link.allowCreatingEmptyLinks' ); + it( 'should allow enabling empty links', async () => { + const { editor } = await createEditorWithLinkConfig( { allowCreatingEmptyLinks: true } ); + const allowCreatingEmptyLinks = editor.config.get( 'link.allowCreatingEmptyLinks' ); - expect( allowCreatingEmptyLinks ).to.equal( true ); + expect( allowCreatingEmptyLinks ).to.equal( true ); - return editor.destroy(); - } ); + return editor.destroy(); } ); - it( 'should not allow submitting empty form when link is required', () => { - return createEditorWithEmptyLinks( false ).then( ( { editor, formView } ) => { - const executeSpy = sinon.spy( editor, 'execute' ); + it( 'should not allow submitting empty form when link is required', async () => { + const { editor, formView } = await createEditorWithLinkConfig( { allowCreatingEmptyLinks: false } ); + const executeSpy = sinon.spy( editor, 'execute' ); - formView.urlInputView.fieldView.value = ''; - formView.fire( 'submit' ); + formView.urlInputView.fieldView.value = ''; + formView.fire( 'submit' ); - expect( executeSpy ).not.to.be.called; - return editor.destroy(); - } ); + expect( executeSpy ).not.to.be.called; + return editor.destroy(); } ); - it( 'should allow submitting empty form when link is not required', () => { - return createEditorWithEmptyLinks( true ).then( ( { editor, formView } ) => { - expect( formView.saveButtonView.isEnabled ).to.be.true; + it( 'should allow submitting empty form when link is not required', async () => { + const { editor, formView } = await createEditorWithLinkConfig( { allowCreatingEmptyLinks: true } ); - return editor.destroy(); - } ); + expect( formView.saveButtonView.isEnabled ).to.be.true; + + return editor.destroy(); } ); } ); describe( 'link protocol', () => { - it( 'should use a default link protocol from the `config.link.defaultProtocol` when provided', () => { - return ClassicTestEditor - .create( editorElement, { - link: { - defaultProtocol: 'https://' - } - } ) - .then( editor => { - const defaultProtocol = editor.config.get( 'link.defaultProtocol' ); + it( 'should use a default link protocol from the `config.link.defaultProtocol` when provided', async () => { + const editor = await ClassicTestEditor.create( editorElement, { + link: { + defaultProtocol: 'https://' + } + } ); - expect( defaultProtocol ).to.equal( 'https://' ); + const defaultProtocol = editor.config.get( 'link.defaultProtocol' ); - return editor.destroy(); - } ); + expect( defaultProtocol ).to.equal( 'https://' ); + + return editor.destroy(); } ); it( 'should not add a protocol without the configuration', () => { @@ -1668,96 +1982,96 @@ describe( 'LinkUI', () => { return editor.destroy(); } ); - it( 'should not add a protocol to the local links even when `config.link.defaultProtocol` configured', () => { - return createEditorWithDefaultProtocol( 'http://' ).then( ( { editor, formView } ) => { - const linkCommandSpy = sinon.spy( editor.commands.get( 'link' ), 'execute' ); - formView.urlInputView.fieldView.value = '#test'; - formView.fire( 'submit' ); + it( 'should not add a protocol to the local links even when `config.link.defaultProtocol` configured', async () => { + const { editor, formView } = await createEditorWithLinkConfig( { defaultProtocol: 'http://' } ); + const linkCommandSpy = sinon.spy( editor.commands.get( 'link' ), 'execute' ); - sinon.assert.calledWith( linkCommandSpy, '#test', sinon.match.any ); + formView.urlInputView.fieldView.value = '#test'; + formView.fire( 'submit' ); - return editor.destroy(); - } ); + sinon.assert.calledWith( linkCommandSpy, '#test', sinon.match.any ); + + return editor.destroy(); } ); - it( 'should not add a protocol to the relative links even when `config.link.defaultProtocol` configured', () => { - return createEditorWithDefaultProtocol( 'http://' ).then( ( { editor, formView } ) => { - const linkCommandSpy = sinon.spy( editor.commands.get( 'link' ), 'execute' ); - formView.urlInputView.fieldView.value = '/test.html'; - formView.fire( 'submit' ); + it( 'should not add a protocol to the relative links even when `config.link.defaultProtocol` configured', async () => { + const { editor, formView } = await createEditorWithLinkConfig( { defaultProtocol: 'http://' } ); + const linkCommandSpy = sinon.spy( editor.commands.get( 'link' ), 'execute' ); - sinon.assert.calledWith( linkCommandSpy, '/test.html', sinon.match.any ); + formView.urlInputView.fieldView.value = '/test.html'; + formView.fire( 'submit' ); - return editor.destroy(); - } ); + sinon.assert.calledWith( linkCommandSpy, '/test.html', sinon.match.any ); + + return editor.destroy(); } ); - it( 'should not add a protocol when given provided within the value even when `config.link.defaultProtocol` configured', () => { - return createEditorWithDefaultProtocol( 'http://' ).then( ( { editor, formView } ) => { - const linkCommandSpy = sinon.spy( editor.commands.get( 'link' ), 'execute' ); - formView.urlInputView.fieldView.value = 'http://example.com'; - formView.fire( 'submit' ); + it( 'should not add a protocol when given provided within the value ' + + 'even when `config.link.defaultProtocol` configured', async () => { + const { editor, formView } = await createEditorWithLinkConfig( { defaultProtocol: 'http://' } ); + const linkCommandSpy = sinon.spy( editor.commands.get( 'link' ), 'execute' ); + + formView.urlInputView.fieldView.value = 'http://example.com'; + formView.fire( 'submit' ); - sinon.assert.calledWith( linkCommandSpy, 'http://example.com', sinon.match.any ); + sinon.assert.calledWith( linkCommandSpy, 'http://example.com', sinon.match.any ); - return editor.destroy(); - } ); + return editor.destroy(); } ); - it( 'should use the "http://" protocol when it\'s configured', () => { - return createEditorWithDefaultProtocol( 'http://' ).then( ( { editor, formView } ) => { - const linkCommandSpy = sinon.spy( editor.commands.get( 'link' ), 'execute' ); + it( 'should use the "http://" protocol when it\'s configured', async () => { + const { editor, formView } = await createEditorWithLinkConfig( { defaultProtocol: 'http://' } ); + const linkCommandSpy = sinon.spy( editor.commands.get( 'link' ), 'execute' ); - formView.urlInputView.fieldView.value = 'ckeditor.com'; - formView.fire( 'submit' ); + formView.urlInputView.fieldView.value = 'ckeditor.com'; + formView.fire( 'submit' ); - sinon.assert.calledWith( linkCommandSpy, 'http://ckeditor.com', sinon.match.any ); + sinon.assert.calledWith( linkCommandSpy, 'http://ckeditor.com', sinon.match.any ); - return editor.destroy(); - } ); + return editor.destroy(); } ); - it( 'should use the "http://" protocol when it\'s configured and form input value contains "www."', () => { - return createEditorWithDefaultProtocol( 'http://' ).then( ( { editor, formView } ) => { - const linkCommandSpy = sinon.spy( editor.commands.get( 'link' ), 'execute' ); + it( 'should use the "http://" protocol when it\'s configured and form input value contains "www."', async () => { + const { editor, formView } = await createEditorWithLinkConfig( { defaultProtocol: 'http://' } ); + const linkCommandSpy = sinon.spy( editor.commands.get( 'link' ), 'execute' ); - formView.urlInputView.fieldView.value = 'www.ckeditor.com'; - formView.fire( 'submit' ); + formView.urlInputView.fieldView.value = 'www.ckeditor.com'; + formView.fire( 'submit' ); - sinon.assert.calledWith( linkCommandSpy, 'http://www.ckeditor.com', sinon.match.any ); + sinon.assert.calledWith( linkCommandSpy, 'http://www.ckeditor.com', sinon.match.any ); - return editor.destroy(); - } ); + return editor.destroy(); } ); - it( 'should propagate the protocol to the link\'s `linkHref` attribute in model', () => { - return createEditorWithDefaultProtocol( 'http://' ).then( ( { editor, formView } ) => { - setModelData( editor.model, '[ckeditor.com]' ); + it( 'should propagate the protocol to the link\'s `linkHref` attribute in model', async () => { + const { editor, formView } = await createEditorWithLinkConfig( { defaultProtocol: 'http://' } ); - formView.urlInputView.fieldView.value = 'ckeditor.com'; - formView.fire( 'submit' ); + setModelData( editor.model, '[ckeditor.com]' ); - expect( getModelData( editor.model ) ).to.equal( - '[<$text linkHref="http://ckeditor.com">ckeditor.com]' - ); + formView.urlInputView.fieldView.value = 'ckeditor.com'; + formView.fire( 'submit' ); - return editor.destroy(); - } ); + expect( getModelData( editor.model ) ).to.equal( + '[<$text linkHref="http://ckeditor.com">ckeditor.com]' + ); + + return editor.destroy(); } ); - it( 'should detect an email on submitting the form and add "mailto:" protocol automatically to the provided value', () => { - return createEditorWithDefaultProtocol( 'http://' ).then( ( { editor, formView } ) => { - setModelData( editor.model, '[email@example.com]' ); + it( 'should detect an email on submitting the form and add "mailto:" ' + + 'protocol automatically to the provided value', async () => { + const { editor, formView } = await createEditorWithLinkConfig( { defaultProtocol: 'http://' } ); - formView.urlInputView.fieldView.value = 'email@example.com'; - formView.fire( 'submit' ); + setModelData( editor.model, '[email@example.com]' ); - expect( getModelData( editor.model ) ).to.equal( - '[<$text linkHref="mailto:email@example.com">email@example.com]' - ); + formView.urlInputView.fieldView.value = 'email@example.com'; + formView.fire( 'submit' ); - return editor.destroy(); - } ); + expect( getModelData( editor.model ) ).to.equal( + '[<$text linkHref="mailto:email@example.com">email@example.com]' + ); + + return editor.destroy(); } ); it( 'should detect an email on submitting the form and add "mailto:" protocol automatically to the provided value ' + @@ -1773,17 +2087,16 @@ describe( 'LinkUI', () => { } ); it( 'should not add an email protocol when given provided within the value ' + - 'even when `config.link.defaultProtocol` configured', () => { - return createEditorWithDefaultProtocol( 'mailto:' ).then( ( { editor, formView } ) => { - const linkCommandSpy = sinon.spy( editor.commands.get( 'link' ), 'execute' ); + 'even when `config.link.defaultProtocol` configured', async () => { + const { editor, formView } = await createEditorWithLinkConfig( { defaultProtocol: 'mailto:' } ); + const linkCommandSpy = sinon.spy( editor.commands.get( 'link' ), 'execute' ); - formView.urlInputView.fieldView.value = 'mailto:test@example.com'; - formView.fire( 'submit' ); + formView.urlInputView.fieldView.value = 'mailto:test@example.com'; + formView.fire( 'submit' ); - sinon.assert.calledWith( linkCommandSpy, 'mailto:test@example.com', sinon.match.any ); + sinon.assert.calledWith( linkCommandSpy, 'mailto:test@example.com', sinon.match.any ); - return editor.destroy(); - } ); + return editor.destroy(); } ); } ); @@ -1792,29 +2105,412 @@ describe( 'LinkUI', () => { setModelData( editor.model, 'f[o]o' ); } ); - it( 'should bind formView.urlInputView#value to link command value', () => { - const command = editor.commands.get( 'link' ); + it( 'should populate form on open on collapsed selection in text', () => { + setModelData( editor.model, 'fo[]o' ); + + const executeSpy = testUtils.sinon.spy( editor, 'execute' ); + + linkUIFeature._showUI(); // FormView + + expect( formView.urlInputView.fieldView.value ).to.equal( '' ); + expect( formView.displayedTextInputView.fieldView.value ).to.equal( '' ); + expect( formView.displayedTextInputView.isEnabled ).to.be.true; + + formView.urlInputView.fieldView.value = 'http://ckeditor.com'; + formView.displayedTextInputView.fieldView.value = 'CKEditor 5'; + + expect( formView.urlInputView.fieldView.value ).to.equal( 'http://ckeditor.com' ); + expect( formView.displayedTextInputView.fieldView.value ).to.equal( 'CKEditor 5' ); + + formView.fire( 'submit' ); + + expect( executeSpy.calledOnce ).to.be.true; + expect( executeSpy.calledWithExactly( + 'link', + 'http://ckeditor.com', + {}, + 'CKEditor 5' + ) ).to.be.true; + + expect( getModelData( editor.model ) ).to.equal( + 'fo<$text linkHref="http://ckeditor.com">CKEditor 5[]o' + ); + } ); + + it( 'should populate form on open on collapsed selection in text (without providing displayed text)', () => { + setModelData( editor.model, 'fo[]o' ); + + const executeSpy = testUtils.sinon.spy( editor, 'execute' ); + + linkUIFeature._showUI(); // FormView + + expect( formView.urlInputView.fieldView.value ).to.equal( '' ); + expect( formView.displayedTextInputView.fieldView.value ).to.equal( '' ); + expect( formView.displayedTextInputView.isEnabled ).to.be.true; + + formView.urlInputView.fieldView.value = 'http://ckeditor.com'; + + expect( formView.urlInputView.fieldView.value ).to.equal( 'http://ckeditor.com' ); + expect( formView.displayedTextInputView.fieldView.value ).to.equal( '' ); + + formView.fire( 'submit' ); + + expect( executeSpy.calledOnce ).to.be.true; + expect( executeSpy.calledWithExactly( + 'link', + 'http://ckeditor.com', + {}, + undefined + ) ).to.be.true; + + expect( getModelData( editor.model ) ).to.equal( + 'fo<$text linkHref="http://ckeditor.com">http://ckeditor.com[]o' + ); + } ); + + it( 'should populate form on open on non-collapsed selection in text', () => { + setModelData( editor.model, 'f[o]o' ); + + const executeSpy = testUtils.sinon.spy( editor, 'execute' ); + + linkUIFeature._showUI(); // FormView + + expect( formView.urlInputView.fieldView.value ).to.equal( '' ); + expect( formView.displayedTextInputView.fieldView.value ).to.equal( 'o' ); + expect( formView.displayedTextInputView.isEnabled ).to.be.true; + + formView.urlInputView.fieldView.value = 'http://ckeditor.com'; + formView.displayedTextInputView.fieldView.value = 'CKEditor 5'; + + expect( formView.urlInputView.fieldView.value ).to.equal( 'http://ckeditor.com' ); + expect( formView.displayedTextInputView.fieldView.value ).to.equal( 'CKEditor 5' ); + + formView.fire( 'submit' ); + + expect( executeSpy.calledOnce ).to.be.true; + expect( executeSpy.calledWithExactly( + 'link', + 'http://ckeditor.com', + {}, + 'CKEditor 5' + ) ).to.be.true; + + expect( getModelData( editor.model ) ).to.equal( + 'f[<$text linkHref="http://ckeditor.com">CKEditor 5]o' + ); + } ); + + it( 'should populate form on open on non-collapsed selection in text (without providing displayed text)', () => { + setModelData( editor.model, 'f[o]o' ); + + const executeSpy = testUtils.sinon.spy( editor, 'execute' ); + + linkUIFeature._showUI(); // FormView + + expect( formView.urlInputView.fieldView.value ).to.equal( '' ); + expect( formView.displayedTextInputView.fieldView.value ).to.equal( 'o' ); + expect( formView.displayedTextInputView.isEnabled ).to.be.true; + + formView.urlInputView.fieldView.value = 'http://ckeditor.com'; + + expect( formView.urlInputView.fieldView.value ).to.equal( 'http://ckeditor.com' ); + expect( formView.displayedTextInputView.fieldView.value ).to.equal( 'o' ); + + formView.fire( 'submit' ); + + expect( executeSpy.calledOnce ).to.be.true; + expect( executeSpy.calledWithExactly( + 'link', + 'http://ckeditor.com', + {}, + undefined + ) ).to.be.true; + + expect( getModelData( editor.model ) ).to.equal( + 'f[<$text linkHref="http://ckeditor.com">o]o' + ); + } ); + + it( 'should populate form on open on collapsed selection in link', () => { + setModelData( editor.model, 'fo<$text linkHref="abc">o[]bar' ); + + const executeSpy = testUtils.sinon.spy( editor, 'execute' ); + + linkUIFeature._showUI(); // ToolbarView + linkUIFeature._showUI(); // FormView + + expect( formView.urlInputView.fieldView.value ).to.equal( 'abc' ); + expect( formView.displayedTextInputView.fieldView.value ).to.equal( 'ob' ); + expect( formView.displayedTextInputView.isEnabled ).to.be.true; + + formView.urlInputView.fieldView.value = 'http://ckeditor.com'; + formView.displayedTextInputView.fieldView.value = 'CKEditor 5'; + + expect( formView.urlInputView.fieldView.value ).to.equal( 'http://ckeditor.com' ); + expect( formView.displayedTextInputView.fieldView.value ).to.equal( 'CKEditor 5' ); + + formView.fire( 'submit' ); + + expect( executeSpy.calledOnce ).to.be.true; + expect( executeSpy.calledWithExactly( + 'link', + 'http://ckeditor.com', + {}, + 'CKEditor 5' + ) ).to.be.true; + + expect( getModelData( editor.model ) ).to.equal( + 'fo<$text linkHref="http://ckeditor.com">CKEditor 5[]ar' + ); + } ); + + it( 'should populate form on open on collapsed selection in link (without providing displayed text)', () => { + setModelData( editor.model, 'fo<$text linkHref="abc">o[]bar' ); + + const executeSpy = testUtils.sinon.spy( editor, 'execute' ); + + linkUIFeature._showUI(); // ToolbarView + linkUIFeature._showUI(); // FormView + + expect( formView.urlInputView.fieldView.value ).to.equal( 'abc' ); + expect( formView.displayedTextInputView.fieldView.value ).to.equal( 'ob' ); + expect( formView.displayedTextInputView.isEnabled ).to.be.true; + + formView.urlInputView.fieldView.value = 'http://ckeditor.com'; + + expect( formView.urlInputView.fieldView.value ).to.equal( 'http://ckeditor.com' ); + expect( formView.displayedTextInputView.fieldView.value ).to.equal( 'ob' ); + + formView.fire( 'submit' ); + + expect( executeSpy.calledOnce ).to.be.true; + expect( executeSpy.calledWithExactly( + 'link', + 'http://ckeditor.com', + {}, + undefined + ) ).to.be.true; + + expect( getModelData( editor.model ) ).to.equal( + 'fo<$text linkHref="http://ckeditor.com">o[]bar' + ); + } ); + + it( 'should populate form on open on non-collapsed selection in link', () => { + setModelData( editor.model, 'fo<$text linkHref="abc">[ob]ar' ); + + const executeSpy = testUtils.sinon.spy( editor, 'execute' ); + + linkUIFeature._showUI(); // ToolbarView + linkUIFeature._showUI(); // FormView + + expect( formView.urlInputView.fieldView.value ).to.equal( 'abc' ); + expect( formView.displayedTextInputView.fieldView.value ).to.equal( 'ob' ); + expect( formView.displayedTextInputView.isEnabled ).to.be.true; + + formView.urlInputView.fieldView.value = 'http://ckeditor.com'; + formView.displayedTextInputView.fieldView.value = 'CKEditor 5'; + + expect( formView.urlInputView.fieldView.value ).to.equal( 'http://ckeditor.com' ); + expect( formView.displayedTextInputView.fieldView.value ).to.equal( 'CKEditor 5' ); + + formView.fire( 'submit' ); + + expect( executeSpy.calledOnce ).to.be.true; + expect( executeSpy.calledWithExactly( + 'link', + 'http://ckeditor.com', + {}, + 'CKEditor 5' + ) ).to.be.true; + + expect( getModelData( editor.model ) ).to.equal( + 'fo[<$text linkHref="http://ckeditor.com">CKEditor 5]ar' + ); + } ); + + it( 'should populate form on open on non-collapsed selection in link (without providing displayed text)', () => { + setModelData( editor.model, 'fo<$text linkHref="abc">[ob]ar' ); + + const executeSpy = testUtils.sinon.spy( editor, 'execute' ); + + linkUIFeature._showUI(); // ToolbarView + linkUIFeature._showUI(); // FormView + + expect( formView.urlInputView.fieldView.value ).to.equal( 'abc' ); + expect( formView.displayedTextInputView.fieldView.value ).to.equal( 'ob' ); + expect( formView.displayedTextInputView.isEnabled ).to.be.true; - expect( formView.urlInputView.fieldView.value ).to.be.undefined; + formView.urlInputView.fieldView.value = 'http://ckeditor.com'; + + expect( formView.urlInputView.fieldView.value ).to.equal( 'http://ckeditor.com' ); + expect( formView.displayedTextInputView.fieldView.value ).to.equal( 'ob' ); + + formView.fire( 'submit' ); + + expect( executeSpy.calledOnce ).to.be.true; + expect( executeSpy.calledWithExactly( + 'link', + 'http://ckeditor.com', + {}, + undefined + ) ).to.be.true; + + expect( getModelData( editor.model ) ).to.equal( + 'fo[<$text linkHref="http://ckeditor.com">ob]ar' + ); + } ); + + it( 'should populate form on open on collapsed selection in link with text matching href', () => { + setModelData( editor.model, + 'fo<$text linkHref="http://cksource.com">http://ck[]source.comar' + ); + + const executeSpy = testUtils.sinon.spy( editor, 'execute' ); + + linkUIFeature._showUI(); // ToolbarView + linkUIFeature._showUI(); // FormView - command.value = 'http://cksource.com'; expect( formView.urlInputView.fieldView.value ).to.equal( 'http://cksource.com' ); + expect( formView.displayedTextInputView.fieldView.value ).to.equal( 'http://cksource.com' ); + expect( formView.displayedTextInputView.isEnabled ).to.be.true; + + formView.urlInputView.fieldView.value = 'http://ckeditor.com'; + + expect( formView.urlInputView.fieldView.value ).to.equal( 'http://ckeditor.com' ); + expect( formView.displayedTextInputView.fieldView.value ).to.equal( 'http://cksource.com' ); + + formView.fire( 'submit' ); + + expect( executeSpy.calledOnce ).to.be.true; + expect( executeSpy.calledWithExactly( + 'link', + 'http://ckeditor.com', + {}, + undefined + ) ).to.be.true; + + expect( getModelData( editor.model ) ).to.equal( + 'fo<$text linkHref="http://ckeditor.com">http://ckeditor.com[]ar' + ); } ); - it( 'should execute link command on formView#submit event', () => { + it( 'should populate form on open on collapsed selection in link with text matching href but styled', () => { + setModelData( editor.model, + '' + + 'fo' + + '<$text linkHref="http://cksource.com">htt[]p://' + + '<$text linkHref="http://cksource.com" bold="true">cksource.com' + + 'ar' + + '' + ); + const executeSpy = testUtils.sinon.spy( editor, 'execute' ); + linkUIFeature._showUI(); // ToolbarView + linkUIFeature._showUI(); // FormView + + expect( formView.urlInputView.fieldView.value ).to.equal( 'http://cksource.com' ); + expect( formView.displayedTextInputView.fieldView.value ).to.equal( 'http://cksource.com' ); + expect( formView.displayedTextInputView.isEnabled ).to.be.true; + formView.urlInputView.fieldView.value = 'http://ckeditor.com'; + expect( formView.urlInputView.fieldView.value ).to.equal( 'http://ckeditor.com' ); + expect( formView.displayedTextInputView.fieldView.value ).to.equal( 'http://cksource.com' ); + + formView.fire( 'submit' ); + + expect( executeSpy.calledOnce ).to.be.true; + expect( executeSpy.calledWithExactly( + 'link', + 'http://ckeditor.com', + {}, + undefined + ) ).to.be.true; + + expect( getModelData( editor.model ) ).to.equal( + 'fo' + + '<$text linkHref="http://ckeditor.com">http://' + + '<$text bold="true" linkHref="http://ckeditor.com">ckeditor.com' + + '[]ar' + + '' + ); + } ); + + it( 'should populate form on open on collapsed selection in link with text matching href but styled ' + + 'and update text', () => { + setModelData( editor.model, + '' + + 'fo' + + '<$text linkHref="http://cksource.com">htt[]p://' + + '<$text linkHref="http://cksource.com" bold="true">cksource.com' + + 'ar' + + '' + ); + + const executeSpy = testUtils.sinon.spy( editor, 'execute' ); + + linkUIFeature._showUI(); // ToolbarView + linkUIFeature._showUI(); // FormView + + expect( formView.urlInputView.fieldView.value ).to.equal( 'http://cksource.com' ); + expect( formView.displayedTextInputView.fieldView.value ).to.equal( 'http://cksource.com' ); + expect( formView.displayedTextInputView.isEnabled ).to.be.true; + + formView.displayedTextInputView.fieldView.value = 'CKSource'; + + expect( formView.urlInputView.fieldView.value ).to.equal( 'http://cksource.com' ); + expect( formView.displayedTextInputView.fieldView.value ).to.equal( 'CKSource' ); - formView.urlInputView.fieldView.value = 'http://cksource.com'; formView.fire( 'submit' ); expect( executeSpy.calledOnce ).to.be.true; - expect( executeSpy.calledWithExactly( 'link', 'http://cksource.com', {} ) ).to.be.true; + expect( executeSpy.calledWithExactly( + 'link', + 'http://cksource.com', + {}, + 'CKSource' + ) ).to.be.true; + + expect( getModelData( editor.model ) ).to.equal( + '' + + 'fo<$text linkHref="http://cksource.com">CKS' + + '<$text bold="true" linkHref="http://cksource.com">ource' + + '[]ar' + + '' + ); + } ); + + it( 'should disable displayed text field on multi block select', () => { + setModelData( editor.model, + 'f[oo' + + 'ba]r' + ); + + linkUIFeature._showUI(); // ToolbarView + linkUIFeature._showUI(); // FormView + + expect( formView.urlInputView.fieldView.value ).to.equal( '' ); + expect( formView.displayedTextInputView.fieldView.value ).to.equal( '' ); + expect( formView.displayedTextInputView.isEnabled ).to.be.false; + } ); + + it( 'should disable displayed text field if it can not be modified as a plain text', () => { + linkUIFeature.selectedLinkableText = undefined; + expect( formView.displayedTextInputView.isEnabled ).to.be.false; + + linkUIFeature.selectedLinkableText = ''; + expect( formView.displayedTextInputView.isEnabled ).to.be.true; + + linkUIFeature.selectedLinkableText = 'foo'; + expect( formView.displayedTextInputView.isEnabled ).to.be.true; + + linkUIFeature.selectedLinkableText = undefined; + expect( formView.displayedTextInputView.isEnabled ).to.be.false; } ); - it( 'should should clear the fake visual selection on formView#submit event', () => { + it( 'should clear the fake visual selection on formView#submit event', () => { linkUIFeature._showUI(); expect( editor.model.markers.has( 'link-ui' ) ).to.be.true; @@ -1824,17 +2520,17 @@ describe( 'LinkUI', () => { expect( editor.model.markers.has( 'link-ui' ) ).to.be.false; } ); - it( 'should hide and reveal the #actionsView on formView#submit event', () => { + it( 'should hide and reveal the #toolbarView on formView#submit event', () => { linkUIFeature._showUI(); formView.urlInputView.fieldView.value = '/test.html'; formView.fire( 'submit' ); - expect( balloon.visibleView ).to.equal( actionsView ); + expect( balloon.visibleView ).to.equal( toolbarView ); expect( focusEditableSpy.calledOnce ).to.be.true; } ); - it( 'should hide and reveal the #actionsView on formView#cancel event if link command has a value', () => { + it( 'should hide and reveal the #toolbarView on formView#cancel event if link command has a value', () => { linkUIFeature._showUI(); const command = editor.commands.get( 'link' ); @@ -1842,7 +2538,7 @@ describe( 'LinkUI', () => { formView.fire( 'cancel' ); - expect( balloon.visibleView ).to.equal( actionsView ); + expect( balloon.visibleView ).to.equal( toolbarView ); expect( focusEditableSpy.calledOnce ).to.be.true; } ); @@ -1853,7 +2549,7 @@ describe( 'LinkUI', () => { expect( balloon.visibleView ).to.be.null; } ); - it( 'should hide and reveal the #actionsView after Esc key press if link command has a value', () => { + it( 'should hide and reveal the #toolbarView after Esc key press if link command has a value', () => { const keyEvtData = { keyCode: keyCodes.esc, preventDefault: sinon.spy(), @@ -1867,7 +2563,7 @@ describe( 'LinkUI', () => { formView.keystrokes.press( keyEvtData ); - expect( balloon.visibleView ).to.equal( actionsView ); + expect( balloon.visibleView ).to.equal( toolbarView ); expect( focusEditableSpy.calledOnce ).to.be.true; } ); @@ -1898,48 +2594,65 @@ describe( 'LinkUI', () => { } ); describe( 'support manual decorators', () => { - let editorElement, editor, model, formView, linkUIFeature; + let editorElement, editor, model, formView, propertiesView, linkUIFeature; - beforeEach( () => { + beforeEach( async () => { editorElement = document.createElement( 'div' ); document.body.appendChild( editorElement ); - return ClassicTestEditor - .create( editorElement, { - plugins: [ LinkEditing, LinkUI, Paragraph ], - link: { - decorators: { - isFoo: { - mode: 'manual', - label: 'Foo', - attributes: { - foo: 'bar' - } + + editor = await ClassicTestEditor.create( editorElement, { + plugins: [ LinkEditing, LinkUI, Paragraph ], + link: { + decorators: { + decorator1: { + mode: 'manual', + label: 'Foo', + attributes: { + foo: 'bar' + } + }, + decorator2: { + mode: 'manual', + label: 'Download', + attributes: { + download: 'download' + }, + defaultValue: true + }, + decorator3: { + mode: 'manual', + label: 'Multi', + attributes: { + class: 'fancy-class', + target: '_blank', + rel: 'noopener noreferrer' } } } - } ) - .then( newEditor => { - editor = newEditor; - model = editor.model; + } + } ); - model.schema.extend( '$text', { - allowIn: '$root', - allowAttributes: 'linkHref' - } ); + model = editor.model; + + model.schema.extend( '$text', { + allowIn: '$root', + allowAttributes: 'linkHref' + } ); - linkUIFeature = editor.plugins.get( LinkUI ); - linkUIFeature._createViews(); + linkUIFeature = editor.plugins.get( LinkUI ); + linkUIFeature._createViews(); - const balloon = editor.plugins.get( ContextualBalloon ); + const balloon = editor.plugins.get( ContextualBalloon ); - formView = linkUIFeature.formView; + formView = linkUIFeature.formView; + propertiesView = linkUIFeature.propertiesView; - // There is no point to execute BalloonPanelView attachTo and pin methods so lets override it. - testUtils.sinon.stub( balloon.view, 'attachTo' ).returns( {} ); - testUtils.sinon.stub( balloon.view, 'pin' ).returns( {} ); + // There is no point to execute BalloonPanelView attachTo and pin methods so lets override it. + testUtils.sinon.stub( balloon.view, 'attachTo' ).returns( {} ); + testUtils.sinon.stub( balloon.view, 'pin' ).returns( {} ); - formView.render(); - } ); + formView.render(); + propertiesView.render(); } ); afterEach( () => { @@ -1950,35 +2663,793 @@ describe( 'LinkUI', () => { it( 'should gather information about manual decorators', () => { const executeSpy = testUtils.sinon.spy( editor, 'execute' ); - setModelData( model, 'f[<$text linkHref="url" linkIsFoo="true">ooba]r' ); - expect( formView.urlInputView.fieldView.element.value ).to.equal( 'url' ); - expect( formView.getDecoratorSwitchesState() ).to.deep.equal( { linkIsFoo: true } ); + setModelData( model, 'f[<$text linkHref="url" linkDecorator1="true">ooba]r' ); + + linkUIFeature._showUI( true ); // ToolbarView + linkUIFeature._showUI( true ); // FormView - formView.fire( 'submit' ); + expect( formView.urlInputView.fieldView.element.value ).to.equal( 'url' ); + expect( formView.displayedTextInputView.fieldView.value ).to.equal( 'ooba' ); + expect( linkUIFeature._getDecoratorSwitchesState() ).to.deep.equal( { + linkDecorator1: true, + linkDecorator2: false, + linkDecorator3: false + } ); - expect( executeSpy.calledOnce ).to.be.true; - expect( executeSpy.calledWithExactly( 'link', 'url', { linkIsFoo: true } ) ).to.be.true; + // Switch the first decorator on. + linkUIFeature._createPropertiesView(); + propertiesView.listChildren.get( 1 ).fire( 'execute' ); + + sinon.assert.calledOnce( executeSpy ); + sinon.assert.calledWithExactly( + executeSpy, + 'link', + 'url', + { + linkDecorator1: true, + linkDecorator2: true, + linkDecorator3: false + } + ); } ); - it( 'should reset switch state when form view is closed', () => { + it( 'should keep switch state when form is closed', () => { setModelData( model, 'f[<$text linkHref="url" linkIsFoo="true">ooba]r' ); + linkUIFeature._createPropertiesView(); + const manualDecorators = editor.commands.get( 'link' ).manualDecorators; const firstDecoratorModel = manualDecorators.first; - const firstDecoratorSwitch = formView._manualDecoratorSwitches.first; + const firstDecoratorSwitch = propertiesView.listChildren.first; - expect( firstDecoratorModel.value, 'Initial value should be read from the model (true)' ).to.be.true; - expect( firstDecoratorSwitch.isOn, 'Initial value should be read from the model (true)' ).to.be.true; + expect( firstDecoratorModel.value, 'Initial value should be read from the model (true)' ).to.be.undefined; + expect( firstDecoratorSwitch.isOn, 'Initial value should be read from the model (true)' ).to.be.false; firstDecoratorSwitch.fire( 'execute' ); - expect( firstDecoratorModel.value, 'Pressing button toggles value' ).to.be.false; - expect( firstDecoratorSwitch.isOn, 'Pressing button toggles value' ).to.be.false; + + expect( firstDecoratorModel.value, 'Pressing button toggles value' ).to.be.true; + expect( firstDecoratorSwitch.isOn, 'Pressing button toggles value' ).to.be.true; linkUIFeature._closeFormView(); - expect( firstDecoratorModel.value, 'Close form view without submit resets value to initial state' ).to.be.true; - expect( firstDecoratorSwitch.isOn, 'Close form view without submit resets value to initial state' ).to.be.true; + + expect( firstDecoratorModel.value ).to.be.true; + expect( firstDecoratorSwitch.isOn ).to.be.true; } ); - } ); + + it( 'switch buttons reflects state of manual decorators', () => { + expect( linkUIFeature.propertiesView.listChildren.length ).to.equal( 3 ); + + expect( linkUIFeature.propertiesView.listChildren.get( 0 ) ).to.deep.include( { + label: 'Foo', + isOn: false + } ); + expect( linkUIFeature.propertiesView.listChildren.get( 1 ) ).to.deep.include( { + label: 'Download', + isOn: true + } ); + expect( linkUIFeature.propertiesView.listChildren.get( 2 ) ).to.deep.include( { + label: 'Multi', + isOn: false + } ); + } ); + + it( 'reacts on switch button changes', () => { + setModelData( model, 'f[<$text linkHref="url" linkDecorator1="true">ooba]r' ); + + const linkCommand = editor.commands.get( 'link' ); + const modelItem = linkCommand.manualDecorators.first; + const viewItem = linkUIFeature.propertiesView.listChildren.first; + + expect( modelItem.value ).to.be.true; + expect( viewItem.isOn ).to.be.true; + + viewItem.element.dispatchEvent( new Event( 'click' ) ); + + expect( modelItem.value ).to.be.undefined; + expect( viewItem.isOn ).to.be.false; + + viewItem.element.dispatchEvent( new Event( 'click' ) ); + + expect( modelItem.value ).to.be.true; + expect( viewItem.isOn ).to.be.true; + } ); + + describe( '_getDecoratorSwitchesState()', () => { + it( 'should provide object with decorators states', () => { + setModelData( model, 'f[<$text linkHref="url" linkDecorator1="true">ooba]r' ); + + expect( linkUIFeature._getDecoratorSwitchesState() ).to.deep.equal( { + linkDecorator1: true, + linkDecorator2: false, + linkDecorator3: false + } ); + + linkUIFeature.propertiesView.listChildren.map( item => { + item.element.dispatchEvent( new Event( 'click' ) ); + } ); + + linkUIFeature.propertiesView.listChildren.get( 2 ).element.dispatchEvent( new Event( 'click' ) ); + + expect( linkUIFeature._getDecoratorSwitchesState() ).to.deep.equal( { + linkDecorator1: false, + linkDecorator2: true, + linkDecorator3: false + } ); + } ); + + it( 'should use decorator default value if command and decorator values are not set', () => { + expect( linkUIFeature._getDecoratorSwitchesState() ).to.deep.equal( { + linkDecorator1: false, + linkDecorator2: true, + linkDecorator3: false + } ); + } ); + + it( 'should use a decorator value if decorator value is set', () => { + const linkCommand = editor.commands.get( 'link' ); + + for ( const decorator of linkCommand.manualDecorators ) { + decorator.value = true; + } + + expect( linkUIFeature._getDecoratorSwitchesState() ).to.deep.equal( { + linkDecorator1: true, + linkDecorator2: true, + linkDecorator3: true + } ); + + for ( const decorator of linkCommand.manualDecorators ) { + decorator.value = false; + } + + expect( linkUIFeature._getDecoratorSwitchesState() ).to.deep.equal( { + linkDecorator1: false, + linkDecorator2: false, + linkDecorator3: false + } ); + } ); + it( 'should use a decorator value if link command value is set', () => { + const linkCommand = editor.commands.get( 'link' ); + + linkCommand.value = ''; + + expect( linkUIFeature._getDecoratorSwitchesState() ).to.deep.equal( { + linkDecorator1: false, + linkDecorator2: false, + linkDecorator3: false + } ); + + for ( const decorator of linkCommand.manualDecorators ) { + decorator.value = false; + } + + expect( linkUIFeature._getDecoratorSwitchesState() ).to.deep.equal( { + linkDecorator1: false, + linkDecorator2: false, + linkDecorator3: false + } ); + + for ( const decorator of linkCommand.manualDecorators ) { + decorator.value = true; + } + + expect( linkUIFeature._getDecoratorSwitchesState() ).to.deep.equal( { + linkDecorator1: true, + linkDecorator2: true, + linkDecorator3: true + } ); + } ); + } ); + } ); + } ); + } ); + + describe( 'properties view', () => { + beforeEach( () => { + editor.commands.get( 'link' ).manualDecorators.add( new ManualDecorator( { + id: 'linkIsBar', + label: 'Bar', + attributes: { + target: '_blank' + } + } ) ); + } ); + + it( 'can be closed by clicking the back button', () => { + const spy = sinon.spy(); + + setModelData( editor.model, 'f[o]o' ); + + linkUIFeature._showUI(); + linkUIFeature._addPropertiesView(); + + expect( balloon.visibleView ).to.equal( linkUIFeature.propertiesView ); + + linkUIFeature.listenTo( linkUIFeature.propertiesView, 'back', spy ); + + const removeBalloonSpy = sinon.spy( balloon, 'remove' ); + linkUIFeature.propertiesView.backButtonView.fire( 'execute' ); + + sinon.assert.calledOnce( spy ); + expect( removeBalloonSpy ).to.be.calledWithExactly( linkUIFeature.propertiesView ); + } ); + + it( 'can be closed by clicking the "esc" button', () => { + setModelData( editor.model, 'f[o]o' ); + + linkUIFeature._showUI(); + linkUIFeature._addPropertiesView(); + + expect( balloon.visibleView ).to.equal( linkUIFeature.propertiesView ); + + const removeBalloonSpy = sinon.spy( balloon, 'remove' ); + + linkUIFeature.propertiesView.keystrokes.press( { + keyCode: keyCodes.esc, + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + } ); + + expect( removeBalloonSpy ).to.be.calledWithExactly( linkUIFeature.propertiesView ); + } ); + } ); + + describe( 'Links Providers', () => { + describe( 'registerLinksListProvider()', () => { + it( 'should not crash the editor when called before showing the form', () => { + linkUIFeature.registerLinksListProvider( { + label: 'Foo', + getItems: () => [] + } ); + } ); + + it( 'should show links provider that were registered before showing form', () => { + linkUIFeature.registerLinksListProvider( { + label: 'Foo', + getItems: () => [] + } ); + + linkUIFeature._showUI(); + + expect( linkUIFeature.formView.providersListChildren.length ).to.equal( 1 ); + expect( linkUIFeature.formView.providersListChildren.first.label ).to.equal( 'Foo' ); + } ); + + it( 'should show link provider that were registered after showing form', () => { + linkUIFeature._showUI(); + + linkUIFeature.registerLinksListProvider( { + label: 'Bar', + getItems: () => [] + } ); + + expect( linkUIFeature.formView.providersListChildren.length ).to.equal( 1 ); + expect( linkUIFeature.formView.providersListChildren.first.label ).to.equal( 'Bar' ); + } ); + + it( 'should be possible to register multiple link providers', () => { + linkUIFeature.registerLinksListProvider( { + label: 'Foo', + getItems: () => [] + } ); + + linkUIFeature.registerLinksListProvider( { + label: 'Bar', + getItems: () => [] + } ); + + linkUIFeature._showUI(); + + linkUIFeature.registerLinksListProvider( { + label: 'Buz', + getItems: () => [] + } ); + + expect( linkUIFeature.formView.providersListChildren.length ).to.equal( 3 ); + + const labels = Array.from( linkUIFeature.formView.providersListChildren ).map( child => child.label ); + + expect( labels ).to.be.deep.equal( [ 'Foo', 'Bar', 'Buz' ] ); + } ); + + it( 'should register link providers in proper order if order passed', () => { + linkUIFeature.registerLinksListProvider( { + order: 2, + label: 'Foo', + getItem: () => null, + getListItems: () => [] + } ); + + linkUIFeature.registerLinksListProvider( { + order: -1, + label: 'Bar', + getItem: () => null, + getListItems: () => [] + } ); + + linkUIFeature.registerLinksListProvider( { + label: 'Buz', + getItem: () => null, + getListItems: () => [] + } ); + + linkUIFeature._showUI(); + + linkUIFeature.registerLinksListProvider( { + order: -3, + label: 'FooBar', + getItem: () => null, + getListItems: () => [] + } ); + + expect( linkUIFeature.formView.providersListChildren.length ).to.equal( 4 ); + + const labels = Array.from( linkUIFeature.formView.providersListChildren ).map( child => child.label ); + + expect( labels ).to.be.deep.equal( [ 'FooBar', 'Bar', 'Buz', 'Foo' ] ); + } ); + } ); + + describe( '_getLinkProviderLinkByHref()', () => { + it( 'should return null if no link provided', () => { + const link = linkUIFeature._getLinkProviderLinkByHref(); + + expect( link ).to.be.null; + } ); + + it( 'should return link object from provider that contains given href', () => { + linkUIFeature.registerLinksListProvider( { + label: 'Foo', + getItem: href => { + if ( href === 'bar' ) { + return { href: 'bar' }; + } + } + } ); + + linkUIFeature.registerLinksListProvider( { + label: 'Bar', + getItem: () => ( { href: 'wrong-link' } ) + } ); + + const link = linkUIFeature._getLinkProviderLinkByHref( 'bar' ); + + expect( link.item ).to.be.deep.equal( { href: 'bar' } ); + expect( link.provider.label ).to.be.equal( 'Foo' ); + } ); + + it( 'should return null if no link with given href was found', () => { + const getItemsStub = [ + sinon.stub().returns( null ), + sinon.stub().returns( null ) + ]; + + linkUIFeature.registerLinksListProvider( { + label: 'Foo', + getItem: getItemsStub[ 0 ] + } ); + + linkUIFeature.registerLinksListProvider( { + label: 'Bar', + getItem: getItemsStub[ 1 ] + } ); + + const link = linkUIFeature._getLinkProviderLinkByHref( 'buz' ); + + expect( link ).to.be.null; + expect( getItemsStub[ 0 ] ).to.be.calledOnce; + expect( getItemsStub[ 1 ] ).to.be.calledOnce; + } ); + } ); + + describe( 'editing integration', () => { + let windowOpenStub; + + beforeEach( async () => { + await editor.destroy(); + + windowOpenStub = sinon.stub( window, 'open' ); + editor = await ClassicEditor.create( editorElement, { + plugins: [ Essentials, LinkEditing, LinkUI, Paragraph, BlockQuote ], + toolbar: [ 'link' ] + } ); + + env.isMac = false; + model = editor.model; + linkUIFeature = editor.plugins.get( LinkUI ); + } ); + + afterEach( async () => { + windowOpenStub.restore(); + + await editor.destroy(); + } ); + + it( 'should register custom opener that lookups in links provider items and calls navigate (returning true)', () => { + const navigate = sinon.stub().returns( true ); + + linkUIFeature.registerLinksListProvider( { + label: 'Foo', + getListItems: () => [ { href: 'https://ckeditor.com' } ], + navigate + } ); + + setModelData( model, '<$text linkHref="https://ckeditor.com">Bar[]' ); + fireClickEvent( { metaKey: false, ctrlKey: true } ); + + expect( navigate ).to.be.calledOnce; + expect( navigate ).to.be.calledWithMatch( { href: 'https://ckeditor.com' } ); + + expect( windowOpenStub ).not.to.be.called; + } ); + + it( 'should register custom opener that lookups in links provider items and calls navigate (returning false)', () => { + const navigate = sinon.stub().returns( false ); + + linkUIFeature.registerLinksListProvider( { + label: 'Foo', + getListItems: () => [ { href: 'https://ckeditor.com' } ], + navigate + } ); + + setModelData( model, '<$text linkHref="https://ckeditor.com">Bar[]' ); + fireClickEvent( { metaKey: false, ctrlKey: true } ); + + expect( navigate ).to.be.calledOnce; + expect( windowOpenStub ).to.be.called; + } ); + + it( 'should not crash if link was not found in provider', () => { + const navigate = sinon.stub().returns( false ); + + linkUIFeature.registerLinksListProvider( { + label: 'Foo', + getListItems: () => [ { href: 'https://ckeditor.com' } ], + navigate + } ); + + setModelData( model, '<$text linkHref="https://example.org">Bar[]' ); + fireClickEvent( { metaKey: false, ctrlKey: true } ); + + expect( navigate ).not.to.be.called; + expect( windowOpenStub ).to.be.called; + } ); + + it( 'should use default navigate to href if no navigate callback was provided', () => { + linkUIFeature.registerLinksListProvider( { + label: 'Foo', + getListItems: () => [ { href: 'https://example.org' } ] + } ); + + setModelData( model, '<$text linkHref="https://example.org">Bar[]' ); + fireClickEvent( { metaKey: false, ctrlKey: true } ); + + expect( windowOpenStub ).to.be.calledWith( 'https://example.org', '_blank' ); + } ); + + function fireClickEvent( options, tagName = 'a' ) { + const linkElement = editor.ui.getEditableElement().getElementsByTagName( tagName )[ 0 ]; + + editor.editing.view.document.fire( 'click', { + domTarget: linkElement, + domEvent: options, + preventDefault: () => {} + } ); + } + } ); + + describe( 'link preview', () => { + let button; + + beforeEach( () => { + button = editor.ui.componentFactory.create( 'linkPreview' ); + } ); + + afterEach( () => { + button.destroy(); + } ); + + it( 'should set fallback label and icon if selected link was not found in link providers', () => { + setModelData( model, '<$text linkHref="https://ckeditor.com">Bar[]' ); + + expect( button.isEnabled ).to.be.true; + expect( button.isVisible ).to.be.true; + expect( button.label ).to.equal( 'https://ckeditor.com' ); + expect( button.icon ).to.be.undefined; + } ); + + it( 'should set label and icon from link provider if selected link was found', () => { + linkUIFeature.registerLinksListProvider( { + label: 'Foo', + getListItems: () => [ + { + href: 'https://ckeditor.com', + label: 'CKEditor', + icon: icons.bookmarkMedium + } + ] + } ); + + setModelData( model, '<$text linkHref="https://ckeditor.com">Bar[]' ); + + expect( button.isEnabled ).to.be.true; + expect( button.isVisible ).to.be.true; + expect( button.label ).to.equal( 'CKEditor' ); + expect( button.tooltip ).to.be.false; + expect( button.icon ).to.be.equal( icons.bookmarkMedium ); + } ); + + it( 'should prefer to use preview tooltip and icon from `getItem` if present', () => { + linkUIFeature.registerLinksListProvider( { + label: 'Foo', + getListItems: () => [ + { + href: 'https://ckeditor.com', + label: 'CKEditor', + icon: icons.bookmarkMedium + } + ], + getItem: href => { + if ( href === 'https://ckeditor.com' ) { + return { + label: 'CKEditor', + icon: icons.bookmarkMedium, + tooltip: 'Tooltip' + }; + } + } + } ); + + setModelData( model, '<$text linkHref="https://ckeditor.com">Bar[]' ); + + expect( button.tooltip ).to.be.equal( 'Tooltip' ); + expect( button.icon ).to.be.equal( icons.bookmarkMedium ); + } ); + + it( 'should not show any icon if preview if icon is null in `getItem`', () => { + linkUIFeature.registerLinksListProvider( { + label: 'Foo', + getListItems: () => [ + { + href: 'https://ckeditor.com', + label: 'CKEditor', + icon: icons.bookmarkMedium + } + ], + getItem: href => { + if ( href === 'https://ckeditor.com' ) { + return { + label: 'CKEditor', + icon: null, + tooltip: 'Tooltip' + }; + } + } + } ); + + setModelData( model, '<$text linkHref="https://ckeditor.com">Bar[]' ); + + expect( button.tooltip ).to.be.equal( 'Tooltip' ); + expect( button.icon ).to.be.null; + } ); + + it( 'should stop the event and execute navigate (that returns true)', () => { + const cancelCheckSpy = sinon.spy(); + const navigate = sinon.stub().returns( true ); + + linkUIFeature.registerLinksListProvider( { + label: 'Foo', + getListItems: () => [ + { + href: 'https://ckeditor.com', + label: 'CKEditor', + icon: icons.bookmarkMedium + } + ], + navigate + } ); + + linkUIFeature.listenTo( button, 'navigate', cancelCheckSpy ); + + button.href = 'https://ckeditor.com'; + button.render(); + button.element.dispatchEvent( new Event( 'click' ) ); + + expect( cancelCheckSpy ).not.to.be.called; + expect( navigate ).to.be.calledOnce; + + sinon.assert.calledWith( navigate, sinon.match( { + href: 'https://ckeditor.com', + label: 'CKEditor', + icon: icons.bookmarkMedium + } ) ); + } ); + + it( 'should not stop the event if link was not found in providers', () => { + const cancelCheckSpy = sinon.spy(); + const navigate = sinon.stub().returns( false ); + + linkUIFeature.registerLinksListProvider( { + label: 'Foo', + getListItems: () => [ + { + href: 'https://ckeditor.com', + label: 'CKEditor', + icon: icons.bookmarkMedium + } + ], + navigate + } ); + + button.on( 'navigate', cancelCheckSpy ); + + button.href = 'https://example.org'; + button.render(); + button.element.dispatchEvent( new Event( 'click' ) ); + + expect( cancelCheckSpy ).to.be.called; + expect( navigate ).not.to.be.called; + } ); + } ); + + describe( 'links view', () => { + beforeEach( () => { + linkUIFeature.registerLinksListProvider( { + label: 'Foo', + getListItems: () => [ + { href: 'https://ckeditor.com', label: 'CKEditor', icon: icons.bookmarkMedium }, + { href: 'https://example.org', label: 'Example', icon: icons.bookmarkSmall }, + { href: 'https://example.com/2', label: 'Example 2', icon: icons.bookmarkSmall }, + { href: 'https://example.com/3', label: 'Example 3', icon: icons.bookmarkSmall } + ] + } ); + + linkUIFeature.registerLinksListProvider( { + label: 'Bar', + getListItems: () => [ + { href: 'https://ckeditor.com', label: 'CKEditor', icon: icons.bookmarkMedium } + ] + } ); + + linkUIFeature.registerLinksListProvider( { + label: 'Buz', + getListItems: () => [] + } ); + } ); + + it( 'can be opened by clicking the link toolbar button', () => { + linkUIFeature._showUI(); + + clickNthLinksProvider( 0 ); + + expect( balloon.visibleView ).to.equal( linkUIFeature.linkProviderItemsView ); + } ); + + it( 'can be closed by clicking the back button', () => { + const spy = sinon.spy(); + + linkUIFeature._showUI(); + clickNthLinksProvider( 0 ); + + linkUIFeature.listenTo( linkUIFeature.linkProviderItemsView, 'cancel', spy ); + backToLinksProviders(); + + sinon.assert.calledOnce( spy ); + expect( balloon.visibleView ).to.equal( linkUIFeature.formView ); + } ); + + it( 'can be closed by clicking the "esc" button', () => { + linkUIFeature._showUI(); + clickNthLinksProvider( 0 ); + + linkUIFeature.linkProviderItemsView.keystrokes.press( { + keyCode: keyCodes.esc, + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + } ); + + expect( balloon.visibleView ).to.equal( linkUIFeature.formView ); + } ); + + it( 'should hide the UI and not focus editable upon clicking outside the UI', () => { + const spy = testUtils.sinon.spy( linkUIFeature, '_hideUI' ); + + linkUIFeature._showUI(); + clickNthLinksProvider( 0 ); + + document.body.dispatchEvent( new Event( 'mousedown', { bubbles: true } ) ); + + sinon.assert.calledWithExactly( spy ); + expect( linkUIFeature._balloon.visibleView ).to.be.null; + } ); + + it( 'opening provider should show items from the provider', () => { + linkUIFeature._showUI(); + + // First provider with 4 items + clickNthLinksProvider( 0 ); + expect( linkUIFeature.linkProviderItemsView.listChildren.length ).to.equal( 4 ); + expectedShownItems( [ 'CKEditor', 'Example', 'Example 2', 'Example 3' ] ); + backToLinksProviders(); + + // Second provider with 1 item + clickNthLinksProvider( 1 ); + expect( linkUIFeature.linkProviderItemsView.listChildren.length ).to.equal( 1 ); + expectedShownItems( [ 'CKEditor' ] ); + backToLinksProviders(); + + // Third provider with 0 items + clickNthLinksProvider( 2 ); + expect( linkUIFeature.linkProviderItemsView.listChildren.length ).to.equal( 0 ); + } ); + + it( 'should execute action after clicking link item', () => { + linkUIFeature._showUI(); + clickNthLinksProvider( 0 ); + + const linkButton = linkUIFeature.linkProviderItemsView.listChildren.get( 0 ); + const focusSpy = testUtils.sinon.spy( linkUIFeature.formView, 'focus' ); + + linkButton.fire( 'execute' ); + + expect( linkUIFeature.formView.urlInputView.fieldView.value ).is.equal( 'https://ckeditor.com' ); + expect( linkUIFeature._balloon.visibleView ).to.be.equal( linkUIFeature.formView ); + expect( focusSpy.calledOnce ).to.be.true; + } ); + + it( 'should clear the error message that appears on first attempt of submit the form ' + + 'when next action is executed after clicking the link button', () => { + linkUIFeature._createViews(); + + const { formView } = linkUIFeature; + + formView.render(); + linkUIFeature._showUI(); + formView.fire( 'submit' ); + + expect( formView.urlInputView.errorText ).to.be.equal( 'Link URL must not be empty.' ); + + clickNthLinksProvider( 0 ); + + const bookmarkButton = linkUIFeature.linkProviderItemsView.listChildren.get( 0 ); + const focusSpy = testUtils.sinon.spy( linkUIFeature.formView, 'focus' ); + + bookmarkButton.fire( 'execute' ); + + expect( linkUIFeature.formView.urlInputView.fieldView.value ).is.equal( 'https://ckeditor.com' ); + expect( linkUIFeature._balloon.visibleView ).to.be.equal( linkUIFeature.formView ); + expect( focusSpy.calledOnce ).to.be.true; + + expect( formView.urlInputView.errorText ).to.be.null; + } ); + + function expectedShownItems( expectedLabels ) { + const labels = Array + .from( linkUIFeature.linkProviderItemsView.listChildren ) + .map( child => child.label ); + + expect( labels ).to.be.deep.equal( expectedLabels ); + } + + function backToLinksProviders() { + linkUIFeature.linkProviderItemsView.backButtonView.fire( 'execute' ); + } + + function clickNthLinksProvider( nth ) { + const providersList = linkUIFeature.formView + .template.children[ 0 ] + .find( child => child.template.attributes.class.includes( 'ck-link-form__providers-list' ) ); + + expect( providersList ).not.to.be.undefined; + + const button = providersList + .template.children[ 0 ] + .get( nth ) // li + .template.children[ 0 ] + .get( 0 ); // button + + button.fire( 'execute' ); + } } ); } ); } ); diff --git a/packages/ckeditor5-link/tests/manual/link.html b/packages/ckeditor5-link/tests/manual/link.html index af8df35b43d..9cc3b0a186c 100644 --- a/packages/ckeditor5-link/tests/manual/link.html +++ b/packages/ckeditor5-link/tests/manual/link.html @@ -1,3 +1,3 @@
-

This is CKEditor5 from CKSource.

+

This is CKEditor5 from CKSource.

diff --git a/packages/ckeditor5-link/tests/manual/link.js b/packages/ckeditor5-link/tests/manual/link.js index 390c4332b8c..3fc21c89543 100644 --- a/packages/ckeditor5-link/tests/manual/link.js +++ b/packages/ckeditor5-link/tests/manual/link.js @@ -11,11 +11,12 @@ import Typing from '@ckeditor/ckeditor5-typing/src/typing.js'; import Link from '../../src/link.js'; import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph.js'; import Undo from '@ckeditor/ckeditor5-undo/src/undo.js'; +import { Bold, Italic } from '@ckeditor/ckeditor5-basic-styles'; ClassicEditor .create( document.querySelector( '#editor' ), { - plugins: [ Link, Typing, Paragraph, Undo, Enter ], - toolbar: [ 'link', 'undo', 'redo' ] + plugins: [ Link, Bold, Italic, Typing, Paragraph, Undo, Enter ], + toolbar: [ 'link', 'bold', 'italic', '|', 'undo', 'redo' ] } ) .then( editor => { window.editor = editor; diff --git a/packages/ckeditor5-link/tests/manual/linkdecorator.js b/packages/ckeditor5-link/tests/manual/linkdecorator.js index f095b8a63a1..17ac48e1ffa 100644 --- a/packages/ckeditor5-link/tests/manual/linkdecorator.js +++ b/packages/ckeditor5-link/tests/manual/linkdecorator.js @@ -26,7 +26,8 @@ ClassicEditor attributes: { target: '_blank', rel: 'noopener noreferrer' - } + }, + defaultValue: true }, isDownloadable: { mode: 'manual', @@ -38,9 +39,7 @@ ClassicEditor isGallery: { mode: 'manual', label: 'Gallery link', - attributes: { - class: 'gallery' - } + classes: 'gallery' } } }, diff --git a/packages/ckeditor5-link/tests/manual/linkproviders.html b/packages/ckeditor5-link/tests/manual/linkproviders.html new file mode 100644 index 00000000000..5a627247df1 --- /dev/null +++ b/packages/ckeditor5-link/tests/manual/linkproviders.html @@ -0,0 +1,9 @@ +

Link Providers (list without scroll)

+ + +

Link Providers (list with scroll - when list contains more than 4 items)

+ diff --git a/packages/ckeditor5-link/tests/manual/linkproviders.js b/packages/ckeditor5-link/tests/manual/linkproviders.js new file mode 100644 index 00000000000..044fe0751a6 --- /dev/null +++ b/packages/ckeditor5-link/tests/manual/linkproviders.js @@ -0,0 +1,175 @@ +/** + * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options + */ + +/* globals console:false, window, document */ + +import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor.js'; +import Plugin from '@ckeditor/ckeditor5-core/src/plugin.js'; +import Enter from '@ckeditor/ckeditor5-enter/src/enter.js'; +import Typing from '@ckeditor/ckeditor5-typing/src/typing.js'; +import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph.js'; +import Undo from '@ckeditor/ckeditor5-undo/src/undo.js'; +import LinkUI from '../../src/linkui.js'; +import Link from '../../src/link.js'; + +import linkIcon from '../../theme/icons/link.svg'; + +const createPredefinedLinksProvider = provider => class MyLinkProvider extends Plugin { + static get requires() { + return [ Link ]; + } + + async init() { + const linkUI = this.editor.plugins.get( LinkUI ); + + linkUI.registerLinksListProvider( provider ); + } +}; + +const SocialLinksPlugin = createPredefinedLinksProvider( { + label: '🌐 Social links', + getListItems: () => [ + { + id: 'facebook', + href: 'https://facebook.com', + label: '👥 Facebook', + icon: linkIcon, + tooltip: 'Visit Facebook' + }, + { + id: 'twitter', + href: 'https://twitter.com', + label: '🐦 Twitter', + icon: linkIcon, + tooltip: 'Visit Twitter' + }, + { + id: 'linkedin', + href: 'https://linkedin.com', + label: '💼 LinkedIn', + icon: linkIcon, + tooltip: 'Visit LinkedIn' + }, + { + id: 'instagram', + href: 'https://instagram.com', + label: '📸 Instagram', + icon: linkIcon, + tooltip: 'Visit Instagram' + } + ] +} ); + +const ShopsLinksPlugin = createPredefinedLinksProvider( { + label: '🛍️ Shops links', + getListItems: () => [ + { + id: 'amazon', + href: 'https://amazon.com', + label: '🛒 Amazon', + icon: linkIcon, + tooltip: 'Shop on Amazon' + }, + { + id: 'ebay', + href: 'https://ebay.com', + label: '🛍️ eBay', + icon: linkIcon, + tooltip: 'Shop on eBay' + }, + { + id: 'allegro', + href: 'https://allegro.com', + label: '🛒 Allegro', + icon: linkIcon, + tooltip: 'Shop on Allegro' + } + ], + navigate: item => { + window.open( `${ item.href }?test=1`, '_blank' ); + return true; + } +} ); + +const EmptyLinkList = createPredefinedLinksProvider( { + label: 'No links', + getListItems: () => [] +} ); + +const CKLinkList = createPredefinedLinksProvider( { + label: 'CK links', + getListItems: () => [ + { + id: 'ckeditor', + href: 'https://ckeditor.com', + label: 'CKEditor5', + icon: linkIcon + }, + { + id: 'cksource', + href: 'https://cksource.com', + label: 'CKSource', + icon: linkIcon + } + ], + navigate: item => { + window.open( `${ item.href }`, '_blank' ); + return true; + } +} ); + +const DummyLinkList = createPredefinedLinksProvider( { + label: 'Dummy links', + getListItems: () => [ + { + id: 'dummy1', + href: 'https://example.com', + label: 'Example 1', + icon: linkIcon + }, + { + id: 'dummy2', + href: 'https://example.com', + label: 'Example 2', + icon: linkIcon + }, + { + id: 'dummy3', + href: 'https://example.com', + label: 'Example 3', + icon: linkIcon + } + ] +} ); + +ClassicEditor + .create( document.querySelector( '#editor-with-link-providers' ), { + plugins: [ + Link, Typing, Paragraph, Undo, Enter, + SocialLinksPlugin, ShopsLinksPlugin, EmptyLinkList, CKLinkList + ], + toolbar: [ 'link', 'undo', 'redo' ] + } ) + .then( editor => { + window.editor = editor; + } ) + .catch( err => { + console.error( err.stack ); + } ); + +ClassicEditor + .create( document.querySelector( '#editor-with-many-link-providers' ), { + plugins: [ + Link, Typing, Paragraph, Undo, Enter, + SocialLinksPlugin, ShopsLinksPlugin, EmptyLinkList, CKLinkList, DummyLinkList + ], + toolbar: [ 'link', 'undo', 'redo' ] + } ) + .then( editor => { + window.editor = editor; + } ) + .catch( err => { + console.error( err.stack ); + } ); diff --git a/packages/ckeditor5-link/tests/manual/linkproviders.md b/packages/ckeditor5-link/tests/manual/linkproviders.md new file mode 100644 index 00000000000..46dca715030 --- /dev/null +++ b/packages/ckeditor5-link/tests/manual/linkproviders.md @@ -0,0 +1,4 @@ +# Link Providers + +- All providers should be shown in the link panel. +- It should be possible to navigate between providers. diff --git a/packages/ckeditor5-link/tests/ui/linkactionsview.js b/packages/ckeditor5-link/tests/ui/linkactionsview.js deleted file mode 100644 index 9c021873347..00000000000 --- a/packages/ckeditor5-link/tests/ui/linkactionsview.js +++ /dev/null @@ -1,419 +0,0 @@ -/** - * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. - * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options - */ - -/* globals window, document, Event */ - -import LinkActionsView from '../../src/ui/linkactionsview.js'; -import View from '@ckeditor/ckeditor5-ui/src/view.js'; -import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard.js'; -import KeystrokeHandler from '@ckeditor/ckeditor5-utils/src/keystrokehandler.js'; -import FocusTracker from '@ckeditor/ckeditor5-utils/src/focustracker.js'; -import FocusCycler from '@ckeditor/ckeditor5-ui/src/focuscycler.js'; -import ViewCollection from '@ckeditor/ckeditor5-ui/src/viewcollection.js'; -import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils.js'; - -describe( 'LinkActionsView', () => { - 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(); - } ); - - describe( 'constructor()', () => { - it( 'should create element from template', () => { - expect( view.element.classList.contains( 'ck' ) ).to.true; - expect( view.element.classList.contains( 'ck-link-actions' ) ).to.true; - expect( view.element.classList.contains( 'ck-responsive-form' ) ).to.true; - expect( view.element.getAttribute( 'tabindex' ) ).to.equal( '-1' ); - } ); - - it( 'should create child views', () => { - expect( view.previewButtonView ).to.be.instanceOf( View ); - expect( view.unlinkButtonView ).to.be.instanceOf( View ); - expect( view.editButtonView ).to.be.instanceOf( View ); - - expect( view._unboundChildren.get( 0 ) ).to.equal( view.previewButtonView ); - expect( view._unboundChildren.get( 1 ) ).to.equal( view.editButtonView ); - expect( view._unboundChildren.get( 2 ) ).to.equal( view.unlinkButtonView ); - } ); - - it( 'should create #focusTracker instance', () => { - expect( view.focusTracker ).to.be.instanceOf( FocusTracker ); - } ); - - it( 'should create #keystrokes instance', () => { - expect( view.keystrokes ).to.be.instanceOf( KeystrokeHandler ); - } ); - - it( 'should create #_focusCycler instance', () => { - expect( view._focusCycler ).to.be.instanceOf( FocusCycler ); - } ); - - it( 'should create #_focusables view collection', () => { - expect( view._focusables ).to.be.instanceOf( ViewCollection ); - } ); - - it( 'should create #_linkConfig as empty object by default', () => { - expect( view._linkConfig ).to.be.empty; - } ); - - it( 'should create #_linkConfig containing config object passed as argument', () => { - const customConfig = { allowedProtocols: [ 'https', 'ftps', 'tel', 'sms' ] }; - - const view = new LinkActionsView( { t: () => {} }, customConfig ); - view.render(); - - expect( view._linkConfig ).to.equal( customConfig ); - - view.destroy(); - } ); - - it( 'should fire `edit` event on editButtonView#execute', () => { - const spy = sinon.spy(); - - view.on( 'edit', spy ); - - view.editButtonView.fire( 'execute' ); - - expect( spy.calledOnce ).to.true; - } ); - - it( 'should fire `unlink` event on unlinkButtonView#execute', () => { - const spy = sinon.spy(); - - view.on( 'unlink', spy ); - - view.unlinkButtonView.fire( 'execute' ); - - expect( spy.calledOnce ).to.true; - } ); - - describe( 'preview button view', () => { - it( 'is an anchor', () => { - expect( view.previewButtonView.element.tagName.toLowerCase() ).to.equal( 'a' ); - } ); - - it( 'has a CSS class', () => { - expect( view.previewButtonView.element.classList.contains( 'ck-link-actions__preview' ) ).to.be.true; - } ); - - it( 'has a "target" attribute', () => { - expect( view.previewButtonView.element.getAttribute( 'target' ) ).to.equal( '_blank' ); - } ); - - it( 'has a "rel" attribute', () => { - expect( view.previewButtonView.element.getAttribute( 'rel' ) ).to.equal( 'noopener noreferrer' ); - } ); - - describe( ' bindings', () => { - it( 'binds href DOM attribute to view#href', () => { - expect( view.previewButtonView.element.getAttribute( 'href' ) ).to.be.null; - - view.href = 'foo'; - - expect( view.previewButtonView.element.getAttribute( 'href' ) ).to.equal( 'foo' ); - } ); - - it( 'does not render unsafe view#href', () => { - view.href = 'javascript:alert(1)'; - - expect( view.previewButtonView.element.getAttribute( 'href' ) ).to.equal( '#' ); - } ); - - it( 'binds #isEnabled to view#href', () => { - expect( view.previewButtonView.isEnabled ).to.be.false; - - view.href = 'foo'; - - 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 ); - } ); - } ); - } ); - } ); - } ); - - describe( 'template', () => { - it( 'has child views', () => { - expect( view.template.children[ 0 ] ).to.equal( view.previewButtonView ); - expect( view.template.children[ 1 ] ).to.equal( view.editButtonView ); - expect( view.template.children[ 2 ] ).to.equal( view.unlinkButtonView ); - } ); - } ); - } ); - - describe( 'render()', () => { - it( 'should register child views in #_focusables', () => { - expect( view._focusables.map( f => f ) ).to.have.members( [ - view.previewButtonView, - view.editButtonView, - view.unlinkButtonView - ] ); - } ); - - it( 'should register child views\' #element in #focusTracker', () => { - const spy = testUtils.sinon.spy( FocusTracker.prototype, 'add' ); - - const view = new LinkActionsView( { t: () => {} } ); - view.render(); - - sinon.assert.calledWithExactly( spy.getCall( 0 ), view.previewButtonView.element ); - sinon.assert.calledWithExactly( spy.getCall( 1 ), view.editButtonView.element ); - sinon.assert.calledWithExactly( spy.getCall( 2 ), view.unlinkButtonView.element ); - - view.destroy(); - } ); - - it( 'starts listening for #keystrokes coming from #element', () => { - const view = new LinkActionsView( { t: () => {} } ); - - const spy = sinon.spy( view.keystrokes, 'listenTo' ); - - view.render(); - sinon.assert.calledOnce( spy ); - sinon.assert.calledWithExactly( spy, view.element ); - - view.destroy(); - } ); - - describe( 'activates keyboard navigation for the toolbar', () => { - it( 'so "tab" focuses the next focusable item', () => { - const keyEvtData = { - keyCode: keyCodes.tab, - preventDefault: sinon.spy(), - stopPropagation: sinon.spy() - }; - - // Mock the preview button is focused. - view.focusTracker.isFocused = true; - view.focusTracker.focusedElement = view.previewButtonView.element; - - const spy = sinon.spy( view.editButtonView, 'focus' ); - - view.keystrokes.press( keyEvtData ); - sinon.assert.calledOnce( keyEvtData.preventDefault ); - sinon.assert.calledOnce( keyEvtData.stopPropagation ); - sinon.assert.calledOnce( spy ); - } ); - - it( 'so "shift + tab" focuses the previous focusable item', () => { - const keyEvtData = { - keyCode: keyCodes.tab, - shiftKey: true, - preventDefault: sinon.spy(), - stopPropagation: sinon.spy() - }; - - // Mock the edit button is focused. - view.focusTracker.isFocused = true; - view.focusTracker.focusedElement = view.editButtonView.element; - - const spy = sinon.spy( view.previewButtonView, 'focus' ); - - view.keystrokes.press( keyEvtData ); - sinon.assert.calledOnce( keyEvtData.preventDefault ); - sinon.assert.calledOnce( keyEvtData.stopPropagation ); - sinon.assert.calledOnce( spy ); - } ); - } ); - } ); - - describe( 'destroy()', () => { - it( 'should destroy the FocusTracker instance', () => { - const destroySpy = sinon.spy( view.focusTracker, 'destroy' ); - - view.destroy(); - - sinon.assert.calledOnce( destroySpy ); - } ); - - it( 'should destroy the KeystrokeHandler instance', () => { - const destroySpy = sinon.spy( view.keystrokes, 'destroy' ); - - view.destroy(); - - sinon.assert.calledOnce( destroySpy ); - } ); - } ); - - describe( 'focus()', () => { - it( 'focuses the #previewButtonView', () => { - const spy = sinon.spy( view.previewButtonView, 'focus' ); - - view.focus(); - - sinon.assert.calledOnce( spy ); - } ); - } ); -} ); diff --git a/packages/ckeditor5-link/tests/ui/linkformview.js b/packages/ckeditor5-link/tests/ui/linkformview.js index 3ce81e142fe..37989150f0a 100644 --- a/packages/ckeditor5-link/tests/ui/linkformview.js +++ b/packages/ckeditor5-link/tests/ui/linkformview.js @@ -6,20 +6,12 @@ /* globals Event, document */ import LinkFormView from '../../src/ui/linkformview.js'; -import View from '@ckeditor/ckeditor5-ui/src/view.js'; +import LinkButtonView from '../../src/ui/linkbuttonview.js'; +import { ListView, View, FocusCycler, ViewCollection } from '@ckeditor/ckeditor5-ui'; import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard.js'; import KeystrokeHandler from '@ckeditor/ckeditor5-utils/src/keystrokehandler.js'; import FocusTracker from '@ckeditor/ckeditor5-utils/src/focustracker.js'; -import FocusCycler from '@ckeditor/ckeditor5-ui/src/focuscycler.js'; -import ViewCollection from '@ckeditor/ckeditor5-ui/src/viewcollection.js'; import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils.js'; -import ManualDecorator from '../../src/utils/manualdecorator.js'; -import Collection from '@ckeditor/ckeditor5-utils/src/collection.js'; -import { add as addTranslations, _clear as clearTranslations } from '@ckeditor/ckeditor5-utils/src/translation-service.js'; -import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor.js'; -import Link from '../../src/link.js'; -import ObservableMixin from '@ckeditor/ckeditor5-utils/src/observablemixin.js'; -import mix from '@ckeditor/ckeditor5-utils/src/mix.js'; describe( 'LinkFormView', () => { let view; @@ -27,7 +19,7 @@ describe( 'LinkFormView', () => { testUtils.createSinonSandbox(); beforeEach( () => { - view = new LinkFormView( { t: val => val }, { manualDecorators: [] } ); + view = new LinkFormView( { t: val => val } ); view.render(); document.body.appendChild( view.element ); } ); @@ -40,22 +32,16 @@ describe( 'LinkFormView', () => { describe( 'constructor()', () => { it( 'should create element from template', () => { expect( view.element.classList.contains( 'ck' ) ).to.true; + expect( view.element.classList.contains( 'ck-form' ) ).to.true; expect( view.element.classList.contains( 'ck-link-form' ) ).to.true; expect( view.element.classList.contains( 'ck-responsive-form' ) ).to.true; - expect( view.element.getAttribute( 'tabindex' ) ).to.equal( '-1' ); } ); it( 'should create child views', () => { - expect( view.urlInputView ).to.be.instanceOf( View ); + expect( view.backButtonView ).to.be.instanceOf( View ); expect( view.saveButtonView ).to.be.instanceOf( View ); - expect( view.cancelButtonView ).to.be.instanceOf( View ); - - expect( view.saveButtonView.element.classList.contains( 'ck-button-save' ) ).to.be.true; - expect( view.cancelButtonView.element.classList.contains( 'ck-button-cancel' ) ).to.be.true; - - expect( view.children.get( 0 ) ).to.equal( view.urlInputView ); - expect( view.children.get( 1 ) ).to.equal( view.saveButtonView ); - expect( view.children.get( 2 ) ).to.equal( view.cancelButtonView ); + expect( view.displayedTextInputView ).to.be.instanceOf( View ); + expect( view.urlInputView ).to.be.instanceOf( View ); } ); it( 'should create #focusTracker instance', () => { @@ -74,12 +60,12 @@ describe( 'LinkFormView', () => { expect( view._focusables ).to.be.instanceOf( ViewCollection ); } ); - it( 'should fire `cancel` event on cancelButtonView#execute', () => { + it( 'should fire `cancel` event on backButtonView#execute', () => { const spy = sinon.spy(); view.on( 'cancel', spy ); - view.cancelButtonView.fire( 'execute' ); + view.backButtonView.fire( 'execute' ); expect( spy.calledOnce ).to.true; } ); @@ -89,13 +75,51 @@ describe( 'LinkFormView', () => { } ); describe( 'template', () => { + /** + * form + * header + * backButtonView + * label + * formRow + * displayedTextInputView + * formRow + * urlInputView + * saveButtonView + * linksButton + */ + it( 'has url input view', () => { - expect( view.template.children[ 0 ].get( 0 ) ).to.equal( view.urlInputView ); + const firstFormRow = view.template.children[ 0 ].get( 1 ); + const secondFormRow = view.template.children[ 0 ].get( 2 ); + + expect( firstFormRow.template.children[ 0 ].get( 0 ) ).to.equal( view.displayedTextInputView ); + expect( secondFormRow.template.children[ 0 ].get( 0 ) ).to.equal( view.urlInputView ); } ); it( 'has button views', () => { - expect( view.template.children[ 0 ].get( 1 ) ).to.equal( view.saveButtonView ); - expect( view.template.children[ 0 ].get( 2 ) ).to.equal( view.cancelButtonView ); + const headerChildren = view.template.children[ 0 ].get( 0 ).template.children[ 0 ]; + const secondFormRow = view.template.children[ 0 ].get( 2 ); + + expect( headerChildren.get( 0 ) ).to.equal( view.backButtonView ); + expect( secondFormRow.template.children[ 0 ].get( 1 ) ).to.equal( view.saveButtonView ); + } ); + + it( 'should `saveButtonView` has no tooltip', () => { + expect( view.saveButtonView.tooltip ).to.be.false; + } ); + + it( 'should `backButtonView` has correct label', () => { + const headerChildren = view.template.children[ 0 ].get( 0 ).template.children[ 0 ]; + const backButton = headerChildren.get( 0 ); + + expect( backButton.template.children[ 0 ].get( 1 ).text ).to.equal( 'Back' ); + } ); + + it( 'should `backButtonView` has correct CSS class', () => { + const headerChildren = view.template.children[ 0 ].get( 0 ).template.children[ 0 ]; + const backButton = headerChildren.get( 0 ); + + expect( backButton.class ).to.equal( 'ck-button-back' ); } ); } ); } ); @@ -105,30 +129,31 @@ describe( 'LinkFormView', () => { expect( view._focusables.map( f => f ) ).to.have.members( [ view.urlInputView, view.saveButtonView, - view.cancelButtonView + view.backButtonView, + view.displayedTextInputView ] ); } ); - it( 'should register child views\' #element in #focusTracker', () => { - const view = new LinkFormView( { t: () => {} }, { manualDecorators: [] } ); - + it( 'should register child views #element in #focusTracker', () => { + const view = new LinkFormView( { t: () => {} } ); const spy = testUtils.sinon.spy( view.focusTracker, 'add' ); view.render(); sinon.assert.calledWithExactly( spy.getCall( 0 ), view.urlInputView.element ); sinon.assert.calledWithExactly( spy.getCall( 1 ), view.saveButtonView.element ); - sinon.assert.calledWithExactly( spy.getCall( 2 ), view.cancelButtonView.element ); + sinon.assert.calledWithExactly( spy.getCall( 2 ), view.backButtonView.element ); + sinon.assert.calledWithExactly( spy.getCall( 3 ), view.displayedTextInputView.element ); view.destroy(); } ); it( 'starts listening for #keystrokes coming from #element', () => { - const view = new LinkFormView( { t: () => {} }, { manualDecorators: [] } ); - + const view = new LinkFormView( { t: () => {} } ); const spy = sinon.spy( view.keystrokes, 'listenTo' ); view.render(); + sinon.assert.calledOnce( spy ); sinon.assert.calledWithExactly( spy, view.element ); @@ -137,6 +162,8 @@ describe( 'LinkFormView', () => { describe( 'activates keyboard navigation for the toolbar', () => { it( 'so "tab" focuses the next focusable item', () => { + const spy = sinon.spy( view.saveButtonView, 'focus' ); + const keyEvtData = { keyCode: keyCodes.tab, preventDefault: sinon.spy(), @@ -146,16 +173,16 @@ describe( 'LinkFormView', () => { // Mock the url input is focused. view.focusTracker.isFocused = true; view.focusTracker.focusedElement = view.urlInputView.element; - - const spy = sinon.spy( view.saveButtonView, 'focus' ); - view.keystrokes.press( keyEvtData ); + sinon.assert.calledOnce( keyEvtData.preventDefault ); sinon.assert.calledOnce( keyEvtData.stopPropagation ); sinon.assert.calledOnce( spy ); } ); it( 'so "shift + tab" focuses the previous focusable item', () => { + const spy = sinon.spy( view.saveButtonView, 'focus' ); + const keyEvtData = { keyCode: keyCodes.tab, shiftKey: true, @@ -165,11 +192,9 @@ describe( 'LinkFormView', () => { // Mock the cancel button is focused. view.focusTracker.isFocused = true; - view.focusTracker.focusedElement = view.cancelButtonView.element; - - const spy = sinon.spy( view.saveButtonView, 'focus' ); - + view.focusTracker.focusedElement = view.backButtonView.element; view.keystrokes.press( keyEvtData ); + sinon.assert.calledOnce( keyEvtData.preventDefault ); sinon.assert.calledOnce( keyEvtData.stopPropagation ); sinon.assert.calledOnce( spy ); @@ -179,7 +204,7 @@ describe( 'LinkFormView', () => { describe( 'isValid()', () => { it( 'should reset error after successful validation', () => { - const view = new LinkFormView( { t: () => {} }, { manualDecorators: [] }, [ + const view = new LinkFormView( { t: () => {} }, [ () => undefined ] ); @@ -188,7 +213,7 @@ describe( 'LinkFormView', () => { } ); it( 'should display first error returned from validators list', () => { - const view = new LinkFormView( { t: () => {} }, { manualDecorators: [] }, [ + const view = new LinkFormView( { t: () => {} }, [ () => undefined, () => 'Foo bar', () => 'Another error' @@ -200,7 +225,7 @@ describe( 'LinkFormView', () => { it( 'should pass view reference as argument to validator', () => { const validatorSpy = sinon.spy(); - const view = new LinkFormView( { t: () => {} }, { manualDecorators: [] }, [ validatorSpy ] ); + const view = new LinkFormView( { t: () => {} }, [ validatorSpy ] ); view.isValid(); @@ -271,241 +296,54 @@ describe( 'LinkFormView', () => { } ); } ); - describe( 'manual decorators', () => { - let view, collection, linkCommand; + describe( 'allows adding more form views', () => { + let button; beforeEach( () => { - collection = new Collection(); - collection.add( new ManualDecorator( { - id: 'decorator1', - label: 'Foo', - attributes: { - foo: 'bar' - } - } ) ); - collection.add( new ManualDecorator( { - id: 'decorator2', - label: 'Download', - attributes: { - download: 'download' - }, - defaultValue: true - } ) ); - collection.add( new ManualDecorator( { - id: 'decorator3', - label: 'Multi', - attributes: { - class: 'fancy-class', - target: '_blank', - rel: 'noopener noreferrer' - } - } ) ); - - class LinkCommandMock { - constructor( manualDecorators ) { - this.manualDecorators = manualDecorators; - this.set( 'value' ); - } - } - mix( LinkCommandMock, ObservableMixin ); - - linkCommand = new LinkCommandMock( collection ); - - view = new LinkFormView( { t: val => val }, linkCommand ); - view.render(); - } ); - - afterEach( () => { - view.destroy(); - collection.clear(); - } ); - - it( 'switch buttons reflects state of manual decorators', () => { - expect( view._manualDecoratorSwitches.length ).to.equal( 3 ); + button = new LinkButtonView(); - expect( view._manualDecoratorSwitches.get( 0 ) ).to.deep.include( { - name: 'decorator1', - label: 'Foo', - isOn: false - } ); - expect( view._manualDecoratorSwitches.get( 1 ) ).to.deep.include( { - name: 'decorator2', - label: 'Download', - isOn: true + button.set( { + label: 'Button' } ); - expect( view._manualDecoratorSwitches.get( 2 ) ).to.deep.include( { - name: 'decorator3', - label: 'Multi', - isOn: false - } ); - } ); - - it( 'reacts on switch button changes', () => { - const modelItem = collection.first; - const viewItem = view._manualDecoratorSwitches.first; - - expect( modelItem.value ).to.be.undefined; - expect( viewItem.isOn ).to.be.false; - - viewItem.element.dispatchEvent( new Event( 'click' ) ); - - expect( modelItem.value ).to.be.true; - expect( viewItem.isOn ).to.be.true; - viewItem.element.dispatchEvent( new Event( 'click' ) ); - - expect( modelItem.value ).to.be.false; - expect( viewItem.isOn ).to.be.false; + view.providersListChildren.add( button ); } ); - it( 'reacts on switch button changes for the decorator with defaultValue', () => { - const modelItem = collection.get( 1 ); - const viewItem = view._manualDecoratorSwitches.get( 1 ); - - expect( modelItem.value ).to.be.undefined; - expect( viewItem.isOn ).to.be.true; - - viewItem.element.dispatchEvent( new Event( 'click' ) ); - - expect( modelItem.value ).to.be.false; - expect( viewItem.isOn ).to.be.false; - - viewItem.element.dispatchEvent( new Event( 'click' ) ); - - expect( modelItem.value ).to.be.true; - expect( viewItem.isOn ).to.be.true; + afterEach( () => { + button.destroy(); } ); - describe( 'getDecoratorSwitchesState()', () => { - it( 'should provide object with decorators states', () => { - expect( view.getDecoratorSwitchesState() ).to.deep.equal( { - decorator1: false, - decorator2: true, - decorator3: false - } ); - - view._manualDecoratorSwitches.map( item => { - item.element.dispatchEvent( new Event( 'click' ) ); - } ); - - view._manualDecoratorSwitches.get( 2 ).element.dispatchEvent( new Event( 'click' ) ); - - expect( view.getDecoratorSwitchesState() ).to.deep.equal( { - decorator1: true, - decorator2: false, - decorator3: false - } ); - } ); + it( 'adds list view', () => { + const listView = view.children.get( 3 ); + const button = listView.template.children[ 0 ].get( 0 ).template.children[ 0 ].get( 0 ); - it( 'should use decorator default value if command and decorator values are not set', () => { - expect( view.getDecoratorSwitchesState() ).to.deep.equal( { - decorator1: false, - decorator2: true, - decorator3: false - } ); - } ); - - it( 'should use a decorator value if decorator value is set', () => { - for ( const decorator of collection ) { - decorator.value = true; - } - - expect( view.getDecoratorSwitchesState() ).to.deep.equal( { - decorator1: true, - decorator2: true, - decorator3: true - } ); - - for ( const decorator of collection ) { - decorator.value = false; - } - - expect( view.getDecoratorSwitchesState() ).to.deep.equal( { - decorator1: false, - decorator2: false, - decorator3: false - } ); - } ); - - it( 'should use a decorator value if link command value is set', () => { - linkCommand.value = ''; - - expect( view.getDecoratorSwitchesState() ).to.deep.equal( { - decorator1: false, - decorator2: false, - decorator3: false - } ); - - for ( const decorator of collection ) { - decorator.value = false; - } - - expect( view.getDecoratorSwitchesState() ).to.deep.equal( { - decorator1: false, - decorator2: false, - decorator3: false - } ); - - for ( const decorator of collection ) { - decorator.value = true; - } - - expect( view.getDecoratorSwitchesState() ).to.deep.equal( { - decorator1: true, - decorator2: true, - decorator3: true - } ); - } ); + expect( button ).to.be.instanceOf( LinkButtonView ); + expect( listView ).to.be.instanceOf( ListView ); } ); - } ); - describe( 'localization of manual decorators', () => { - before( () => { - addTranslations( 'pl', { - 'Open in a new tab': 'Otwórz w nowym oknie' + it( 'should register list view items in #focusTracker', () => { + const view = new LinkFormView( { t: () => { } } ); + const button = new LinkButtonView(); + + button.set( { + label: 'Button' } ); - } ); - after( () => { - clearTranslations(); - } ); - let editor, editorElement, linkFormView; + view.providersListChildren.add( button ); - beforeEach( () => { - editorElement = document.createElement( 'div' ); - document.body.appendChild( editorElement ); - - return ClassicTestEditor - .create( editorElement, { - plugins: [ Link ], - toolbar: [ 'link' ], - language: 'pl', - link: { - decorators: { - IsExternal: { - mode: 'manual', - label: 'Open in a new tab', - attributes: { - target: '_blank' - } - } - } - } - } ) - .then( newEditor => { - editor = newEditor; - linkFormView = new LinkFormView( editor.locale, editor.commands.get( 'link' ) ); - } ); - } ); + const spy = testUtils.sinon.spy( view.focusTracker, 'add' ); + const listView = view.children.get( 3 ); + const { element } = listView.template.children[ 0 ].get( 0 ).template.children[ 0 ].get( 0 ); - afterEach( () => { - editorElement.remove(); + view.render(); - return editor.destroy(); - } ); + sinon.assert.calledWithExactly( spy.getCall( 0 ), view.urlInputView.element ); + sinon.assert.calledWithExactly( spy.getCall( 1 ), view.saveButtonView.element ); + sinon.assert.calledWithExactly( spy.getCall( 2 ), element ); + sinon.assert.calledWithExactly( spy.getCall( 3 ), view.backButtonView.element ); + sinon.assert.calledWithExactly( spy.getCall( 4 ), view.displayedTextInputView.element ); - it( 'translates labels of manual decorators UI', () => { - expect( linkFormView._manualDecoratorSwitches.first.label ).to.equal( 'Otwórz w nowym oknie' ); + view.destroy(); } ); } ); } ); diff --git a/packages/ckeditor5-link/tests/ui/linkpreviewbuttonview.js b/packages/ckeditor5-link/tests/ui/linkpreviewbuttonview.js new file mode 100644 index 00000000000..63d43d7c92d --- /dev/null +++ b/packages/ckeditor5-link/tests/ui/linkpreviewbuttonview.js @@ -0,0 +1,104 @@ +/** + * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options + */ + +/* globals document, Event */ + +import LinkPreviewButtonView from '../../src/ui/linkpreviewbuttonview.js'; +import { ButtonView } from '@ckeditor/ckeditor5-ui'; +import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils.js'; + +describe( 'LinkPreviewButtonView', () => { + let view; + + testUtils.createSinonSandbox(); + + beforeEach( () => { + view = new LinkPreviewButtonView( { t: () => {} } ); + view.render(); + document.body.appendChild( view.element ); + } ); + + afterEach( () => { + view.element.remove(); + view.destroy(); + } ); + + it( 'should extend ButtonView', () => { + expect( view ).to.be.instanceOf( ButtonView ); + } ); + + it( 'is an anchor', () => { + expect( view.element.tagName.toLowerCase() ).to.equal( 'a' ); + } ); + + it( 'has a CSS class', () => { + expect( view.element.classList.contains( 'ck-link-toolbar__preview' ) ).to.be.true; + } ); + + it( 'has a "target" attribute', () => { + expect( view.element.getAttribute( 'target' ) ).to.equal( '_blank' ); + } ); + + it( 'has a "rel" attribute', () => { + expect( view.element.getAttribute( 'rel' ) ).to.equal( 'noopener noreferrer' ); + } ); + + it( 'binds href DOM attribute to view#href', () => { + expect( view.element.getAttribute( 'href' ) ).to.be.null; + + view.href = 'foo'; + + expect( view.element.getAttribute( 'href' ) ).to.equal( 'foo' ); + } ); + + it( 'does not trigger `navigate` event if #href is not set', () => { + const spy = sinon.spy(); + + view.on( 'navigate', spy ); + + view.href = ''; + view.element.dispatchEvent( new Event( 'click' ) ); + + sinon.assert.notCalled( spy ); + } ); + + it( 'triggers `navigate` event if #href is set', () => { + const spy = sinon.spy(); + + view.on( 'navigate', spy ); + + view.href = 'foo'; + view.element.dispatchEvent( new Event( 'click' ) ); + + sinon.assert.calledOnce( spy ); + } ); + + it( 'the `navigate` event provides a #href value', () => { + const spy = sinon.spy(); + + view.on( 'navigate', spy ); + + view.href = 'foo'; + view.element.dispatchEvent( new Event( 'click' ) ); + + sinon.assert.calledOnce( spy ); + expect( spy.firstCall.args[ 1 ] ).to.equal( 'foo' ); + } ); + + it( 'the `navigate` event can be canceled', () => { + const event = new Event( 'click' ); + + sinon.stub( event, 'preventDefault' ); + + view.on( 'navigate', ( evt, href, cancel ) => cancel() ); + + sinon.assert.notCalled( event.preventDefault ); + + view.href = 'foo'; + view.element.dispatchEvent( event ); + + sinon.assert.calledOnce( event.preventDefault ); + } ); +} ); diff --git a/packages/ckeditor5-link/tests/ui/linkpropertiesview.js b/packages/ckeditor5-link/tests/ui/linkpropertiesview.js new file mode 100644 index 00000000000..e9fdb70bb69 --- /dev/null +++ b/packages/ckeditor5-link/tests/ui/linkpropertiesview.js @@ -0,0 +1,242 @@ +/** + * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options + */ + +/* globals document */ + +import { + Collection, + KeystrokeHandler, + FocusTracker, + keyCodes +} from '@ckeditor/ckeditor5-utils'; +import { + View, + FocusCycler, + ViewCollection, + SwitchButtonView +} from '@ckeditor/ckeditor5-ui'; +import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils.js'; +import LinkPropertiesView from '../../src/ui/linkpropertiesview.js'; +import ManualDecorator from '../../src/utils/manualdecorator.js'; + +const mockLocale = { t: val => val }; + +describe( 'LinkPropertiesView', () => { + let view, collection, linkCommand, decorator1, decorator2, decorator3; + + testUtils.createSinonSandbox(); + + beforeEach( () => { + collection = new Collection(); + + decorator1 = new ManualDecorator( { + id: 'decorator1', + label: 'Foo', + attributes: { + foo: 'bar' + } + } ); + + decorator2 = new ManualDecorator( { + id: 'decorator2', + label: 'Download', + attributes: { + download: 'download' + }, + defaultValue: true + } ); + + decorator3 = new ManualDecorator( { + id: 'decorator3', + label: 'Multi', + attributes: { + class: 'fancy-class', + target: '_blank', + rel: 'noopener noreferrer' + } + } ); + + collection.addMany( [ + decorator1, + decorator2, + decorator3 + ] ); + + view = new LinkPropertiesView( mockLocale ); + + view.listChildren.bindTo( collection ).using( decorator => { + const button = new SwitchButtonView(); + + button.set( { + label: decorator.label, + withText: true + } ); + + button.bind( 'isOn' ).toMany( [ decorator ], 'value', decoratorValue => { + return Boolean( decoratorValue === undefined ? decorator.defaultValue : decoratorValue ); + } ); + + button.on( 'execute', () => { + decorator.set( 'value', !button.isOn ); + } ); + + return button; + } ); + + view.render(); + document.body.appendChild( view.element ); + } ); + + afterEach( () => { + view.element.remove(); + view.destroy(); + collection.clear(); + } ); + + describe( 'constructor()', () => { + it( 'should create element from template', () => { + expect( view.element.tagName.toLowerCase() ).to.equal( 'div' ); + expect( view.element.classList.contains( 'ck' ) ).to.true; + expect( view.element.classList.contains( 'ck-link-properties' ) ).to.true; + } ); + + it( 'should create child views', () => { + expect( view.backButtonView ).to.be.instanceOf( View ); + } ); + + it( 'should create #focusTracker instance', () => { + expect( view.focusTracker ).to.be.instanceOf( FocusTracker ); + } ); + + it( 'should create #keystrokes instance', () => { + expect( view.keystrokes ).to.be.instanceOf( KeystrokeHandler ); + } ); + + it( 'should create #_focusCycler instance', () => { + expect( view._focusCycler ).to.be.instanceOf( FocusCycler ); + } ); + + it( 'should create #_focusables view collection', () => { + expect( view._focusables ).to.be.instanceOf( ViewCollection ); + } ); + + it( 'should fire `back` event on backButtonView#execute', () => { + const spy = sinon.spy(); + + view.on( 'back', spy ); + + view.backButtonView.fire( 'execute' ); + + expect( spy.calledOnce ).to.true; + } ); + + describe( 'template', () => { + it( 'has back button', () => { + const button = view.template.children[ 0 ].get( 0 ).template.children[ 0 ].get( 0 ); + + expect( button ).to.equal( view.backButtonView ); + } ); + } ); + } ); + + describe( 'render()', () => { + it( 'should register child views in #_focusables', () => { + expect( view._focusables.map( f => f ) ).to.have.members( [ + view.backButtonView, + ...view.listChildren + ] ); + } ); + + it( 'should register child views #element in #focusTracker', () => { + expect( view.focusTracker.elements[ 0 ] ).to.equal( view.listChildren.get( 0 ).element ); + expect( view.focusTracker.elements[ 1 ] ).to.equal( view.listChildren.get( 1 ).element ); + expect( view.focusTracker.elements[ 2 ] ).to.equal( view.listChildren.get( 2 ).element ); + expect( view.focusTracker.elements[ 3 ] ).to.equal( view.backButtonView.element ); + + view.destroy(); + } ); + + it( 'starts listening for #keystrokes coming from #element', () => { + const view = new LinkPropertiesView( mockLocale, linkCommand ); + const spy = sinon.spy( view.keystrokes, 'listenTo' ); + + view.render(); + + sinon.assert.calledOnce( spy ); + sinon.assert.calledWithExactly( spy, view.element ); + + view.destroy(); + } ); + + describe( 'activates keyboard navigation', () => { + it( 'so "tab" focuses the next focusable item', () => { + const spy = sinon.spy( view.backButtonView, 'focus' ); + + const keyEvtData = { + keyCode: keyCodes.tab, + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + }; + + // Mock the focus on last switch button. + view.focusTracker.isFocused = true; + view.focusTracker.focusedElement = view.listChildren.last.element; + view.keystrokes.press( keyEvtData ); + + sinon.assert.calledOnce( keyEvtData.preventDefault ); + sinon.assert.calledOnce( keyEvtData.stopPropagation ); + sinon.assert.calledOnce( spy ); + } ); + + it( 'so "shift + tab" focuses the previous focusable item', () => { + const spy = sinon.spy( view.listChildren.last, 'focus' ); + + const keyEvtData = { + keyCode: keyCodes.tab, + shiftKey: true, + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + }; + + // Mock the cancel button is focused. + view.focusTracker.isFocused = true; + view.focusTracker.focusedElement = view.backButtonView.element; + view.keystrokes.press( keyEvtData ); + + sinon.assert.calledOnce( keyEvtData.preventDefault ); + sinon.assert.calledOnce( keyEvtData.stopPropagation ); + sinon.assert.calledOnce( spy ); + } ); + } ); + } ); + + describe( 'destroy()', () => { + it( 'should destroy the FocusTracker instance', () => { + const destroySpy = sinon.spy( view.focusTracker, 'destroy' ); + + view.destroy(); + + sinon.assert.calledOnce( destroySpy ); + } ); + + it( 'should destroy the KeystrokeHandler instance', () => { + const destroySpy = sinon.spy( view.keystrokes, 'destroy' ); + + view.destroy(); + + sinon.assert.calledOnce( destroySpy ); + } ); + } ); + + describe( 'focus()', () => { + it( 'focuses the first switch button', () => { + const spy = sinon.spy( view.listChildren.first, 'focus' ); + + view.focus(); + + sinon.assert.calledOnce( spy ); + } ); + } ); +} ); diff --git a/packages/ckeditor5-link/tests/ui/linkprovideritemsview.js b/packages/ckeditor5-link/tests/ui/linkprovideritemsview.js new file mode 100644 index 00000000000..7d9f37deeaf --- /dev/null +++ b/packages/ckeditor5-link/tests/ui/linkprovideritemsview.js @@ -0,0 +1,298 @@ +/** + * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options + */ + +/* globals document */ + +import { + KeystrokeHandler, + FocusTracker, + keyCodes +} from '@ckeditor/ckeditor5-utils'; + +import { + View, + ListView, + FocusCycler, + ViewCollection, + ButtonView +} from '@ckeditor/ckeditor5-ui'; + +import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils.js'; + +import LinkProviderItemsView from '../../src/ui/linkprovideritemsview.js'; + +const mockLocale = { t: val => val }; + +describe( 'LinkProviderItemsView', () => { + let view, linksButtonsArrayMock; + + testUtils.createSinonSandbox(); + + beforeEach( () => { + view = new LinkProviderItemsView( mockLocale ); + view.render(); + document.body.appendChild( view.element ); + + linksButtonsArrayMock = [ + createButton( 'Mocked link button 1' ), + createButton( 'Mocked link button 2' ), + createButton( 'Mocked link button 3' ) + ]; + } ); + + afterEach( () => { + view.element.remove(); + view.destroy(); + } ); + + describe( 'constructor()', () => { + it( 'should create element from template', () => { + expect( view.element.tagName.toLowerCase() ).to.equal( 'div' ); + expect( view.element.classList.contains( 'ck' ) ).to.true; + expect( view.element.classList.contains( 'ck-link-providers' ) ).to.true; + expect( view.element.getAttribute( 'tabindex' ) ).to.equal( '-1' ); + } ); + + it( 'should create child views', () => { + expect( view.backButtonView ).to.be.instanceOf( ButtonView ); + expect( view.listView ).to.be.instanceOf( ListView ); + expect( view.emptyListInformation ).to.be.instanceOf( View ); + expect( view.children ).to.be.instanceOf( ViewCollection ); + expect( view.listChildren ).to.be.instanceOf( ViewCollection ); + expect( view.children ).to.be.instanceOf( ViewCollection ); + } ); + + it( 'should create #focusTracker instance', () => { + expect( view.focusTracker ).to.be.instanceOf( FocusTracker ); + } ); + + it( 'should create #keystrokes instance', () => { + expect( view.keystrokes ).to.be.instanceOf( KeystrokeHandler ); + } ); + + it( 'should create #_focusCycler instance', () => { + expect( view._focusCycler ).to.be.instanceOf( FocusCycler ); + } ); + + it( 'should create #_focusables view collection', () => { + expect( view._focusables ).to.be.instanceOf( ViewCollection ); + } ); + + it( 'should create #hasItems instance and set it to `false`', () => { + expect( view.hasItems ).to.be.equal( false ); + + view.listChildren.addMany( linksButtonsArrayMock ); + + expect( view.hasItems ).to.be.equal( true ); + + view.listChildren.clear(); + + expect( view.hasItems ).to.be.equal( false ); + } ); + + it( 'should fire `cancel` event on backButtonView#execute', () => { + const spy = sinon.spy(); + + view.on( 'cancel', spy ); + + view.backButtonView.fire( 'execute' ); + + sinon.assert.calledOnce( spy ); + } ); + + describe( 'template', () => { + it( 'has back button', () => { + const button = view.template.children[ 0 ].get( 0 ).template.children[ 0 ].get( 0 ); + + expect( button ).to.equal( view.backButtonView ); + expect( button.template.children[ 0 ].get( 1 ).text ).to.equal( 'Back' ); + } ); + } ); + + it( 'should create emptyListInformation element from template', () => { + const emptyListInformation = view.emptyListInformation; + + expect( emptyListInformation.element.tagName.toLowerCase() ).to.equal( 'p' ); + expect( emptyListInformation.element.classList.contains( 'ck' ) ).to.true; + expect( emptyListInformation.element.classList.contains( 'ck-link__empty-list-info' ) ).to.true; + } ); + } ); + + describe( 'bindings', () => { + it( 'should hide after Esc key press', () => { + const keyEvtData = { + keyCode: keyCodes.esc, + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + }; + const spy = sinon.spy(); + + view.on( 'cancel', spy ); + + view.keystrokes.press( keyEvtData ); + + sinon.assert.calledOnce( spy ); + sinon.assert.calledOnce( keyEvtData.preventDefault ); + sinon.assert.calledOnce( keyEvtData.stopPropagation ); + } ); + + it( 'should bind the #title to headerView.label', () => { + view.title = 'Mocked header label'; + + expect( view.children.get( 0 ).label ).to.equal( 'Mocked header label' ); + } ); + + it( 'should bind the #emptyListInformation to emptyListInformation', () => { + view.emptyListPlaceholder = 'Mocked empty list information'; + + expect( view.emptyListInformation.element.innerText ).to.equal( 'Mocked empty list information' ); + } ); + } ); + + describe( 'render()', () => { + it( 'should register child views in #_focusables', () => { + expect( view._focusables.map( f => f ) ).to.have.members( [ + view.backButtonView, + view.listView + ] ); + } ); + + it( 'should register child views #element in #focusTracker', () => { + const view = new LinkProviderItemsView( mockLocale ); + const spy = testUtils.sinon.spy( view.focusTracker, 'add' ); + + view.render(); + + sinon.assert.calledWithExactly( spy.getCall( 0 ), view.listView.element ); + sinon.assert.calledWithExactly( spy.getCall( 1 ), view.backButtonView.element ); + + view.destroy(); + } ); + + it( 'starts listening for #keystrokes coming from #element', () => { + const view = new LinkProviderItemsView( mockLocale ); + const spy = sinon.spy( view.keystrokes, 'listenTo' ); + + view.render(); + + sinon.assert.calledOnce( spy ); + sinon.assert.calledWithExactly( spy, view.element ); + + view.destroy(); + } ); + + describe( 'activates keyboard navigation', () => { + let view; + + testUtils.createSinonSandbox(); + + beforeEach( () => { + view = new LinkProviderItemsView( mockLocale ); + view.render(); + document.body.appendChild( view.element ); + + view.listChildren.addMany( linksButtonsArrayMock ); + } ); + + afterEach( () => { + view.element.remove(); + view.destroy(); + } ); + + it( 'so "tab" focuses the next focusable item', () => { + expect( view.hasItems ).to.be.equal( true ); + + const spy = sinon.spy( view.backButtonView, 'focus' ); + const keyEvtData = { + keyCode: keyCodes.tab, + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + }; + + // Mock the focus on list. + view.focusTracker.isFocused = true; + view.focusTracker.focusedElement = view.listView.element; + view.keystrokes.press( keyEvtData ); + + sinon.assert.calledOnce( keyEvtData.preventDefault ); + sinon.assert.calledOnce( keyEvtData.stopPropagation ); + sinon.assert.calledOnce( spy ); + } ); + + it( 'so "shift + tab" focuses the previous focusable item', () => { + expect( view.hasItems ).to.be.equal( true ); + + const spy = sinon.spy( view.listView, 'focus' ); + const keyEvtData = { + keyCode: keyCodes.tab, + shiftKey: true, + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + }; + + // Mock the back button is focused. + view.focusTracker.isFocused = true; + view.focusTracker.focusedElement = view.backButtonView.element; + view.keystrokes.press( keyEvtData ); + + sinon.assert.calledOnce( keyEvtData.preventDefault ); + sinon.assert.calledOnce( keyEvtData.stopPropagation ); + sinon.assert.calledOnce( spy ); + } ); + } ); + } ); + + describe( 'destroy()', () => { + it( 'should destroy the FocusTracker instance', () => { + const destroySpy = sinon.spy( view.focusTracker, 'destroy' ); + + view.destroy(); + + sinon.assert.calledOnce( destroySpy ); + } ); + + it( 'should destroy the KeystrokeHandler instance', () => { + const destroySpy = sinon.spy( view.keystrokes, 'destroy' ); + + view.destroy(); + + sinon.assert.calledOnce( destroySpy ); + } ); + } ); + + describe( 'focus()', () => { + it( 'focuses the back button when links list is empty', () => { + const backButtonSpy = sinon.spy( view.backButtonView, 'focus' ); + + view.focus(); + + sinon.assert.calledOnce( backButtonSpy ); + } ); + + it( 'focuses the back button when links list is not empty', () => { + const backButtonSpy = sinon.spy( view.backButtonView, 'focus' ); + + view.listChildren.addMany( linksButtonsArrayMock ); + + const listItemSpy = sinon.spy( view.listChildren.first, 'focus' ); + + view.focus(); + + sinon.assert.notCalled( backButtonSpy ); + sinon.assert.calledOnce( listItemSpy ); + } ); + } ); + + function createButton( label ) { + const button = new ButtonView( mockLocale ); + + button.set( { + label, + withText: true + } ); + + return button; + } +} ); diff --git a/packages/ckeditor5-link/tests/utils.js b/packages/ckeditor5-link/tests/utils.js index 36a4f6c5e61..a683c914c66 100644 --- a/packages/ckeditor5-link/tests/utils.js +++ b/packages/ckeditor5-link/tests/utils.js @@ -5,6 +5,7 @@ /* global window */ +import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor.js'; import ViewDocument from '@ckeditor/ckeditor5-engine/src/view/document.js'; import ViewDowncastWriter from '@ckeditor/ckeditor5-engine/src/view/downcastwriter.js'; import AttributeElement from '@ckeditor/ckeditor5-engine/src/view/attributeelement.js'; @@ -12,6 +13,10 @@ import ContainerElement from '@ckeditor/ckeditor5-engine/src/view/containereleme import Text from '@ckeditor/ckeditor5-engine/src/view/text.js'; import Schema from '@ckeditor/ckeditor5-engine/src/model/schema.js'; import ModelElement from '@ckeditor/ckeditor5-engine/src/model/element.js'; +import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph.js'; +import BoldEditing from '@ckeditor/ckeditor5-basic-styles/src/bold/boldediting.js'; +import { setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model.js'; + import { createLinkElement, isLinkElement, @@ -20,7 +25,8 @@ import { isLinkableElement, isEmail, addLinkProtocolIfApplicable, - openLink + openLink, + extractTextFromLinkRange } from '../src/utils.js'; describe( 'utils', () => { @@ -370,4 +376,74 @@ describe( 'utils', () => { expect( stub.calledWith( url, '_blank', 'noopener' ) ).to.be.true; } ); } ); + + describe( 'extractTextFromLinkRange()', () => { + let editor; + + beforeEach( async () => { + function InlineWidget( editor ) { + editor.model.schema.register( 'inlineWidget', { inheritAllFrom: '$inlineObject' } ); + editor.conversion.elementToElement( { + view: { name: 'span', class: 'foo' }, + model: 'inlineWidget' + } ); + } + + editor = await ModelTestEditor.create( { + plugins: [ Paragraph, InlineWidget, BoldEditing ] + } ); + } ); + + afterEach( async () => { + await editor.destroy(); + } ); + + it( 'should extract text from range', () => { + setModelData( editor.model, 'foo[bar]baz' ); + + const text = extractTextFromLinkRange( editor.model.document.selection.getFirstRange() ); + + expect( text ).to.equal( 'bar' ); + } ); + + it( 'should extract text from range even when split into multiple text nodes with different style', () => { + setModelData( editor.model, + '' + + 'abc[fo' + + '<$text bold="true">ob' + + 'ar]def' + + '' + ); + + expect( editor.model.document.getRoot().getChild( 0 ).childCount ).to.equal( 3 ); + + const text = extractTextFromLinkRange( editor.model.document.selection.getFirstRange() ); + + expect( text ).to.equal( 'foobar' ); + } ); + + it( 'should return undefined if range includes an inline object', () => { + setModelData( editor.model, 'foo[bar]baz' ); + + const text = extractTextFromLinkRange( editor.model.document.selection.getFirstRange() ); + + expect( text ).to.be.undefined; + } ); + + it( 'should return undefined if range is on an inline object', () => { + setModelData( editor.model, 'fooba[]rbaz' ); + + const text = extractTextFromLinkRange( editor.model.document.selection.getFirstRange() ); + + expect( text ).to.be.undefined; + } ); + + it( 'should return undefined if range is spanning multiple blocks', () => { + setModelData( editor.model, 'f[ooba]z' ); + + const text = extractTextFromLinkRange( editor.model.document.selection.getFirstRange() ); + + expect( text ).to.be.undefined; + } ); + } ); } ); diff --git a/packages/ckeditor5-link/theme/linkactions.css b/packages/ckeditor5-link/theme/linkactions.css deleted file mode 100644 index 25a7913dd8c..00000000000 --- a/packages/ckeditor5-link/theme/linkactions.css +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. - * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options - */ - -@import "@ckeditor/ckeditor5-ui/theme/mixins/_rwd.css"; - -.ck.ck-link-actions { - display: flex; - flex-direction: row; - flex-wrap: nowrap; - - & .ck-link-actions__preview { - display: inline-block; - - & .ck-button__label { - overflow: hidden; - } - } - - @mixin ck-media-phone { - flex-wrap: wrap; - - & .ck-link-actions__preview { - flex-basis: 100%; - } - - & .ck-button:not(.ck-link-actions__preview) { - flex-basis: 50%; - } - } -} diff --git a/packages/ckeditor5-link/theme/linkform.css b/packages/ckeditor5-link/theme/linkform.css index 5aff0ddb8c3..bdcf1e20ef0 100644 --- a/packages/ckeditor5-link/theme/linkform.css +++ b/packages/ckeditor5-link/theme/linkform.css @@ -5,42 +5,20 @@ @import "@ckeditor/ckeditor5-ui/theme/mixins/_rwd.css"; +/* Generic class that wraps each link balloon view. */ .ck.ck-link-form { - display: flex; - align-items: flex-start; - - & .ck-label { - display: none; - } - - @mixin ck-media-phone { - flex-wrap: wrap; - - & .ck-labeled-field-view { - flex-basis: 100%; - } - - & .ck-button { - flex-basis: 50%; + & .ck-link-form__providers-list { + display: flex; + flex-direction: column; + + & .ck-link__button { + & > .ck-button__label { + flex-grow: 1; + } } } -} - -/* - * Style link form differently when manual decorators are available. - * See: https://github.com/ckeditor/ckeditor5-link/issues/186. - */ -.ck.ck-link-form_layout-vertical { - display: block; - /* - * Whether the form is in the responsive mode or not, if there are decorator buttons - * keep the top margin of action buttons medium. - */ - & .ck-button { - &.ck-button-save, - &.ck-button-cancel { - margin-top: var(--ck-spacing-medium); - } + & .ck-link__items:empty { + display: none; } } diff --git a/packages/ckeditor5-link/theme/linkproperties.css b/packages/ckeditor5-link/theme/linkproperties.css new file mode 100644 index 00000000000..e770d7e4759 --- /dev/null +++ b/packages/ckeditor5-link/theme/linkproperties.css @@ -0,0 +1,4 @@ +/* + * Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options + */ diff --git a/packages/ckeditor5-link/theme/linkprovideritems.css b/packages/ckeditor5-link/theme/linkprovideritems.css new file mode 100644 index 00000000000..694976eb1cc --- /dev/null +++ b/packages/ckeditor5-link/theme/linkprovideritems.css @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options + */ + +.ck.ck-link-providers { + & > .ck-link-providers__list { + overflow-x: hidden; + overflow-y: auto; + overscroll-behavior: contain; + + & .ck-button { + & > .ck-icon { + flex-shrink: 0; + } + } + } +} diff --git a/packages/ckeditor5-link/theme/linktoolbar.css b/packages/ckeditor5-link/theme/linktoolbar.css new file mode 100644 index 00000000000..5aee177e359 --- /dev/null +++ b/packages/ckeditor5-link/theme/linktoolbar.css @@ -0,0 +1,12 @@ +/* + * Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options + */ + +.ck.ck-link-toolbar__preview { + display: inline-block; + + & .ck-button__label { + overflow: hidden; + } +} diff --git a/packages/ckeditor5-media-embed/lang/contexts.json b/packages/ckeditor5-media-embed/lang/contexts.json index 172cba246fa..a72742e674b 100644 --- a/packages/ckeditor5-media-embed/lang/contexts.json +++ b/packages/ckeditor5-media-embed/lang/contexts.json @@ -6,6 +6,7 @@ "The URL must not be empty.": "An error message that informs about an empty value in the URL input.", "This media URL is not supported.": "An error message that informs about unsupported media URL.", "Insert media": "Toolbar button tooltip for the Media Embed feature.", + "Media embed": "The label for the Media Embed balloon.", "Media": "Label describing type of the inserted content (e.g. 'insert media').", "Media toolbar": "The label used by assistive technologies describing an image toolbar attached to an image widget.", "Open media in new tab": "A tooltip displayed when the user hovers a non-previewable media URL in the editor content." diff --git a/packages/ckeditor5-media-embed/src/mediaembedui.ts b/packages/ckeditor5-media-embed/src/mediaembedui.ts index 03e1d13021a..b6839906fe2 100644 --- a/packages/ckeditor5-media-embed/src/mediaembedui.ts +++ b/packages/ckeditor5-media-embed/src/mediaembedui.ts @@ -99,6 +99,8 @@ export default class MediaEmbedUI extends Plugin { const command = editor.commands.get( 'mediaEmbed' )!; const t = editor.locale.t; + const isMediaSelected = command.value !== undefined; + if ( !this._formView ) { const registry = editor.plugins.get( MediaEmbedEditing ).registry; @@ -108,7 +110,7 @@ export default class MediaEmbedUI extends Plugin { dialog.show( { id: 'mediaEmbed', - title: t( 'Insert media' ), + title: t( 'Media embed' ), content: this._formView, isModal: true, onShow: () => { @@ -123,7 +125,7 @@ export default class MediaEmbedUI extends Plugin { onExecute: () => dialog.hide() }, { - label: t( 'Accept' ), + label: isMediaSelected ? t( 'Save' ) : t( 'Insert' ), class: 'ck-button-action', withText: true, onExecute: () => this._handleSubmitForm() diff --git a/packages/ckeditor5-media-embed/tests/mediaembedui.js b/packages/ckeditor5-media-embed/tests/mediaembedui.js index e4ce9c6630e..9287b705567 100644 --- a/packages/ckeditor5-media-embed/tests/mediaembedui.js +++ b/packages/ckeditor5-media-embed/tests/mediaembedui.js @@ -80,18 +80,19 @@ describe( 'MediaEmbedUI', () => { } ); describe( 'dialog', () => { - let form, dialog; + let form, dialog, mediaEmbedCommand; beforeEach( () => { button.fire( 'execute' ); dialog = editor.plugins.get( 'Dialog' ); form = editor.plugins.get( 'MediaEmbedUI' )._formView; + mediaEmbedCommand = editor.commands.get( 'mediaEmbed' ); } ); it( 'has two action buttons', () => { expect( dialog.view.actionsView.children ).to.have.length( 2 ); expect( dialog.view.actionsView.children.get( 0 ).label ).to.equal( 'Cancel' ); - expect( dialog.view.actionsView.children.get( 1 ).label ).to.equal( 'Accept' ); + expect( dialog.view.actionsView.children.get( 1 ).label ).to.equal( 'Insert' ); } ); it( 'should be open as modal', () => { @@ -102,6 +103,31 @@ describe( 'MediaEmbedUI', () => { expect( dialog.view.position ).to.be.equal( DialogViewPosition.SCREEN_CENTER ); } ); + it( 'should have a title', () => { + const sinonSpy = sinon.spy( dialog, 'show' ); + + dialog.hide(); + button.fire( 'execute' ); + + expect( sinonSpy ).to.have.been.calledWithMatch( { title: 'Media embed' } ); + } ); + + it( 'should show save button if media is selected', () => { + dialog.hide(); + mediaEmbedCommand.value = 'http://example.org'; + button.fire( 'execute' ); + + expect( dialog.view.actionsView.children.get( 1 ).label ).to.equal( 'Save' ); + } ); + + it( 'should show insert button if media is selected', () => { + dialog.hide(); + mediaEmbedCommand.value = undefined; + button.fire( 'execute' ); + + expect( dialog.view.actionsView.children.get( 1 ).label ).to.equal( 'Insert' ); + } ); + testSubmit( 'Accept button', () => { const acceptButton = dialog.view.actionsView.children.get( 1 ); diff --git a/packages/ckeditor5-table/src/tablecellproperties/ui/tablecellpropertiesview.ts b/packages/ckeditor5-table/src/tablecellproperties/ui/tablecellpropertiesview.ts index 1d313b97c65..b92cbdec833 100644 --- a/packages/ckeditor5-table/src/tablecellproperties/ui/tablecellpropertiesview.ts +++ b/packages/ckeditor5-table/src/tablecellproperties/ui/tablecellpropertiesview.ts @@ -13,6 +13,7 @@ import { createLabeledDropdown, createLabeledInputText, FocusCycler, + FormRowView, FormHeaderView, LabeledFieldView, LabelView, @@ -38,11 +39,12 @@ import { getBorderStyleLabels, getLabeledColorInputCreator } from '../../utils/ui/table-properties.js'; -import FormRowView from '../../ui/formrowview.js'; import type ColorInputView from '../../ui/colorinputview.js'; import type { TableCellPropertiesOptions } from '../../tableconfig.js'; -import '../../../theme/form.css'; +// eslint-disable-next-line ckeditor5-rules/ckeditor-imports +import '@ckeditor/ckeditor5-ui/theme/components/form/form.css'; +import '../../../theme/formrow.css'; import '../../../theme/tableform.css'; import '../../../theme/tablecellproperties.css'; diff --git a/packages/ckeditor5-table/src/tableproperties/ui/tablepropertiesview.ts b/packages/ckeditor5-table/src/tableproperties/ui/tablepropertiesview.ts index 45fa9d613b5..84385357678 100644 --- a/packages/ckeditor5-table/src/tableproperties/ui/tablepropertiesview.ts +++ b/packages/ckeditor5-table/src/tableproperties/ui/tablepropertiesview.ts @@ -13,6 +13,7 @@ import { createLabeledDropdown, createLabeledInputText, FocusCycler, + FormRowView, FormHeaderView, LabeledFieldView, LabelView, @@ -35,9 +36,10 @@ import { getBorderStyleLabels, getLabeledColorInputCreator } from '../../utils/ui/table-properties.js'; -import FormRowView from '../../ui/formrowview.js'; -import '../../../theme/form.css'; +// eslint-disable-next-line ckeditor5-rules/ckeditor-imports +import '@ckeditor/ckeditor5-ui/theme/components/form/form.css'; +import '../../../theme/formrow.css'; import '../../../theme/tableform.css'; import '../../../theme/tableproperties.css'; import type ColorInputView from '../../ui/colorinputview.js'; diff --git a/packages/ckeditor5-table/theme/form.css b/packages/ckeditor5-table/theme/form.css deleted file mode 100644 index 79b167baf9e..00000000000 --- a/packages/ckeditor5-table/theme/form.css +++ /dev/null @@ -1,11 +0,0 @@ -/* - * Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. - * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options - */ - -/* - * Note: This file should contain the wireframe styles only. But since there are no such styles, - * it acts as a message to the builder telling that it should look for the corresponding styles - * **in the theme** when compiling the editor. - */ - diff --git a/packages/ckeditor5-table/theme/formrow.css b/packages/ckeditor5-table/theme/formrow.css index d6fb75a3243..3ee753b79b2 100644 --- a/packages/ckeditor5-table/theme/formrow.css +++ b/packages/ckeditor5-table/theme/formrow.css @@ -4,16 +4,6 @@ */ .ck.ck-form__row { - display: flex; - flex-direction: row; - flex-wrap: nowrap; - justify-content: space-between; - - /* Ignore labels that work as fieldset legends */ - & > *:not(.ck-label) { - flex-grow: 1; - } - &.ck-table-form__action-row { & .ck-button-save, & .ck-button-cancel { diff --git a/packages/ckeditor5-table/theme/tableform.css b/packages/ckeditor5-table/theme/tableform.css index 6283d983b97..2e6809ee42c 100644 --- a/packages/ckeditor5-table/theme/tableform.css +++ b/packages/ckeditor5-table/theme/tableform.css @@ -31,6 +31,12 @@ flex-grow: 0; } } + + /* Ignore labels that work as fieldset legends */ + /* Fallback for table dimension operator */ + & > *:not(.ck-label, .ck-table-form__dimension-operator) { + flex-grow: 1; + } } & .ck.ck-labeled-field-view { diff --git a/packages/ckeditor5-theme-lark/theme/ckeditor5-bookmark/bookmark.css b/packages/ckeditor5-theme-lark/theme/ckeditor5-bookmark/bookmark.css index 43634ff1065..f3151486de4 100644 --- a/packages/ckeditor5-theme-lark/theme/ckeditor5-bookmark/bookmark.css +++ b/packages/ckeditor5-theme-lark/theme/ckeditor5-bookmark/bookmark.css @@ -3,6 +3,54 @@ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options */ +:root { + --ck-bookmark-icon-hover-fill-color: var(--ck-color-widget-hover-border); + --ck-bookmark-icon-selected-fill-color: var(--ck-color-focus-border); + --ck-bookmark-icon-animation-duration: var(--ck-widget-handler-animation-duration); + --ck-bookmark-icon-animation-curve: var(--ck-widget-handler-animation-curve); +} + +.ck-bookmark { + &.ck-widget { + display: inline-block; + outline: none; + + & .ck-bookmark__icon .ck-icon__fill { + transition: fill var(--ck-bookmark-icon-animation-duration) var(--ck-bookmark-icon-animation-curve); + } + + &:hover { + & .ck-bookmark__icon .ck-icon__fill { + fill: var(--ck-bookmark-icon-hover-fill-color); + } + } + + &.ck-widget_selected { + .ck-bookmark__icon .ck-icon__fill { + fill: var(--ck-bookmark-icon-selected-fill-color); + } + } + + &.ck-widget_selected, + &.ck-widget_selected:hover { + outline: none; + } + + & .ck-bookmark__icon { + position: relative; + display: block; + /* To make it align with text baseline. */ + top: -0.1em; + + & .ck-icon { + height: 1.2em; + width: auto; + vertical-align: middle; + } + } + } +} + /* * Classes used by the "fake visual selection" displayed in the content when an input * in the bookmark UI has focus (the browser does not render the native selection in this state). diff --git a/packages/ckeditor5-theme-lark/theme/ckeditor5-bookmark/bookmarkform.css b/packages/ckeditor5-theme-lark/theme/ckeditor5-bookmark/bookmarkform.css new file mode 100644 index 00000000000..37c2adf0ded --- /dev/null +++ b/packages/ckeditor5-theme-lark/theme/ckeditor5-bookmark/bookmarkform.css @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options + */ + +@import "@ckeditor/ckeditor5-ui/theme/mixins/_rwd.css"; + +:root { + --ck-bookmark-form-width: 340px; +} + +@mixin ck-media-phone { + :root { + --ck-bookmark-form-width: 300px; + } +} + +.ck.ck-bookmark-form { + width: var(--ck-bookmark-form-width); +} diff --git a/packages/ckeditor5-theme-lark/theme/ckeditor5-bookmark/bookmarktoolbar.css b/packages/ckeditor5-theme-lark/theme/ckeditor5-bookmark/bookmarktoolbar.css new file mode 100644 index 00000000000..895e9d90f09 --- /dev/null +++ b/packages/ckeditor5-theme-lark/theme/ckeditor5-bookmark/bookmarktoolbar.css @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options + */ + +@import "@ckeditor/ckeditor5-ui/theme/mixins/_unselectable.css"; + +.ck.ck-bookmark-toolbar__preview { + padding: 0 var(--ck-spacing-medium); + max-width: var(--ck-input-width); + min-width: 3em; + font-weight: normal; + text-overflow: ellipsis; + text-align: center; + overflow: hidden; + + @mixin ck-unselectable; + cursor: default; +} diff --git a/packages/ckeditor5-theme-lark/theme/ckeditor5-image/imagecustomresizeform.css b/packages/ckeditor5-theme-lark/theme/ckeditor5-image/imagecustomresizeform.css new file mode 100644 index 00000000000..325df77cf95 --- /dev/null +++ b/packages/ckeditor5-theme-lark/theme/ckeditor5-image/imagecustomresizeform.css @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options + */ + +@import "@ckeditor/ckeditor5-ui/theme/mixins/_rwd.css"; + +:root { + --ck-image-custom-resize-form-width: 340px; +} + +@mixin ck-media-phone { + :root { + --ck-image-custom-resize-form-width: 300px; + } +} + +.ck.ck-image-custom-resize-form { + &.ck-responsive-form { + width: var(--ck-image-custom-resize-form-width); + } +} diff --git a/packages/ckeditor5-theme-lark/theme/ckeditor5-image/textalternativeform.css b/packages/ckeditor5-theme-lark/theme/ckeditor5-image/textalternativeform.css new file mode 100644 index 00000000000..fe96277b357 --- /dev/null +++ b/packages/ckeditor5-theme-lark/theme/ckeditor5-image/textalternativeform.css @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options + */ + +@import "@ckeditor/ckeditor5-ui/theme/mixins/_rwd.css"; + +:root { + --ck-text-alternative-form-width: 340px; +} + +@mixin ck-media-phone { + :root { + --ck-text-alternative-form-width: 300px; + } +} + +.ck.ck-text-alternative-form { + &.ck-responsive-form { + width: var(--ck-text-alternative-form-width); + } +} diff --git a/packages/ckeditor5-theme-lark/theme/ckeditor5-link/linkactions.css b/packages/ckeditor5-theme-lark/theme/ckeditor5-link/linkactions.css deleted file mode 100644 index 19f928c639d..00000000000 --- a/packages/ckeditor5-theme-lark/theme/ckeditor5-link/linkactions.css +++ /dev/null @@ -1,84 +0,0 @@ -/* - * Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. - * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options - */ - -@import "@ckeditor/ckeditor5-ui/theme/mixins/_unselectable.css"; -@import "@ckeditor/ckeditor5-ui/theme/mixins/_dir.css"; -@import "../mixins/_focus.css"; -@import "../mixins/_shadow.css"; -@import "@ckeditor/ckeditor5-ui/theme/mixins/_rwd.css"; - -.ck.ck-link-actions { - & .ck-button.ck-link-actions__preview { - padding-left: 0; - padding-right: 0; - - & .ck-button__label { - padding: 0 var(--ck-spacing-medium); - color: var(--ck-color-link-default); - text-overflow: ellipsis; - cursor: pointer; - - /* Match the box model of the link editor form's input so the balloon - does not change width when moving between actions and the form. */ - max-width: var(--ck-input-width); - min-width: 3em; - text-align: center; - - &:hover { - text-decoration: underline; - } - } - - &, - &:hover, - &:focus, - &:active { - background: none; - } - - &:active { - box-shadow: none; - } - - &:focus { - & .ck-button__label { - text-decoration: underline; - } - } - } - - @mixin ck-dir ltr { - & .ck-button:not(:first-child) { - margin-left: var(--ck-spacing-standard); - } - } - - @mixin ck-dir rtl { - & .ck-button:not(:last-child) { - margin-left: var(--ck-spacing-standard); - } - } - - @mixin ck-media-phone { - & .ck-button.ck-link-actions__preview { - margin: var(--ck-spacing-standard) var(--ck-spacing-standard) 0; - - & .ck-button__label { - min-width: 0; - max-width: 100%; - } - } - - & .ck-button:not(.ck-link-actions__preview) { - @mixin ck-dir ltr { - margin-left: 0; - } - - @mixin ck-dir rtl { - margin-left: 0; - } - } - } -} diff --git a/packages/ckeditor5-theme-lark/theme/ckeditor5-link/linkform.css b/packages/ckeditor5-theme-lark/theme/ckeditor5-link/linkform.css index 03531389010..84e88c856c9 100644 --- a/packages/ckeditor5-theme-lark/theme/ckeditor5-link/linkform.css +++ b/packages/ckeditor5-theme-lark/theme/ckeditor5-link/linkform.css @@ -4,58 +4,50 @@ */ @import "@ckeditor/ckeditor5-ui/theme/mixins/_dir.css"; +@import "@ckeditor/ckeditor5-ui/theme/mixins/_rwd.css"; + +:root { + --ck-link-panel-width: 340px; + /* The height of the text inside the link providers list button. */ + --ck-link-provider-list-item-text-height: calc(var(--ck-line-height-base) * var(--ck-font-size-base)); + /* The height of the link providers list item contained paddings. */ + --ck-link-provider-list-item-height: calc(var(--ck-link-provider-list-item-text-height) + var(--ck-spacing-small) + var(--ck-spacing-small)); +} -/* - * Style link form differently when manual decorators are available. - * See: https://github.com/ckeditor/ckeditor5-link/issues/186. - */ -.ck.ck-link-form_layout-vertical { - padding: 0; - min-width: var(--ck-input-width); - - & .ck-labeled-field-view { - margin: var(--ck-spacing-large) var(--ck-spacing-large) var(--ck-spacing-small); - - & .ck-input-text { - min-width: 0; - width: 100%; - } +@mixin ck-media-phone { + :root { + --ck-link-panel-width: 300px; } +} - & > .ck-button { - padding: var(--ck-spacing-standard); - margin: 0; - width: 50%; - border-radius: 0; - - &:not(:focus) { - border-top: 1px solid var(--ck-color-base-border); - } - - @mixin ck-dir ltr { - margin-left: 0; - } - - @mixin ck-dir rtl { - margin-left: 0; +/* Generic class that wraps each link balloon view. */ +.ck.ck-link-form { + width: var(--ck-link-panel-width); + padding-bottom: 0; - &:last-of-type { - border-right: 1px solid var(--ck-color-base-border); + &.ck-responsive-form { + & .ck-labeled-field-view { + @mixin ck-media-phone { + margin: 0; } } } - /* Using additional `.ck` class for stronger CSS specificity than `.ck.ck-link-form > :not(:first-child)`. */ - & .ck.ck-list { - margin: 0 var(--ck-spacing-large); + & .ck-link-form__providers-list { + border-top: 1px solid var(--ck-color-base-border); - & .ck-button.ck-switchbutton { - padding: 0; - width: 100%; + /* The list should be scrollable when there are more than 4 items. */ + &:has(.ck-list__item:nth-child(n + 5)) { + overflow: auto; + /* Scroll should appear when there are more than 5 item on the list. + * var(--ck-spacing-large) - is a form padding. + * 1px - is a border width. */ + max-height: calc(var(--ck-link-provider-list-item-height) * 4 + var(--ck-spacing-large) + 1px); + } - &:hover { - background: none; - } + & .ck-link__button { + padding: var(--ck-spacing-small) var(--ck-spacing-large); + border-radius: 0; } } } diff --git a/packages/ckeditor5-theme-lark/theme/ckeditor5-link/linkproperties.css b/packages/ckeditor5-theme-lark/theme/ckeditor5-link/linkproperties.css new file mode 100644 index 00000000000..50d54f2ee55 --- /dev/null +++ b/packages/ckeditor5-theme-lark/theme/ckeditor5-link/linkproperties.css @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options + */ + +@import "@ckeditor/ckeditor5-ui/theme/mixins/_rwd.css"; + +:root { + --ck-link-properties-width: 340px; +} + +@mixin ck-media-phone { + :root { + --ck-link-properties-width: 300px; + } +} + +.ck.ck-link-properties { + width: var(--ck-link-properties-width); +} diff --git a/packages/ckeditor5-theme-lark/theme/ckeditor5-link/linkprovideritems.css b/packages/ckeditor5-theme-lark/theme/ckeditor5-link/linkprovideritems.css new file mode 100644 index 00000000000..e81fe25497d --- /dev/null +++ b/packages/ckeditor5-theme-lark/theme/ckeditor5-link/linkprovideritems.css @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options + */ + +@import "@ckeditor/ckeditor5-ui/theme/mixins/_rwd.css"; + +:root { + --ck-link-providers-width: 340px; + --ck-link-list-view-max-height: 240px; + --ck-link-list-view-icon-size: calc( var(--ck-icon-size) * 0.8); /* 0.8 = 16/20 cause default the icon size is 20px */ +} + +@mixin ck-media-phone { + :root { + --ck-link-providers-width: 300px; + } +} + +.ck.ck-link-providers { + width: var(--ck-link-providers-width); + + & > .ck-link-providers__list { + max-height: min( var(--ck-link-list-view-max-height), 40vh ); + + & .ck-button { + & > .ck-icon { + width: var(--ck-link-list-view-icon-size); + height: var(--ck-link-list-view-icon-size); + } + + & > .ck-button__label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } + } + + & .ck-link__empty-list-info { + padding: calc( 2 * var(--ck-spacing-large) ) var(--ck-spacing-medium); + text-align: center; + font-style: italic; + } +} diff --git a/packages/ckeditor5-theme-lark/theme/ckeditor5-link/linktoolbar.css b/packages/ckeditor5-theme-lark/theme/ckeditor5-link/linktoolbar.css new file mode 100644 index 00000000000..45d6feea156 --- /dev/null +++ b/packages/ckeditor5-theme-lark/theme/ckeditor5-link/linktoolbar.css @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options + */ + +@import "@ckeditor/ckeditor5-ui/theme/mixins/_dir.css"; + +:root { + --ck-link-bookmark-icon-size: calc( var(--ck-icon-size) * 0.7); /* 0.7 = 14/20 cause default the icon size is 20px */ +} + +a.ck.ck-button.ck-link-toolbar__preview { + padding: 0 var(--ck-spacing-medium); + color: var(--ck-color-link-default); + cursor: pointer; + justify-content: center; + + & .ck.ck-button__label { + text-overflow: ellipsis; + + /* Match the box model of the link editor form's input so the balloon + does not change width when moving between actions and the form. */ + max-width: var(--ck-input-width); + } + + &, + &:hover, + &:focus, + &:active { + background: none; + } + + &:active { + box-shadow: none; + } + + &:hover, + &:focus { + text-decoration: underline; + } + + &.ck-button_with-text .ck.ck-icon.ck-button__icon { + width: var(--ck-link-bookmark-icon-size); + height: var(--ck-link-bookmark-icon-size); + + @mixin ck-dir ltr { + margin-right: var(--ck-spacing-tiny); + margin-left: var(--ck-spacing-small); + } + + @mixin ck-dir rtl { + margin-left: var(--ck-spacing-tiny); + margin-right: var(--ck-spacing-small); + } + } + + &:has( .ck-icon ) { + padding-left: var(--ck-spacing-extra-tiny ); + } +} diff --git a/packages/ckeditor5-theme-lark/theme/ckeditor5-table/form.css b/packages/ckeditor5-theme-lark/theme/ckeditor5-table/form.css deleted file mode 100644 index 01ef5a43d0e..00000000000 --- a/packages/ckeditor5-theme-lark/theme/ckeditor5-table/form.css +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. - * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options - */ - -.ck.ck-form { - padding: 0 0 var(--ck-spacing-large); - - &:focus { - /* See: https://github.com/ckeditor/ckeditor5/issues/4773 */ - outline: none; - } - - & .ck.ck-input-text { - min-width: 100%; - width: 0; - } - - & .ck.ck-dropdown { - min-width: 100%; - - & .ck-dropdown__button { - &:not(:focus) { - border: 1px solid var(--ck-color-base-border); - } - - & .ck-button__label { - width: 100%; - } - } - } -} diff --git a/packages/ckeditor5-theme-lark/theme/ckeditor5-table/formrow.css b/packages/ckeditor5-theme-lark/theme/ckeditor5-table/formrow.css index 2f56892083f..085128b1d68 100644 --- a/packages/ckeditor5-theme-lark/theme/ckeditor5-table/formrow.css +++ b/packages/ckeditor5-theme-lark/theme/ckeditor5-table/formrow.css @@ -6,18 +6,10 @@ @import "@ckeditor/ckeditor5-ui/theme/mixins/_dir.css"; .ck.ck-form__row { - padding: var(--ck-spacing-standard) var(--ck-spacing-large) 0; - /* Ignore labels that work as fieldset legends */ & > *:not(.ck-label) { & + * { - @mixin ck-dir ltr { - margin-left: var(--ck-spacing-large); - } - - @mixin ck-dir rtl { - margin-right: var(--ck-spacing-large); - } + margin-inline-start: var(--ck-spacing-large); } } diff --git a/packages/ckeditor5-theme-lark/theme/ckeditor5-ui/components/form/form.css b/packages/ckeditor5-theme-lark/theme/ckeditor5-ui/components/form/form.css new file mode 100644 index 00000000000..e770d7e4759 --- /dev/null +++ b/packages/ckeditor5-theme-lark/theme/ckeditor5-ui/components/form/form.css @@ -0,0 +1,4 @@ +/* + * Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options + */ diff --git a/packages/ckeditor5-theme-lark/theme/ckeditor5-ui/components/formheader/formheader.css b/packages/ckeditor5-theme-lark/theme/ckeditor5-ui/components/formheader/formheader.css index 5120fe18efe..cbe2132f2f7 100644 --- a/packages/ckeditor5-theme-lark/theme/ckeditor5-ui/components/formheader/formheader.css +++ b/packages/ckeditor5-theme-lark/theme/ckeditor5-ui/components/formheader/formheader.css @@ -29,4 +29,18 @@ --ck-font-size-base: 15px; font-weight: bold; } + + /* Padding when back button is hidden */ + &:has(.ck-button-back.ck-hidden) { + padding-inline: var(--ck-spacing-large) var(--ck-spacing-large); + } + + /* Padding when back button is visible */ + &:has(.ck-button-back:not(.ck-hidden)) { + padding-inline: var(--ck-spacing-small) var(--ck-spacing-small); + } + + & > .ck-button-back { + margin-inline-end: var(--ck-spacing-small); + } } diff --git a/packages/ckeditor5-theme-lark/theme/ckeditor5-ui/components/labeledfield/labeledfieldview.css b/packages/ckeditor5-theme-lark/theme/ckeditor5-ui/components/labeledfield/labeledfieldview.css index 27bb96d61b8..3aba57a6992 100644 --- a/packages/ckeditor5-theme-lark/theme/ckeditor5-ui/components/labeledfield/labeledfieldview.css +++ b/packages/ckeditor5-theme-lark/theme/ckeditor5-ui/components/labeledfield/labeledfieldview.css @@ -124,4 +124,8 @@ &.ck-labeled-field-view_empty:not(.ck-labeled-field-view_focused):not(.ck-labeled-field-view_placeholder) > .ck.ck-labeled-field-view__input-wrapper > .ck-dropdown + .ck-label { max-width: calc(var(--ck-labeled-field-empty-unfocused-max-width) - var(--ck-dropdown-arrow-size) - var(--ck-spacing-standard)); } + + &.ck-labeled-field-view_full-width { + flex-grow: 1; + } } diff --git a/packages/ckeditor5-theme-lark/theme/index.css b/packages/ckeditor5-theme-lark/theme/index.css index 89af3ca882a..48506bcc4b8 100644 --- a/packages/ckeditor5-theme-lark/theme/index.css +++ b/packages/ckeditor5-theme-lark/theme/index.css @@ -43,6 +43,7 @@ @import "./ckeditor5-ui/components/panel/balloonrotator.css"; @import "./ckeditor5-ui/components/panel/fakepanel.css"; @import "./ckeditor5-ui/components/panel/stickypanel.css"; +@import "./ckeditor5-ui/components/form/form.css"; @import "./ckeditor5-ui/components/responsive-form/responsiveform.css"; @import "./ckeditor5-ui/components/search/search.css"; @import "./ckeditor5-ui/components/spinner/spinner.css"; @@ -56,6 +57,8 @@ /* Plugins */ @import "./ckeditor5-bookmark/bookmark.css"; +@import "./ckeditor5-bookmark/bookmarkform.css"; +@import "./ckeditor5-bookmark/bookmarktoolbar.css"; @import "./ckeditor5-clipboard/clipboard.css"; @import "./ckeditor5-code-block/codeblock.css"; @import "./ckeditor5-engine/placeholder.css"; @@ -68,10 +71,14 @@ @import "./ckeditor5-image/imageuploadicon.css"; @import "./ckeditor5-image/imageuploadloader.css"; @import "./ckeditor5-image/imageuploadprogress.css"; +@import "./ckeditor5-image/textalternativeform.css"; +@import "./ckeditor5-image/imagecustomresizeform.css"; @import "./ckeditor5-link/link.css"; -@import "./ckeditor5-link/linkactions.css"; +@import "./ckeditor5-link/linktoolbar.css"; @import "./ckeditor5-link/linkform.css"; @import "./ckeditor5-link/linkimage.css"; +@import "./ckeditor5-link/linkproperties.css"; +@import "./ckeditor5-link/linkprovideritems.css"; @import "./ckeditor5-list/listproperties.css"; @import "./ckeditor5-list/liststyles.css"; @import "./ckeditor5-media-embed/mediaembedediting.css"; @@ -85,7 +92,6 @@ @import "./ckeditor5-style/stylegroup.css"; @import "./ckeditor5-style/stylepanel.css"; @import "./ckeditor5-table/colorinput.css"; -@import "./ckeditor5-table/form.css"; @import "./ckeditor5-table/formrow.css"; @import "./ckeditor5-table/inserttable.css"; @import "./ckeditor5-table/tablecellproperties.css"; diff --git a/packages/ckeditor5-typing/src/twostepcaretmovement.ts b/packages/ckeditor5-typing/src/twostepcaretmovement.ts index e8ae0beffc2..5b7b70b44d3 100644 --- a/packages/ckeditor5-typing/src/twostepcaretmovement.ts +++ b/packages/ckeditor5-typing/src/twostepcaretmovement.ts @@ -294,10 +294,11 @@ export default class TwoStepCaretMovement extends Plugin { * Updates the document selection and the view according to the two–step caret movement state * when moving **forwards**. Executed upon `keypress` in the {@link module:engine/view/view~View}. * - * @param data Data of the key press. + * @internal + * @param eventData Data of the key press. * @returns `true` when the handler prevented caret movement. */ - private _handleForwardMovement( data: DomEventData ): boolean { + public _handleForwardMovement( eventData?: DomEventData ): boolean { const attributes = this.attributes; const model = this.editor.model; const selection = model.document.selection; @@ -335,7 +336,9 @@ export default class TwoStepCaretMovement extends Plugin { // foo{}<$text attribute>barbaz // if ( isBetweenDifferentAttributes( position, attributes ) ) { - preventCaretMovement( data ); + if ( eventData ) { + preventCaretMovement( eventData ); + } // CLEAR 2-SCM attributes if we are at the end of one 2-SCM and before // the next one with a different value of the same attribute. @@ -361,10 +364,11 @@ export default class TwoStepCaretMovement extends Plugin { * Updates the document selection and the view according to the two–step caret movement state * when moving **backwards**. Executed upon `keypress` in the {@link module:engine/view/view~View}. * - * @param data Data of the key press. + * @internal + * @param eventData Data of the key press. * @returns `true` when the handler prevented caret movement */ - private _handleBackwardMovement( data: DomEventData ): boolean { + public _handleBackwardMovement( eventData?: DomEventData ): boolean { const attributes = this.attributes; const model = this.editor.model; const selection = model.document.selection; @@ -379,7 +383,10 @@ export default class TwoStepCaretMovement extends Plugin { // foo<$text attribute>bar{}baz // if ( this._isGravityOverridden ) { - preventCaretMovement( data ); + if ( eventData ) { + preventCaretMovement( eventData ); + } + this._restoreGravity(); // CLEAR 2-SCM attributes if we are at the end of one 2-SCM and before @@ -402,7 +409,10 @@ export default class TwoStepCaretMovement extends Plugin { // if ( position.isAtStart ) { if ( hasAnyAttribute( selection, attributes ) ) { - preventCaretMovement( data ); + if ( eventData ) { + preventCaretMovement( eventData ); + } + setSelectionAttributesFromTheNodeBefore( model, attributes, position ); return true; @@ -419,7 +429,10 @@ export default class TwoStepCaretMovement extends Plugin { !hasAnyAttribute( selection, attributes ) && isBetweenDifferentAttributes( position, attributes, true ) ) { - preventCaretMovement( data ); + if ( eventData ) { + preventCaretMovement( eventData ); + } + setSelectionAttributesFromTheNodeBefore( model, attributes, position ); return true; @@ -445,7 +458,10 @@ export default class TwoStepCaretMovement extends Plugin { !hasAnyAttribute( selection, attributes ) && isBetweenDifferentAttributes( position, attributes ) ) { - preventCaretMovement( data ); + if ( eventData ) { + preventCaretMovement( eventData ); + } + setSelectionAttributesFromTheNodeBefore( model, attributes, position ); return true; diff --git a/packages/ckeditor5-ui/docs/assets/img/framework-deep-dive-focus-inline-scenario.gif b/packages/ckeditor5-ui/docs/assets/img/framework-deep-dive-focus-inline-scenario.gif index a937120c0fe..9e0031f4faf 100644 Binary files a/packages/ckeditor5-ui/docs/assets/img/framework-deep-dive-focus-inline-scenario.gif and b/packages/ckeditor5-ui/docs/assets/img/framework-deep-dive-focus-inline-scenario.gif differ diff --git a/packages/ckeditor5-ui/docs/assets/img/framework-deep-dive-focus-link-blinking-caret.gif b/packages/ckeditor5-ui/docs/assets/img/framework-deep-dive-focus-link-blinking-caret.gif index 20bf626ae36..c934bf48c9d 100644 Binary files a/packages/ckeditor5-ui/docs/assets/img/framework-deep-dive-focus-link-blinking-caret.gif and b/packages/ckeditor5-ui/docs/assets/img/framework-deep-dive-focus-link-blinking-caret.gif differ diff --git a/packages/ckeditor5-ui/docs/framework/deep-dive/focus-tracking.md b/packages/ckeditor5-ui/docs/framework/deep-dive/focus-tracking.md index ae4c5d4e257..7f56b3fe4f6 100644 --- a/packages/ckeditor5-ui/docs/framework/deep-dive/focus-tracking.md +++ b/packages/ckeditor5-ui/docs/framework/deep-dive/focus-tracking.md @@ -661,19 +661,19 @@ Take a look at the following scenario where both mouse and keyboard are used to And here are the steps of the scenario: 1. The editor is not focused (the focus is somewhere else on the web page). -2. The {@link module:ui/editableui/inline/inlineeditableuiview~InlineEditableUIView editable area} gets focused using the mouse. The main toolbar shows up and because the link was clicked, the {@link module:link/ui/linkactionsview~LinkActionsView link actions view} also pops up. -3. The Tab key is used to focus the {@link module:link/ui/linkactionsview~LinkActionsView#previewButtonView link preview} in the balloon (a child of {@link module:link/ui/linkactionsview~LinkActionsView}). -4. The Tab key is used to focus the {@link module:link/ui/linkactionsview~LinkActionsView#editButtonView "Edit link" button}. +2. The {@link module:ui/editableui/inline/inlineeditableuiview~InlineEditableUIView editable area} gets focused using the mouse. The main toolbar shows up and because the link was clicked, the {@link module:ui/toolbar/toolbarview~ToolbarView toolbar view} also pops up. +3. The Tab key is used to focus the {@link module:ui/toolbar/toolbarview~ToolbarView link preview} in the balloon. +4. The key is used to focus the {@link module:ui/toolbar/toolbarview~ToolbarView "Edit link" button}. 5. The Space key is used to execute the "Edit link" button. The focus moves to the {@link module:link/ui/linkformview~LinkFormView#urlInputView input} in the {@link module:link/ui/linkformview~LinkFormView}. -6. The Tab key is used to move from the link URL field to the {@link module:link/ui/linkformview~LinkFormView#saveButtonView "Save" button}. -7. The Tab key is used to move from the "Save" button to the {@link module:link/ui/linkformview~LinkFormView#cancelButtonView "Cancel" button}. -8. The Space key is used to execute the "Cancel" button and close the editing form. +6. The Tab key is used to move from the link URL field to the {@link module:link/ui/linkformview~LinkFormView#saveButtonView "Update" button}. +7. The Tab key is used to move from the "Update" button to the {@link features/bookmarks "Bookmarks" button}. +8. The Esc key is used to close the editing form. 9. The Esc key is used to close the link balloon and go back to the editable. There are 3 focus tracker instances at play in the scenario: 1. The {@link module:ui/editorui/editorui~EditorUI#focusTracker `EditorUI#focusTracker`} (the ["global" focus tracker](#a-note-about-the-global-focus-tracker)), -2. The {@link module:link/ui/linkactionsview~LinkActionsView#focusTracker `LinkActionsView#focusTracker`}, +2. The {@link module:ui/toolbar/toolbarview~ToolbarView#focusTracker `ToolbarView#focusTracker`}, 3. The {@link module:link/ui/linkformview~LinkFormView#focusTracker `LinkFormView#focusTracker`}. Let's see how they react to the user actions (states were recorded **after** each step): @@ -683,7 +683,7 @@ Let's see how they react to the user actions (states were recorded **after** eac Step {@link module:ui/editorui/editorui~EditorUI#focusTracker `EditorUI#focusTracker`} - {@link module:link/ui/linkactionsview~LinkActionsView#focusTracker `LinkActionsView#focusTracker`} + {@link module:ui/toolbar/toolbarview~ToolbarView#focusTracker `ToolbarView#focusTracker`} {@link module:link/ui/linkformview~LinkFormView#focusTracker `LinkFormView#focusTracker`} @@ -788,8 +788,8 @@ Let's see how they react to the user actions (states were recorded **after** eac * It does not know which element is focused on deeper layers (for instance the "Edit link" button), though. All it knows is where the focus went (for example, from the editable to the balloon panel). * It lacks precise information about the focus in the link UI because this is the responsibility of the focus tracker of the link UI layer. * All editor features **can always depend on the global focus tracker** when necessary. For instance, the main editor toolbar is displayed as long as the global focus tracker knows the focus is somewhere in the editor. -* You can see that the focus management is modular: `LinkActionsView` and `LinkFormView` only know about the focus as long as one of their children has it. -* Focus trackers belonging to `LinkActionsView` and `LinkFormView` know precisely which element has focus. This is their region of interest and, unlike the global focus tracker of the editor, they need that information to allow navigation using the keyboard. +* You can see that the focus management is modular: `ToolbarView` and `LinkFormView` only know about the focus as long as one of their children has it. +* Focus trackers belonging to `ToolbarView` and `LinkFormView` know precisely which element has focus. This is their region of interest and, unlike the global focus tracker of the editor, they need that information to allow navigation using the keyboard.