diff --git a/packages/ckeditor5-bookmark/lang/contexts.json b/packages/ckeditor5-bookmark/lang/contexts.json index 3cf525dea95..7c96c322fb5 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,6 @@ "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." } diff --git a/packages/ckeditor5-bookmark/package.json b/packages/ckeditor5-bookmark/package.json index 3d2aac00022..89a9ac3645d 100644 --- a/packages/ckeditor5-bookmark/package.json +++ b/packages/ckeditor5-bookmark/package.json @@ -15,7 +15,6 @@ "dependencies": { "ckeditor5": "43.2.0", "@ckeditor/ckeditor5-core": "43.2.0", - "@ckeditor/ckeditor5-engine": "43.2.0", "@ckeditor/ckeditor5-widget": "43.2.0", "@ckeditor/ckeditor5-utils": "43.2.0", "@ckeditor/ckeditor5-ui": "43.2.0" @@ -28,13 +27,14 @@ "@ckeditor/ckeditor5-dev-utils": "^42.0.0", "@ckeditor/ckeditor5-easy-image": "43.2.0", "@ckeditor/ckeditor5-editor-classic": "43.2.0", + "@ckeditor/ckeditor5-editor-multi-root": "43.2.0", + "@ckeditor/ckeditor5-engine": "43.2.0", "@ckeditor/ckeditor5-essentials": "43.2.0", "@ckeditor/ckeditor5-heading": "43.2.0", "@ckeditor/ckeditor5-html-support": "43.2.0", "@ckeditor/ckeditor5-image": "43.2.0", "@ckeditor/ckeditor5-link": "43.2.0", "@ckeditor/ckeditor5-list": "43.2.0", - "@ckeditor/ckeditor5-editor-multi-root": "43.2.0", "@ckeditor/ckeditor5-paragraph": "43.2.0", "@ckeditor/ckeditor5-paste-from-office": "43.2.0", "@ckeditor/ckeditor5-table": "43.2.0", diff --git a/packages/ckeditor5-bookmark/src/bookmarkconfig.ts b/packages/ckeditor5-bookmark/src/bookmarkconfig.ts index c0a2c3f22e0..577aea8c14c 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 2b0d5609b87..d282a61de1d 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 0f7ce8302d9..b181184414c 100644 --- a/packages/ckeditor5-bookmark/src/bookmarkui.ts +++ b/packages/ckeditor5-bookmark/src/bookmarkui.ts @@ -14,26 +14,29 @@ import { 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 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 +46,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 +60,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 +83,10 @@ export default class BookmarkUI extends Plugin { public init(): void { const editor = this.editor; - editor.editing.view.addObserver( ClickObserver ); - this._balloon = editor.plugins.get( ContextualBalloon ); // 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 +116,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 +159,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 +185,7 @@ export default class BookmarkUI extends Plugin { const formView = new ( CssTransitionDisablerMixin( BookmarkFormView ) )( locale, getFormValidators( editor ) ); formView.idInputView.fieldView.bind( 'value' ).to( updateBookmarkCommand, 'value' ); + formView.buttonView.bind( 'label' ).to( updateBookmarkCommand, 'value', value => value ? t( 'Update' ) : t( 'Insert' ) ); // Form elements should be read-only when corresponding commands are disabled. formView.idInputView.bind( 'isEnabled' ).toMany( @@ -220,7 +212,7 @@ export default class BookmarkUI extends Plugin { editor.execute( 'insertBookmark', { bookmarkId: value } ); } - this._closeFormView(); + this._hideFormView(); } } ); @@ -231,7 +223,7 @@ export default class BookmarkUI extends Plugin { // Close the panel on esc key press when the **form has focus**. formView.keystrokes.set( 'Esc', ( data, cancel ) => { - this._closeFormView(); + this._hideFormView(); cancel(); } ); @@ -242,11 +234,11 @@ export default class BookmarkUI extends Plugin { * 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 +248,71 @@ 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' ); + } ); + + 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 +326,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 +339,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 +355,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() } ); } @@ -407,79 +394,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!.buttonView.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 +437,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 +455,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 +462,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 +471,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 +484,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 +522,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. - */ - 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}. + * Returns `true` when {@link #formView} is in the {@link #_balloon} and it is currently visible. */ - 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 +559,13 @@ export default class BookmarkUI extends Plugin { }; } - return target && { target }; + if ( !target ) { + return; + } + + return { + target + }; } /** @@ -745,3 +667,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/tests/bookmarkediting.js b/packages/ckeditor5-bookmark/tests/bookmarkediting.js index df2f89e9539..cd2493c1eb3 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 6ba7b2a67fc..8863e7e223d 100644 --- a/packages/ckeditor5-bookmark/tests/bookmarkui.js +++ b/packages/ckeditor5-bookmark/tests/bookmarkui.js @@ -10,23 +10,23 @@ import { Essentials } from '@ckeditor/ckeditor5-essentials'; import { Paragraph } from '@ckeditor/ckeditor5-paragraph'; 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(); @@ -40,6 +40,8 @@ describe( 'BookmarkUI', () => { 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 +55,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 +79,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,7 +140,7 @@ 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' ); @@ -146,7 +148,241 @@ describe( 'BookmarkUI', () => { } ); } - 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 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 ); + } ); + } ); + } ); + + 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( '_showFormView()', () => { let balloonAddSpy; beforeEach( () => { @@ -157,7 +393,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 +401,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 +431,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; @@ -219,7 +455,7 @@ describe( 'BookmarkUI', () => { const insertBookmark = editor.commands.get( 'insertBookmark' ); const updateBookmark = editor.commands.get( 'updateBookmark' ); - bookmarkUIFeature._showUI(); + bookmarkUIFeature._showFormView(); const buttonView = bookmarkUIFeature.formView.buttonView; insertBookmark.isEnabled = true; @@ -239,52 +475,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 ); - - const addSpyCallArgs = balloonAddSpy.firstCall.args[ 0 ]; + editor.ui.update(); - 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 +508,7 @@ describe( 'BookmarkUI', () => { stackId: 'secondary' } ); - bookmarkUIFeature._showUI( true ); + bookmarkUIFeature._showFormView(); expect( balloon.visibleView ).to.equal( formView ); } ); @@ -302,18 +516,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 +536,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 +563,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 +590,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 +611,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 +642,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 +657,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 +673,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 +688,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 +699,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 +709,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 +747,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 +764,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 +779,7 @@ describe( 'BookmarkUI', () => { setModelData( editor.model, 'f[]oo' ); - bookmarkUIFeature._showUI(); + bookmarkUIFeature._showFormView(); const customView = new View(); @@ -595,10 +803,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 +822,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 +844,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 +869,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 +890,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 +915,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 +945,7 @@ describe( 'BookmarkUI', () => { '' + ']baz' ); - bookmarkUIFeature._showUI(); + bookmarkUIFeature._showFormView(); expect( editor.model.markers.has( 'bookmark-ui' ) ).to.be.true; @@ -775,7 +983,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 +1008,7 @@ describe( 'BookmarkUI', () => { '' + ']bar' ); - bookmarkUIFeature._showUI(); + bookmarkUIFeature._showFormView(); expect( editor.model.markers.has( 'bookmark-ui' ) ).to.be.true; @@ -828,7 +1036,7 @@ describe( 'BookmarkUI', () => { '' + ']bar' ); - bookmarkUIFeature._showUI(); + bookmarkUIFeature._showFormView(); expect( editor.model.markers.has( 'bookmark-ui' ) ).to.be.true; @@ -857,7 +1065,7 @@ describe( 'BookmarkUI', () => { ']' + 'bar' ); - bookmarkUIFeature._showUI(); + bookmarkUIFeature._showFormView(); expect( editor.model.markers.has( 'bookmark-ui' ) ).to.be.true; @@ -882,29 +1090,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; @@ -949,7 +1134,7 @@ describe( 'BookmarkUI', () => { bookmarkUIFeature._addFormView(); formView = bookmarkUIFeature.formView; - bookmarkUIFeature._showUI(); + bookmarkUIFeature._showFormView(); expect( formView.buttonView.label ).to.equal( 'Insert' ); } ); @@ -960,51 +1145,47 @@ describe( 'BookmarkUI', () => { bookmarkUIFeature._addFormView(); formView = bookmarkUIFeature.formView; - bookmarkUIFeature._showUI(); + bookmarkUIFeature._showFormView(); expect( formView.buttonView.label ).to.equal( 'Update' ); } ); it( 'should have "Update" 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' ); formView.idInputView.fieldView.value = 'new_id'; formView.fire( 'submit' ); - actionsView.fire( 'edit' ); + bookmarkUIFeature._showFormView(); expect( formView.buttonView.label ).to.equal( 'Update' ); } ); } ); } ); - 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 +1195,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 +1212,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,20 +1221,20 @@ 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(); } ); } ); @@ -1065,11 +1246,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 +1257,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 +1274,7 @@ describe( 'BookmarkUI', () => { bookmarkUIFeature._createViews(); formView = bookmarkUIFeature.formView; - bookmarkUIFeature._showUI(); + bookmarkUIFeature._showFormView(); bookmarkUIFeature._removeFormView(); formView.keystrokes.press( keyEvtData ); @@ -1151,7 +1283,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 +1296,7 @@ describe( 'BookmarkUI', () => { destroy: () => {} }; - bookmarkUIFeature._showUI(); + bookmarkUIFeature._showFormView(); // Some view precedes the bookmark UI in the balloon. balloon.add( { view: viewMock } ); @@ -1178,159 +1310,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 ec62f6dd45c..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-oss-license - */ - -/* 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/theme/bookmarkactions.css b/packages/ckeditor5-bookmark/theme/bookmarkactions.css deleted file mode 100644 index 8ea45669b20..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-oss-license - */ - -@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/bookmarktoolbar.css b/packages/ckeditor5-bookmark/theme/bookmarktoolbar.css new file mode 100644 index 00000000000..9e764cc3d74 --- /dev/null +++ b/packages/ckeditor5-bookmark/theme/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-oss-license + */ + +@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-core/lang/contexts.json b/packages/ckeditor5-core/lang/contexts.json index 204c3f280c7..9b4caa768e0 100644 --- a/packages/ckeditor5-core/lang/contexts.json +++ b/packages/ckeditor5-core/lang/contexts.json @@ -29,5 +29,7 @@ "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." } diff --git a/packages/ckeditor5-core/src/index.ts b/packages/ckeditor5-core/src/index.ts index 8f91cca482d..ed8570d254c 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..fa09c6753fe --- /dev/null +++ b/packages/ckeditor5-core/theme/icons/settings.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ckeditor5-link/lang/contexts.json b/packages/ckeditor5-link/lang/contexts.json index 989e5c22248..ddb4d07e000 100644 --- a/packages/ckeditor5-link/lang/contexts.json +++ b/packages/ckeditor5-link/lang/contexts.json @@ -11,5 +11,10 @@ "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.", + "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.", + "Advanced": "Title for a feature displaying advanced link settings.", + "Displayed text": "The label of the input field for the displayed text of the link.", + "Back": "The label of the button that returns to the previous view." } diff --git a/packages/ckeditor5-link/src/index.ts b/packages/ckeditor5-link/src/index.ts index b564110b717..1ffb5dfdb58 100644 --- a/packages/ckeditor5-link/src/index.ts +++ b/packages/ckeditor5-link/src/index.ts @@ -14,7 +14,6 @@ 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/linkconfig.ts b/packages/ckeditor5-link/src/linkconfig.ts index 56d00de5b02..8256e0b8127 100644 --- a/packages/ckeditor5-link/src/linkconfig.ts +++ b/packages/ckeditor5-link/src/linkconfig.ts @@ -180,6 +180,31 @@ 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'`, + * * `'unlink'`. + * + * The default configuration for link toolbar is: + * + * ```ts + * const linkConfig = { + * toolbar: [ 'linkPreview', '|', 'editLink', 'unlink' ] + * }; + * ``` + * + * 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 e7c5dcbf403..2cb910967df 100644 --- a/packages/ckeditor5-link/src/linkediting.ts +++ b/packages/ckeditor5-link/src/linkediting.ts @@ -39,8 +39,8 @@ import { getLocalizedDecorators, normalizeDecorators, addLinkProtocolIfApplicable, - createBookmarkCallbacks, openLink, + scrollToTarget, type NormalizedLinkDecoratorAutomaticDefinition, type NormalizedLinkDecoratorManualDefinition } from './utils.js'; @@ -89,7 +89,8 @@ export default class LinkEditing extends Plugin { editor.config.define( 'link', { allowCreatingEmptyLinks: false, - addTargetToExternalLinks: false + addTargetToExternalLinks: false, + toolbar: [ 'linkPreview', '|', 'editLink', 'unlink' ] } ); } @@ -261,12 +262,9 @@ 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 { + if ( !scrollToTarget( editor, url ) ) { openLink( url ); } } diff --git a/packages/ckeditor5-link/src/linkimageui.ts b/packages/ckeditor5-link/src/linkimageui.ts index 8a1e844e701..b037d431c82 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 8ef2c45f7ba..c600544ea19 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,40 @@ 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 { isWidget } from 'ckeditor5/src/widget.js'; +import LinkPreviewButtonView, { type LinkPreviewButtonNavigateEvent } from './ui/linkpreviewbuttonview.js'; import LinkFormView, { type LinkFormValidatorCallback } from './ui/linkformview.js'; -import LinkActionsView from './ui/linkactionsview.js'; +import LinkBookmarksView from './ui/linkbookmarksview.js'; +import LinkAdvancedView from './ui/linkadvancedview.js'; +import LinkButtonView from './ui/linkbuttonview.js'; import type LinkCommand from './linkcommand.js'; import type UnlinkCommand from './unlinkcommand.js'; + import { addLinkProtocolIfApplicable, + ensureSafeUrl, isLinkElement, - createBookmarkCallbacks, + isScrollableToTarget, + scrollToTarget, 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,15 +62,25 @@ 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 bookmarks list. + */ + public bookmarksView: LinkBookmarksView | null = null; + + /** + * The form view displaying advanced link settings. + */ + public advancedView: LinkAdvancedView | null = null; + /** * The contextual balloon plugin instance. */ @@ -96,7 +119,7 @@ export default class LinkUI extends Plugin { this._balloon = editor.plugins.get( ContextualBalloon ); // Create toolbar buttons. - this._createToolbarLinkButton(); + this._registerComponents(); this._enableBalloonActivators(); // Renders a fake visual selection marker on an expanded selection. @@ -151,12 +174,20 @@ export default class LinkUI extends Plugin { super.destroy(); // Destroy created UI components as they are not automatically destroyed (see ckeditor5#1341). + if ( this.advancedView ) { + this.advancedView.destroy(); + } + if ( this.formView ) { this.formView.destroy(); } - if ( this.actionsView ) { - this.actionsView.destroy(); + if ( this.toolbarView ) { + this.toolbarView.destroy(); + } + + if ( this.bookmarksView ) { + this.bookmarksView.destroy(); } } @@ -164,54 +195,57 @@ export default class LinkUI extends Plugin { * Creates views. */ private _createViews() { - this.actionsView = this._createActionsView(); + this.toolbarView = this._createToolbarView(); this.formView = this._createFormView(); + this.advancedView = this._createAdvancedView(); + + if ( this.editor.plugins.has( 'BookmarkEditing' ) ) { + this.bookmarksView = this._createBookmarksView(); + this.formView.listChildren.add( this._createBookmarksButton() ); + } // 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 linkCommand: LinkCommand = editor.commands.get( 'link' )!; - const unlinkCommand: UnlinkCommand = editor.commands.get( 'unlink' )!; + const toolbarView = new ToolbarView( editor.locale ); - actionsView.bind( 'href' ).to( linkCommand, 'value' ); - actionsView.editButtonView.bind( 'isEnabled' ).to( linkCommand ); - actionsView.unlinkButtonView.bind( 'isEnabled' ).to( unlinkCommand ); - - // Execute unlink command after clicking on the "Edit" button. - this.listenTo( actionsView, 'edit', () => { - this._addFormView(); - } ); + toolbarView.class = 'ck-link-toolbar'; - // Execute unlink command after clicking on the "Unlink" button. - this.listenTo( actionsView, 'unlink', () => { - editor.execute( 'unlink' ); - this._hideUI(); - } ); + toolbarView.fillFromConfig( editor.config.get( 'link.toolbar' )!, 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,25 +253,37 @@ 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' ); + // TODO: Bind to the "Displayed text" input + // Form elements should be read-only when corresponding commands are disabled. formView.urlInputView.bind( 'isEnabled' ).to( linkCommand, 'isEnabled' ); // Disable the "save" button if the command is disabled. formView.saveButtonView.bind( 'isEnabled' ).to( linkCommand, 'isEnabled' ); + // Show the "Advanced" button only when there are manual decorators. + formView.settingsButtonView.bind( 'isVisible' ).to( linkCommand, 'manualDecorators', decorators => decorators.length > 0 ); + + // 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', () => { + // TODO: Does this need updating after adding the "Displayed text" input? if ( formView.isValid() ) { const { value } = formView.urlInputView.fieldView.element!; const parsedUrl = addLinkProtocolIfApplicable( value, defaultProtocol ); - editor.execute( 'link', parsedUrl, formView.getDecoratorSwitchesState() ); + + editor.execute( 'link', parsedUrl, this._getDecoratorSwitchesState() ); + this._closeFormView(); } } ); @@ -252,6 +298,15 @@ export default class LinkUI extends Plugin { this._closeFormView(); } ); + this.listenTo( formView.settingsButtonView, 'execute', () => { + this._balloon.add( { + view: this.advancedView!, + position: this._getBalloonPositionData() + } ); + + this.advancedView!.focus(); + } ); + // Close the panel on esc key press when the **form has focus**. formView.keystrokes.set( 'Esc', ( data, cancel ) => { this._closeFormView(); @@ -262,10 +317,128 @@ export default class LinkUI extends Plugin { } /** - * Creates a toolbar Link button. Clicking this button will show - * a {@link #_balloon} attached to the selection. + * Creates a sorted array of buttons with bookmark names. + */ + private _createBookmarksListView(): Array { + const editor = this.editor; + const bookmarkEditing = editor.plugins.get( 'BookmarkEditing' ); + const bookmarksNames = Array.from( bookmarkEditing.getAllBookmarkNames() ); + + bookmarksNames.sort( ( a, b ) => a.localeCompare( b ) ); + + return bookmarksNames.map( bookmarkName => { + const buttonView = new ButtonView(); + + buttonView.set( { + label: bookmarkName, + tooltip: false, + icon: icons.bookmarkMedium, + withText: true + } ); + + buttonView.on( 'execute', () => { + this.formView!.urlInputView.fieldView.value = '#' + bookmarkName; + + // Set focus to the editing view to prevent from losing it while current view is removed. + editor.editing.view.focus(); + + this._removeBookmarksView(); + + // Set the focus to the URL input field. + this.formView!.focus(); + } ); + + return buttonView; + } ); + } + + /** + * Creates a view for bookmarks. */ - private _createToolbarLinkButton(): void { + private _createBookmarksView(): LinkBookmarksView { + const editor = this.editor; + const view = new LinkBookmarksView( editor.locale ); + + // 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._removeBookmarksView(); + + // Set the focus to the URL input field. + this.formView!.focus(); + } ); + + return view; + } + + /** + * Creates the {@link module:link/ui/linkadvancedview~LinkAdvancedView} instance. + */ + private _createAdvancedView(): LinkAdvancedView { + const editor = this.editor; + const linkCommand: LinkCommand = this.editor.commands.get( 'link' )!; + const view = new LinkAdvancedView( this.editor.locale ); + + // Hide the panel after clicking the back button. + this.listenTo( view, 'back', () => { + // 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(); + + this._removeAdvancedView(); + this.formView!.focus(); + } ); + + 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 ); + } ); + + 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 components in the ComponentFactory. + */ + private _registerComponents(): void { const editor = this.editor; editor.ui.componentFactory.add( 'link', () => { @@ -287,6 +460,103 @@ 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( 'href' ).to( linkCommand, 'value', href => { + return href && ensureSafeUrl( href, allowedProtocols ); + } ); + + button.bind( 'label' ).to( linkCommand, 'value', href => { + if ( !href ) { + return t( 'This link has no URL' ); + } + + return isScrollableToTarget( editor, href ) ? href.slice( 1 ) : href; + } ); + + button.bind( 'icon' ).to( linkCommand, 'value', href => { + return href && isScrollableToTarget( editor, href ) ? icons.bookmarkSmall : undefined; + } ); + + button.bind( 'isEnabled' ).to( linkCommand, 'value', href => !!href ); + + button.bind( 'tooltip' ).to( linkCommand, 'value', + url => isScrollableToTarget( editor, url ) ? t( 'Scroll to target' ) : t( 'Open link in new tab' ) + ); + + this.listenTo( button, 'navigate', ( evt, href, cancel ) => { + if ( scrollToTarget( editor, href ) ) { + 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; + } ); + } + + /** + * Creates a bookmarks button view. + */ + private _createBookmarksButton(): LinkButtonView { + const locale = this.editor.locale!; + const t = locale.t; + const bookmarksButton = new LinkButtonView( locale ); + + bookmarksButton.set( { + label: t( 'Bookmarks' ) + } ); + + this.listenTo( bookmarksButton, 'execute', () => { + this._addBookmarksView(); + } ); + + return bookmarksButton; } /** @@ -310,7 +580,7 @@ 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 ) ); return view; } @@ -352,8 +622,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 +651,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' } ); } @@ -439,6 +710,24 @@ export default class LinkUI extends Plugin { this.formView!.enableCssTransitions(); } + /** + * Adds the {@link #bookmarksView} to the {@link #_balloon}. + */ + private _addBookmarksView(): void { + // Clear the collection of bookmarks. + this.bookmarksView!.listChildren.clear(); + + // Add bookmarks to the collection. + this.bookmarksView!.listChildren.addMany( this._createBookmarksListView() ); + + this._balloon.add( { + view: this.bookmarksView!, + position: this._getBalloonPositionData() + } ); + + this.bookmarksView!.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). @@ -454,12 +743,31 @@ export default class LinkUI extends Plugin { linkCommand.restoreManualDecoratorStates(); if ( linkCommand.value !== undefined ) { + this._removeAdvancedView(); this._removeFormView(); } else { this._hideUI(); } } + /** + * Removes the {@link #advancedView} from the {@link #_balloon}. + */ + private _removeAdvancedView(): void { + if ( this._isAdvancedInPanel ) { + this._balloon.remove( this.advancedView! ); + } + } + + /** + * Removes the {@link #bookmarksView} from the {@link #_balloon}. + */ + private _removeBookmarksView(): void { + if ( this._areBookmarksInPanel ) { + this._balloon.remove( this.bookmarksView! ); + } + } + /** * Removes the {@link #formView} from the {@link #_balloon}. */ @@ -483,7 +791,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 +806,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 +817,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 +839,39 @@ 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(); + } + + // TODO: Remove dynamically registered views + + // If the bookmarks view is visible, remove it because it can be on top of the stack. + this._removeBookmarksView(); - // Remove form first because it's on top of the stack. + // If the advanced form view is visible, remove it because it can be on top of the stack. + this._removeAdvancedView(); + + // Then remove the form view because it's beneath the advanced 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 +899,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 +932,20 @@ export default class LinkUI extends Plugin { this.listenTo( this._balloon, 'change:visibleView', update ); } + /** + * Returns `true` when {@link #advancedView} is in the {@link #_balloon}. + */ + private get _isAdvancedInPanel(): boolean { + return !!this.advancedView && this._balloon.hasView( this.advancedView ); + } + + /** + * Returns `true` when {@link #bookmarksView} is in the {@link #_balloon}. + */ + private get _areBookmarksInPanel(): boolean { + return !!this.bookmarksView && this._balloon.hasView( this.bookmarksView ); + } + /** * Returns `true` when {@link #formView} is in the {@link #_balloon}. */ @@ -620,35 +954,58 @@ 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 _areActionsInPanel(): boolean { - return !!this.actionsView && this._balloon.hasView( this.actionsView ); + private get _isToolbarInPanel(): boolean { + return !!this.toolbarView && this._balloon.hasView( this.toolbarView ); } /** - * Returns `true` when {@link #actionsView} is in the {@link #_balloon} and it is + * Returns `true` when {@link #advancedView} is in the {@link #_balloon} and it is * currently visible. */ - private get _areActionsVisible(): boolean { - return !!this.actionsView && this._balloon.visibleView === this.actionsView; + private get _isAdvancedVisible(): boolean { + return !!this.advancedView && this._balloon.visibleView === this.advancedView; } /** - * Returns `true` when {@link #actionsView} or {@link #formView} is in the {@link #_balloon}. + * Returns `true` when {@link #formView} is in the {@link #_balloon} and it is + * currently visible. */ - private get _isUIInPanel(): boolean { - return this._isFormInPanel || this._areActionsInPanel; + 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} and it is + * Returns `true` when {@link #toolbarView} is in the {@link #_balloon} and it is * currently visible. */ - private get _isUIVisible(): boolean { - const visibleView = this._balloon.visibleView; + private get _isToolbarVisible(): boolean { + return !!this.toolbarView && this._balloon.visibleView === this.toolbarView; + } + + /** + * Returns `true` when {@link #bookmarksView} is in the {@link #_balloon} and it is + * currently visible. + */ + private get _areBookmarksVisible(): boolean { + return !!this.bookmarksView && this._balloon.visibleView === this.bookmarksView; + } + + /** + * Returns `true` when {@link #advancedView}, {@link #toolbarView}, {@link #bookmarksView} + * or {@link #formView} is in the {@link #_balloon}. + */ + private get _isUIInPanel(): boolean { + return this._isAdvancedInPanel || this._areBookmarksInPanel || this._isFormInPanel || this._isToolbarInPanel; + } - return !!this.formView && visibleView == this.formView || this._areActionsVisible; + /** + * Returns `true` when {@link #advancedView}, {@link #bookmarksView}, {@link #toolbarView} + * or {@link #formView} is in the {@link #_balloon} and it is currently visible. + */ + private get _isUIVisible(): boolean { + return this._isAdvancedVisible || this._areBookmarksVisible || this._isFormVisible || this._isToolbarVisible; } /** diff --git a/packages/ckeditor5-link/src/ui/linkactionsview.ts b/packages/ckeditor5-link/src/ui/linkactionsview.ts deleted file mode 100644 index 20b3f761818..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-oss-license - */ - -/** - * @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/linkadvancedview.ts b/packages/ckeditor5-link/src/ui/linkadvancedview.ts new file mode 100644 index 00000000000..2bd10d15b47 --- /dev/null +++ b/packages/ckeditor5-link/src/ui/linkadvancedview.ts @@ -0,0 +1,235 @@ +/** + * @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-oss-license + */ + +/** + * @module link/ui/linkadvancedview + */ + +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'; + +// See: #8833. +// eslint-disable-next-line ckeditor5-rules/ckeditor-imports +import '@ckeditor/ckeditor5-ui/theme/components/responsive-form/responsiveform.css'; +import '../../theme/linkform.css'; + +/** + * The link form view controller class. + * + * See {@link module:link/ui/linkadvancedview~LinkAdvancedView}. + */ +export default class LinkAdvancedView 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/linkadvancedview~LinkAdvancedView} 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__panel', 'ck-link__advanced' ], + + // 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( { + 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( 'Advanced' ) + } ); + + 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 ~LinkAdvancedView#backButtonView} is pressed. + * + * @eventName ~LinkAdvancedView#back + */ +export type BackEvent = { + name: 'back'; + args: []; +}; diff --git a/packages/ckeditor5-link/src/ui/linkbookmarksview.ts b/packages/ckeditor5-link/src/ui/linkbookmarksview.ts new file mode 100644 index 00000000000..65e0985bace --- /dev/null +++ b/packages/ckeditor5-link/src/ui/linkbookmarksview.ts @@ -0,0 +1,282 @@ +/** + * @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-oss-license + */ + +/** + * @module link/ui/linkbookmarksview + */ + +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'; + +/** + * The link bookmarks list view. + */ +export default class LinkBookmarksView extends View { + /** + * Tracks information about the list of bookmarks. + * + * @observable + */ + declare public hasItems: boolean; + + /** + * 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 bookmarks 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/linkbookmarksview~LinkBookmarksView} 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._createEmptyBookmarksListItemView(); + + this.children = this.createCollection( [ + this._createHeaderView(), + this.emptyListInformation + ] ); + + 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__panel', 'ck-link__bookmarks' ], + + // 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-list__bookmark-items' + ] + } + } ); + + 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( { + label: t( 'Cancel' ), + icon: icons.previousArrow, + tooltip: true + } ); + + backButton.delegate( 'execute' ).to( this, 'cancel' ); + + 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( 'Bookmarks' ) + } ); + + header.children.add( this.backButtonView, 0 ); + + return header; + } + + /** + * Creates an info view for an empty list. + */ + private _createEmptyBookmarksListItemView(): View { + const t = this.locale!.t; + const view = new View( this.locale ); + + view.setTemplate( { + tag: 'p', + attributes: { + class: [ 'ck ck-link__empty-list-info' ] + }, + children: [ + t( 'No bookmarks available.' ) + ] + } ); + + return view; + } +} + +/** + * Fired when the bookmarks view is canceled, for example with a click on {@link ~LinkBookmarksView#backButtonView}. + * + * @eventName ~LinkBookmarksView#cancel + */ +export type CancelEvent = { + name: 'cancel'; + args: []; +}; diff --git a/packages/ckeditor5-link/src/ui/linkbuttonview.ts b/packages/ckeditor5-link/src/ui/linkbuttonview.ts new file mode 100644 index 00000000000..bc365dcbb06 --- /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-oss-license + */ + +/** + * @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 cb328c109d2..08e0b44002c 100644 --- a/packages/ckeditor5-link/src/ui/linkformview.ts +++ b/packages/ckeditor5-link/src/ui/linkformview.ts @@ -9,9 +9,11 @@ import { ButtonView, + ListView, + ListItemView, FocusCycler, LabeledFieldView, - SwitchButtonView, + FormHeaderView, View, ViewCollection, createLabeledInputText, @@ -22,23 +24,17 @@ 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'; 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 +48,14 @@ 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 Settings button view displayed in the header. + */ + public settingsButtonView: ButtonView; /** * The Save button view. @@ -62,22 +63,30 @@ 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 form. + */ + public readonly formChildren: ViewCollection; + + /** + * A collection of child views in the footer. + */ + public readonly listChildren: ViewCollection; + /** * An array of form validators used by {@link #isValid}. */ @@ -99,21 +108,38 @@ 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.settingsButtonView = this._createSettingsButton(); + 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.formChildren = this._createFormChildren(); + this.listChildren = this.createCollection(); + this.children = this.createCollection( [ + this._createHeaderView(), + this._createFormView() + ] ); + + // Add 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.listChildren, 'add', () => { + this.stopListening( this.listChildren, 'add' ); + this.children.add( this._createListView() ); + } ); this._focusCycler = new FocusCycler( { focusables: this._focusables, @@ -128,17 +154,11 @@ 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-link__panel' ], // https://github.com/ckeditor/ckeditor5-link/issues/90 tabindex: '-1' @@ -148,22 +168,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 +180,11 @@ export default class LinkFormView extends View { const childViews = [ this.urlInputView, - ...this._manualDecoratorSwitches, this.saveButtonView, - this.cancelButtonView + ...this.listChildren, + this.backButtonView, + this.settingsButtonView, + this.displayedTextInputView ]; childViews.forEach( v => { @@ -242,128 +248,171 @@ 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( { + label: t( 'Cancel' ), + 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 settings button view that opens the advanced settings panel. */ - private _createButton( label: string, icon: string, className: string, eventName?: string ): ButtonView { - const button = new ButtonView( this.locale ); + private _createSettingsButton(): ButtonView { + const t = this.locale!.t; + const settingsButton = new ButtonView( this.locale ); - button.set( { - label, - icon, + settingsButton.set( { + label: t( 'Advanced' ), + icon: icons.settings, tooltip: true } ); - button.extendTemplate( { + return settingsButton; + } + + /** + * Creates a save button view that inserts the link. + */ + private _createSaveButton(): ButtonView { + const t = this.locale!.t; + const saveButton = new ButtonView( this.locale ); + + saveButton.set( { + label: t( 'Insert' ), + tooltip: true, + withText: true, + type: 'submit', + class: 'ck-button-insert 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( 'Link' ) + } ); + + header.children.add( this.backButtonView, 0 ); + header.children.add( this.settingsButtonView ); + + return header; + } + + /** + * Creates a form view for the link form. + */ + private _createFormView(): View { + const form = new View( this.locale ); + + form.setTemplate( { + tag: 'div', + + attributes: { + class: [ + 'ck', + 'ck-link__form', + 'ck-responsive-form' + ] + }, + + children: this.formChildren + } ); + + return form; + } + + /** + * Creates a view for the list at the bottom. + */ + private _createListView(): ListView { + const listView = new ListView( this.locale ); + + listView.extendTemplate( { attributes: { - class: className + class: [ + 'ck-link__list', + 'ck-link__list-border-top' + ] } } ); - if ( eventName ) { - button.delegate( 'execute' ).to( this, eventName ); - } + listView.items.bindTo( this.listChildren ).using( def => { + const listItemView = new ListItemView( this.locale ); - return button; + listItemView.children.add( def ); + + return listItemView; + } ); + + return listView; } /** - * 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 labeled input view for the "Displayed text" field. */ - private _createManualDecoratorSwitches( linkCommand: LinkCommand ): ViewCollection { - const switches = this.createCollection(); - - for ( const manualDecorator of linkCommand.manualDecorators ) { - const switchButton: SwitchButtonView & { name?: string } = new SwitchButtonView( this.locale ); + private _createDisplayedTextInput(): LabeledFieldView { + const t = this.locale!.t; + const labeledInput = new LabeledFieldView( this.locale, createLabeledInputText ); - switchButton.set( { - name: manualDecorator.id, - label: manualDecorator.label, - withText: true - } ); + labeledInput.label = t( 'Displayed text' ); - switchButton.bind( 'isOn' ).toMany( [ manualDecorator, linkCommand ], 'value', ( decoratorValue, commandValue ) => { - return commandValue === undefined && decoratorValue === undefined ? !!manualDecorator.defaultValue : !!decoratorValue; - } ); + return labeledInput; + } - switchButton.on( 'execute', () => { - manualDecorator.set( 'value', !switchButton.isOn ); - } ); + /** + * Creates a labeled input view for the URL field. + * + * @returns Labeled field view instance. + */ + private _createUrlInput(): LabeledFieldView { + const t = this.locale!.t; + const labeledInput = new LabeledFieldView( this.locale, createLabeledInputText ); - switches.add( switchButton ); - } + labeledInput.fieldView.inputMode = 'url'; + labeledInput.label = t( 'Link URL' ); - return switches; + return labeledInput; } /** * Populates the {@link #children} collection of the form. * - * 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. */ - private _createFormChildren( manualDecorators: Collection ): ViewCollection { + private _createFormChildren(): ViewCollection { const children = this.createCollection(); + const linkInputAndSubmit = new View(); - 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 ); - } + linkInputAndSubmit.setTemplate( { + tag: 'div', + attributes: { + class: [ 'ck', 'ck-link-and-submit' ] + }, + children: [ + this.urlInputView, + this.saveButtonView + ] + } ); - children.add( this.saveButtonView ); - children.add( this.cancelButtonView ); + children.add( this.displayedTextInputView ); + children.add( linkInputAndSubmit ); return children; } @@ -405,7 +454,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..5eabb7bb55c --- /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-oss-license + */ + +/** + * @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/utils.ts b/packages/ckeditor5-link/src/utils.ts index c7060ef8e2b..b37c2bc1aa3 100644 --- a/packages/ckeditor5-link/src/utils.ts +++ b/packages/ckeditor5-link/src/utils.ts @@ -28,8 +28,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 +198,43 @@ export function openLink( link: string ): void { } /** - * Creates the bookmark callbacks for handling link opening experience. + * Returns `true` when link can be handled internally in the editor without using native browser link handlers. */ -export function createBookmarkCallbacks( editor: Editor ): LinkActionsViewOptions { - const bookmarkEditing: BookmarkEditing | null = editor.plugins.has( 'BookmarkEditing' ) ? - editor.plugins.get( 'BookmarkEditing' ) : - null; +export function isScrollableToTarget( editor: Editor, link: string | undefined ): boolean { + if ( !editor.plugins.has( 'BookmarkEditing' ) ) { + return false; + } - /** - * 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 ) ); + if ( !link || !link.startsWith( '#' ) ) { + return false; } - /** - * 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 - } ); + const bookmarkEditing: BookmarkEditing = editor.plugins.get( 'BookmarkEditing' ); + + return !!bookmarkEditing.getElementForBookmarkId( link.slice( 1 ) ); +} + +/** + * Scrolls the view to the desired bookmark. + */ +export function scrollToTarget( editor: Editor, link: string ): boolean { + if ( !isScrollableToTarget( editor, link ) ) { + return false; } - return { - isScrollableToTarget, - scrollToTarget - }; + const bookmarkEditing: BookmarkEditing = editor.plugins.get( 'BookmarkEditing' )!; + const modelBookmark = bookmarkEditing.getElementForBookmarkId( link.slice( 1 ) ); + + editor.model.change( writer => { + writer.setSelection( modelBookmark!, 'on' ); + } ); + + editor.editing.view.scrollToTheSelection( { + alignToTop: true, + forceScroll: true + } ); + + return true; } export type NormalizedLinkDecoratorAutomaticDefinition = LinkDecoratorAutomaticDefinition & { id: string }; diff --git a/packages/ckeditor5-link/tests/linkimageui.js b/packages/ckeditor5-link/tests/linkimageui.js index 812bc1af634..416b2ca1d4d 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 92e3eee7175..3b28353fc59 100644 --- a/packages/ckeditor5-link/tests/linkui.js +++ b/packages/ckeditor5-link/tests/linkui.js @@ -6,11 +6,13 @@ /* globals document, 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'; import isRange from '@ckeditor/ckeditor5-utils/src/dom/isrange.js'; import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard.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 env from '@ckeditor/ckeditor5-utils/src/env.js'; @@ -22,17 +24,21 @@ import ContextualBalloon from '@ckeditor/ckeditor5-ui/src/panel/balloon/contextu import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview.js'; import View from '@ckeditor/ckeditor5-ui/src/view.js'; import { toWidget } from '@ckeditor/ckeditor5-widget'; +import { icons } from '@ckeditor/ckeditor5-core'; 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 LinkButtonView from '../src/ui/linkbuttonview.js'; +import LinkBookmarksView from '../src/ui/linkbookmarksview.js'; +import LinkAdvancedView from '../src/ui/linkadvancedview.js'; +import LinkPreviewButtonView from '../src/ui/linkpreviewbuttonview.js'; +import { MenuBarMenuListItemButtonView, ToolbarView } from '@ckeditor/ckeditor5-ui'; import linkIcon from '../theme/icons/link.svg'; describe( 'LinkUI', () => { - let editor, linkUIFeature, linkButton, balloon, formView, actionsView, editorElement; + let editor, linkUIFeature, linkButton, balloon, formView, toolbarView, advancedView, editorElement; testUtils.createSinonSandbox(); @@ -42,7 +48,35 @@ describe( 'LinkUI', () => { return ClassicTestEditor .create( editorElement, { - plugins: [ Essentials, LinkEditing, LinkUI, Paragraph, BlockQuote ] + plugins: [ Essentials, LinkEditing, LinkUI, Paragraph, BlockQuote ], + 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; @@ -99,8 +133,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', () => { @@ -163,6 +197,141 @@ describe( 'LinkUI', () => { expect( linkButton.isEnabled ).to.equal( !initState ); } ); } + + 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 ); + } ); + } ); + + 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 ); + } ); + } ); } ); describe( '_showUI()', () => { @@ -173,12 +342,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 +392,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 +406,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 +414,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 +451,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 +469,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 +479,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 +497,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 +506,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 +523,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 +535,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 +554,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 +564,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 +576,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 +592,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 +612,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 +626,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 +645,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 +734,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 +752,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 +1077,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 +1133,63 @@ describe( 'LinkUI', () => { } ); } ); + describe( '_createAdvancedView()', () => { + beforeEach( () => { + editor.editing.view.document.isFocused = true; + } ); + + it( 'should create #advancedView', () => { + setModelData( editor.model, 'f[o]o' ); + + linkUIFeature._showUI(); + + expect( linkUIFeature.advancedView ).to.be.instanceOf( LinkAdvancedView ); + } ); + + it( 'should add #advancedView to the balloon and attach the balloon', () => { + setModelData( editor.model, 'f[o]o' ); + + linkUIFeature._showUI(); + linkUIFeature.formView.settingsButtonView.fire( 'execute' ); + advancedView = linkUIFeature.advancedView; + + expect( balloon.visibleView ).to.equal( advancedView ); + } ); + } ); + + describe( '_createBookmarksView()', () => { + beforeEach( () => { + editor.editing.view.document.isFocused = true; + } ); + + describe( 'when Bookmark plugin is not loaded', () => { + it( 'should not create #advancedView', () => { + setModelData( editor.model, 'f[o]o' ); + + linkUIFeature._showUI(); + + expect( linkUIFeature.bookmarksView ).to.be.equal( null ); + } ); + } ); + } ); + describe( '_hideUI()', () => { beforeEach( () => { linkUIFeature._showUI(); formView = linkUIFeature.formView; - actionsView = linkUIFeature.actionsView; + toolbarView = linkUIFeature.toolbarView; + advancedView = linkUIFeature.advancedView; } ); 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', () => { @@ -1037,17 +1248,34 @@ describe( 'LinkUI', () => { linkUIFeature._hideUI(); } ).to.not.throw(); } ); + + it( 'should remove the advanced view UI from the balloon', () => { + const spy = testUtils.sinon.spy( editor.editing.view, 'focus' ); + formView.settingsButtonView.fire( 'execute' ); + + expect( balloon.hasView( formView ) ).to.be.true; + expect( balloon.hasView( toolbarView ) ).to.be.true; + expect( balloon.hasView( advancedView ) ).to.be.true; + + linkUIFeature._hideUI(); + + expect( balloon.hasView( formView ) ).to.be.false; + expect( balloon.hasView( toolbarView ) ).to.be.false; + expect( balloon.hasView( advancedView ) ).to.be.false; + + sinon.assert.calledTwice( spy ); + } ); } ); describe( 'keyboard support', () => { 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 +1366,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 +1379,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 +1392,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 +1405,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 +1417,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 +1427,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 +1521,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 +1550,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 +1694,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 +1742,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 +1781,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 +1798,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 ); } ); } ); @@ -1581,11 +1860,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(); @@ -1768,7 +2047,7 @@ describe( 'LinkUI', () => { formView.fire( 'submit' ); expect( getModelData( editor.model ) ).to.equal( - '[<$text linkHref="mailto:email@example.com">email@example.com]' + '[<$text linkDecorator2="true" linkHref="mailto:email@example.com">email@example.com]' ); } ); @@ -1811,7 +2090,11 @@ describe( 'LinkUI', () => { 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', { + linkDecorator1: false, + linkDecorator2: true, + linkDecorator3: false + } ) ).to.be.true; } ); it( 'should should clear the fake visual selection on formView#submit event', () => { @@ -1824,17 +2107,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 +2125,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 +2136,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 +2150,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,7 +2181,7 @@ describe( 'LinkUI', () => { } ); describe( 'support manual decorators', () => { - let editorElement, editor, model, formView, linkUIFeature; + let editorElement, editor, model, formView, advancedView, linkUIFeature; beforeEach( () => { editorElement = document.createElement( 'div' ); @@ -1908,12 +2191,29 @@ describe( 'LinkUI', () => { plugins: [ LinkEditing, LinkUI, Paragraph ], link: { decorators: { - isFoo: { + 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' + } } } } @@ -1933,12 +2233,14 @@ describe( 'LinkUI', () => { const balloon = editor.plugins.get( ContextualBalloon ); formView = linkUIFeature.formView; + advancedView = linkUIFeature.advancedView; // 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(); + advancedView.render(); } ); } ); @@ -1950,14 +2252,24 @@ 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' ); + setModelData( model, 'f[<$text linkHref="url" linkDecorator1="true">ooba]r' ); expect( formView.urlInputView.fieldView.element.value ).to.equal( 'url' ); - expect( formView.getDecoratorSwitchesState() ).to.deep.equal( { linkIsFoo: true } ); + expect( linkUIFeature._getDecoratorSwitchesState() ).to.deep.equal( { + linkDecorator1: true, + linkDecorator2: false, + linkDecorator3: false + } ); + // Switch the first decorator on. + advancedView.listChildren.get( 1 ).fire( 'execute' ); formView.fire( 'submit' ); - expect( executeSpy.calledOnce ).to.be.true; - expect( executeSpy.calledWithExactly( 'link', 'url', { linkIsFoo: true } ) ).to.be.true; + 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', () => { @@ -1965,20 +2277,511 @@ describe( 'LinkUI', () => { const manualDecorators = editor.commands.get( 'link' ).manualDecorators; const firstDecoratorModel = manualDecorators.first; - const firstDecoratorSwitch = formView._manualDecoratorSwitches.first; + const firstDecoratorSwitch = advancedView.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, 'Close form view without submit resets value to initial state' ).to.be.undefined; + expect( firstDecoratorSwitch.isOn, 'Close form view without submit resets value to initial state' ).to.be.false; + } ); + + it( 'switch buttons reflects state of manual decorators', () => { + expect( linkUIFeature.advancedView.listChildren.length ).to.equal( 3 ); + + expect( linkUIFeature.advancedView.listChildren.get( 0 ) ).to.deep.include( { + label: 'Foo', + isOn: false + } ); + expect( linkUIFeature.advancedView.listChildren.get( 1 ) ).to.deep.include( { + label: 'Download', + isOn: true + } ); + expect( linkUIFeature.advancedView.listChildren.get( 2 ) ).to.deep.include( { + label: 'Multi', + isOn: false + } ); + } ); + + it( 'reacts on switch button changes', () => { + const linkCommand = editor.commands.get( 'link' ); + const modelItem = linkCommand.manualDecorators.first; + const viewItem = linkUIFeature.advancedView.listChildren.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; + } ); + + it( 'reacts on switch button changes for the decorator with defaultValue', () => { + const linkCommand = editor.commands.get( 'link' ); + const modelItem = linkCommand.manualDecorators.get( 1 ); + const viewItem = linkUIFeature.advancedView.listChildren.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; + } ); + + describe( '_getDecoratorSwitchesState()', () => { + it( 'should provide object with decorators states', () => { + expect( linkUIFeature._getDecoratorSwitchesState() ).to.deep.equal( { + linkDecorator1: false, + linkDecorator2: true, + linkDecorator3: false + } ); + + linkUIFeature.advancedView.listChildren.map( item => { + item.element.dispatchEvent( new Event( 'click' ) ); + } ); + + linkUIFeature.advancedView.listChildren.get( 2 ).element.dispatchEvent( new Event( 'click' ) ); + + expect( linkUIFeature._getDecoratorSwitchesState() ).to.deep.equal( { + linkDecorator1: true, + linkDecorator2: false, + 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( 'advanced view', () => { + it( 'is not visible if there are no decorators', () => { + setModelData( editor.model, 'f[o]o' ); + + editor.commands.get( 'link' ).manualDecorators.clear(); + + linkUIFeature._showUI(); + + expect( linkUIFeature.formView.settingsButtonView.isVisible ).to.be.false; + } ); + + it( 'can be opened by clicking the settings button', () => { + const spy = sinon.spy(); + + setModelData( editor.model, 'f[o]o' ); + + linkUIFeature._showUI(); + linkUIFeature.listenTo( linkUIFeature.formView.settingsButtonView, 'execute', spy ); + linkUIFeature.formView.settingsButtonView.fire( 'execute' ); + + sinon.assert.calledOnce( spy ); + expect( balloon.visibleView ).to.equal( linkUIFeature.advancedView ); + } ); + + it( 'can be closed by clicking the back button', () => { + const spy = sinon.spy(); + + setModelData( editor.model, 'f[o]o' ); + + linkUIFeature._showUI(); + linkUIFeature.listenTo( linkUIFeature.advancedView, 'back', spy ); + linkUIFeature.formView.settingsButtonView.fire( 'execute' ); + linkUIFeature.advancedView.backButtonView.fire( 'execute' ); + + sinon.assert.calledOnce( spy ); + expect( balloon.visibleView ).to.equal( linkUIFeature.formView ); + } ); + + it( 'can be closed by clicking the "esc" button', () => { + setModelData( editor.model, 'f[o]o' ); + + linkUIFeature._showUI(); + linkUIFeature.formView.settingsButtonView.fire( 'execute' ); + + linkUIFeature.advancedView.keystrokes.press( { + keyCode: keyCodes.esc, + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + } ); + + expect( balloon.visibleView ).to.equal( linkUIFeature.formView ); + } ); + } ); +} ); + +describe( 'LinkUI with Bookmark', () => { + let editor, linkUIFeature, balloon, editorElement, bookmarksView; + + testUtils.createSinonSandbox(); + + beforeEach( () => { + editorElement = document.createElement( 'div' ); + document.body.appendChild( editorElement ); + + return ClassicTestEditor + .create( editorElement, { + plugins: [ Essentials, LinkEditing, LinkUI, Paragraph, BlockQuote, Bookmark ] + } ) + .then( newEditor => { + editor = newEditor; + + linkUIFeature = editor.plugins.get( LinkUI ); + 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( {} ); + } ); + } ); + + afterEach( () => { + editorElement.remove(); + + return editor.destroy(); + } ); + + it( 'should create #formView with bookmarks button', () => { + setModelData( editor.model, 'f[o]o' ); + + linkUIFeature._showUI(); + + const formView = linkUIFeature.formView; + const button = formView + .template.children[ 0 ] + .last // ul + .template.children[ 0 ] + .get( 0 ) // li + .template.children[ 0 ] + .get( 0 ); // button + + expect( linkUIFeature.formView ).to.be.instanceOf( LinkFormView ); + expect( button ).to.be.instanceOf( LinkButtonView ); + + expect( linkUIFeature._areBookmarksVisible ).to.be.false; + + button.fire( 'execute' ); + + expect( linkUIFeature._areBookmarksVisible ).to.be.true; + } ); + + describe( '_createBookmarksView()', () => { + it( 'should create #bookmarksView', () => { + setModelData( editor.model, 'f[o]o' ); + + linkUIFeature._showUI(); + + expect( linkUIFeature.bookmarksView ).to.be.instanceOf( LinkBookmarksView ); + } ); + } ); + + describe( '_createBookmarksListView()', () => { + describe( 'with bookmarks data', () => { + let bookmarksView; + + beforeEach( () => { + setModelData( editor.model, + 'f[o]o' + + '' + + '' + + '' + + '' + ); + + linkUIFeature._showUI(); + linkUIFeature._addBookmarksView(); + + bookmarksView = linkUIFeature.bookmarksView; + } ); + + it( 'should create a sorted list of bookmarks buttons', () => { + expect( bookmarksView.listChildren.length ).to.equal( 3 ); + + expect( bookmarksView.listChildren.get( 0 ) ).is.instanceOf( ButtonView ); + expect( bookmarksView.listChildren.get( 1 ) ).is.instanceOf( ButtonView ); + expect( bookmarksView.listChildren.get( 2 ) ).is.instanceOf( ButtonView ); + + expect( bookmarksView.listChildren.get( 0 ).icon ).to.equal( icons.bookmarkMedium ); + expect( bookmarksView.listChildren.get( 1 ).icon ).to.equal( icons.bookmarkMedium ); + expect( bookmarksView.listChildren.get( 2 ).icon ).to.equal( icons.bookmarkMedium ); + + expect( bookmarksView.listChildren.get( 0 ).label ).to.equal( 'aaa' ); + expect( bookmarksView.listChildren.get( 1 ).label ).to.equal( 'ccc' ); + expect( bookmarksView.listChildren.get( 2 ).label ).to.equal( 'zzz' ); + } ); + + it( 'should execute action after click the bookmark button', () => { + // First button from the list with bookmark name 'aaa'. + const bookmarkButton = bookmarksView.listChildren.get( 0 ); + const focusSpy = testUtils.sinon.spy( linkUIFeature.formView, 'focus' ); + + bookmarkButton.fire( 'execute' ); + + expect( linkUIFeature.formView.urlInputView.fieldView.value ).is.equal( '#aaa' ); + expect( linkUIFeature._balloon.visibleView ).to.be.equal( linkUIFeature.formView ); + expect( focusSpy.calledOnce ).to.be.true; + } ); + } ); + } ); + + describe( 'bookmarks view', () => { + it( 'can be opened by clicking the bookmarks button', () => { + setModelData( editor.model, 'f[o]o' ); + + linkUIFeature._showUI(); + + const formView = linkUIFeature.formView; + const button = formView + .template.children[ 0 ] + .last // ul + .template.children[ 0 ] + .get( 0 ) // li + .template.children[ 0 ] + .get( 0 ); // button + + button.fire( 'execute' ); + bookmarksView = linkUIFeature.bookmarksView; + + expect( balloon.visibleView ).to.equal( bookmarksView ); + } ); + + it( 'can be closed by clicking the back button', () => { + const spy = sinon.spy(); + + setModelData( editor.model, 'f[o]o' ); + + linkUIFeature._showUI(); + linkUIFeature.listenTo( linkUIFeature.bookmarksView, 'cancel', spy ); + linkUIFeature._addBookmarksView(); + + linkUIFeature.bookmarksView.backButtonView.fire( 'execute' ); + + sinon.assert.calledOnce( spy ); + expect( balloon.visibleView ).to.equal( linkUIFeature.formView ); + } ); + + it( 'can be closed by clicking the "esc" button', () => { + setModelData( editor.model, 'f[o]o' ); + + linkUIFeature._showUI(); + linkUIFeature._addBookmarksView(); + + linkUIFeature.bookmarksView.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' ); + + setModelData( editor.model, 'f[o]o' ); + + linkUIFeature._showUI(); + linkUIFeature._addBookmarksView(); + + document.body.dispatchEvent( new Event( 'mousedown', { bubbles: true } ) ); + + sinon.assert.calledWithExactly( spy ); + expect( linkUIFeature._balloon.visibleView ).to.be.null; + } ); + } ); + + 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' ); + const bookmarkEditing = editor.plugins.get( 'BookmarkEditing' ); + + sinon.stub( bookmarkEditing, 'getElementForBookmarkId' ).callsFake( id => id === 'abc' ); + + 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' ); + + linkCommand.value = '#abc'; + expect( button.tooltip ).to.equal( 'Scroll to target' ); + } ); + + it( 'should use icon for bookmarks', () => { + const linkCommand = editor.commands.get( 'link' ); + const bookmarkEditing = editor.plugins.get( 'BookmarkEditing' ); + + sinon.stub( bookmarkEditing, 'getElementForBookmarkId' ).callsFake( id => id === 'abc' ); + + linkCommand.value = 'foo'; + expect( button.icon ).to.equal( undefined ); + + linkCommand.value = '#foo'; + expect( button.icon ).to.equal( undefined ); + + linkCommand.value = '#abc'; + expect( button.icon ).to.equal( icons.bookmarkSmall ); + } ); + + it( 'should bind label', () => { + const linkCommand = editor.commands.get( 'link' ); + const bookmarkEditing = editor.plugins.get( 'BookmarkEditing' ); + + sinon.stub( bookmarkEditing, 'getElementForBookmarkId' ).callsFake( id => id === 'abc' ); + + linkCommand.value = 'foo'; + expect( button.label ).to.equal( 'foo' ); + + linkCommand.value = '#foo'; + expect( button.label ).to.equal( '#foo' ); + + linkCommand.value = '#abc'; + expect( button.label ).to.equal( 'abc' ); + } ); + + it( 'should trigger scroll if target is in the same document', () => { + const cancelSpy = sinon.spy(); + const scrollStub = sinon.stub( editor.editing.view, 'scrollToTheSelection' ); + + setModelData( editor.model, '[foo]' ); + + button.fire( 'navigate', '#abc', cancelSpy ); + + sinon.assert.calledOnce( cancelSpy ); + sinon.assert.calledOnce( scrollStub ); + + expect( getModelData( editor.model ) ).to.equal( 'foo[]' ); + } ); + + it( 'should not trigger scroll if target is not a known bookmark', () => { + const cancelSpy = sinon.spy(); + const scrollStub = sinon.stub( editor.editing.view, 'scrollToTheSelection' ); + + setModelData( editor.model, '[foo]' ); + + button.fire( 'navigate', '#foo', cancelSpy ); + + sinon.assert.notCalled( cancelSpy ); + sinon.assert.notCalled( scrollStub ); + + expect( getModelData( editor.model ) ).to.equal( '[foo]' ); + } ); + } ); } ); diff --git a/packages/ckeditor5-link/tests/manual/linkdecorator.js b/packages/ckeditor5-link/tests/manual/linkdecorator.js index 0c8078f8bb3..53dc004d6c5 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', diff --git a/packages/ckeditor5-link/tests/ui/linkactionsview.js b/packages/ckeditor5-link/tests/ui/linkactionsview.js deleted file mode 100644 index c497e723162..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-oss-license - */ - -/* 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/linkadvancedview.js b/packages/ckeditor5-link/tests/ui/linkadvancedview.js new file mode 100644 index 00000000000..9586282ffb6 --- /dev/null +++ b/packages/ckeditor5-link/tests/ui/linkadvancedview.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-oss-license + */ + +/* 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 LinkAdvancedView from '../../src/ui/linkadvancedview.js'; +import ManualDecorator from '../../src/utils/manualdecorator.js'; + +const mockLocale = { t: val => val }; + +describe( 'LinkAdvancedView', () => { + 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 LinkAdvancedView( 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__panel' ) ).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 LinkAdvancedView( 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/linkbookmarksview.js b/packages/ckeditor5-link/tests/ui/linkbookmarksview.js new file mode 100644 index 00000000000..7032a9312e6 --- /dev/null +++ b/packages/ckeditor5-link/tests/ui/linkbookmarksview.js @@ -0,0 +1,288 @@ +/** + * @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-oss-license + */ + +/* 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 LinkBookmarksView from '../../src/ui/linkbookmarksview.js'; + +const mockLocale = { t: val => val }; + +describe( 'LinkBookmarksView', () => { + let view, bookmarksButtonsArrayMock; + + testUtils.createSinonSandbox(); + + beforeEach( () => { + view = new LinkBookmarksView( mockLocale ); + view.render(); + document.body.appendChild( view.element ); + + bookmarksButtonsArrayMock = [ + createButton( 'Mocked bookmark button 1' ), + createButton( 'Mocked bookmark button 2' ), + createButton( 'Mocked bookmark 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__panel' ) ).to.true; + expect( view.element.classList.contains( 'ck-link__bookmarks' ) ).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( bookmarksButtonsArrayMock ); + + 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 ); + } ); + } ); + + 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; + + expect( emptyListInformation.template.children[ 0 ].text[ 0 ] ).to.equal( 'No bookmarks available.' ); + } ); + } ); + + 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 ); + } ); + } ); + + 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 LinkBookmarksView( 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 LinkBookmarksView( 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 LinkBookmarksView( mockLocale ); + view.render(); + document.body.appendChild( view.element ); + + view.listChildren.addMany( bookmarksButtonsArrayMock ); + } ); + + 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 bookmarks list is empty', () => { + const backButtonSpy = sinon.spy( view.backButtonView, 'focus' ); + + view.focus(); + + sinon.assert.calledOnce( backButtonSpy ); + } ); + + it( 'focuses the back button when bookmarks list is not empty', () => { + const backButtonSpy = sinon.spy( view.backButtonView, 'focus' ); + + view.listChildren.addMany( bookmarksButtonsArrayMock ); + + 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/ui/linkformview.js b/packages/ckeditor5-link/tests/ui/linkformview.js index 36f379d6390..c80b7334bcc 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,15 @@ describe( 'LinkFormView', () => { describe( 'constructor()', () => { it( 'should create element from template', () => { expect( view.element.classList.contains( 'ck' ) ).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' ); + expect( view.element.classList.contains( 'ck-link__panel' ) ).to.true; } ); it( 'should create child views', () => { - expect( view.urlInputView ).to.be.instanceOf( View ); + expect( view.backButtonView ).to.be.instanceOf( View ); + expect( view.settingsButtonView ).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 +59,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 +74,34 @@ describe( 'LinkFormView', () => { } ); describe( 'template', () => { + /** + * form + * header + * backButtonView + * label + * settingsButtonView + * div + * displayedTextInputView + * div + * urlInputView + * saveButtonView + * bookmarksButton + */ + it( 'has url input view', () => { - expect( view.template.children[ 0 ].get( 0 ) ).to.equal( view.urlInputView ); + const formChildren = view.template.children[ 0 ].get( 1 ).template.children[ 0 ]; + + expect( formChildren.get( 0 ) ).to.equal( view.displayedTextInputView ); + expect( formChildren.get( 1 ).template.children[ 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 formChildren = view.template.children[ 0 ].get( 1 ).template.children[ 0 ]; + + expect( headerChildren.get( 0 ) ).to.equal( view.backButtonView ); + expect( headerChildren.get( 2 ) ).to.equal( view.settingsButtonView ); + expect( formChildren.last.template.children[ 1 ] ).to.equal( view.saveButtonView ); } ); } ); } ); @@ -105,30 +111,33 @@ describe( 'LinkFormView', () => { expect( view._focusables.map( f => f ) ).to.have.members( [ view.urlInputView, view.saveButtonView, - view.cancelButtonView + view.backButtonView, + view.settingsButtonView, + 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.settingsButtonView.element ); + sinon.assert.calledWithExactly( spy.getCall( 4 ), 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 +146,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 +157,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 +176,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 +188,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 +197,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 +209,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 +280,55 @@ 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(); - } ); + button = new LinkButtonView(); - afterEach( () => { - view.destroy(); - collection.clear(); - } ); - - it( 'switch buttons reflects state of manual decorators', () => { - expect( view._manualDecoratorSwitches.length ).to.equal( 3 ); - - 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 - } ); - expect( view._manualDecoratorSwitches.get( 2 ) ).to.deep.include( { - name: 'decorator3', - label: 'Multi', - isOn: false + button.set( { + label: 'Button' } ); - } ); - - 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.listChildren.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( '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( 'adds list view', () => { + const listView = view.template.children[ 0 ].get( 2 ); + const button = listView.template.children[ 0 ].get( 0 ).template.children[ 0 ].get( 0 ); - 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.listChildren.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.template.children[ 0 ].get( 2 ); + 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.settingsButtonView.element ); + sinon.assert.calledWithExactly( spy.getCall( 5 ), 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..34b75955cca --- /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-oss-license + */ + +/* 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/utils.js b/packages/ckeditor5-link/tests/utils.js index 8d02c75725b..40373d6d8da 100644 --- a/packages/ckeditor5-link/tests/utils.js +++ b/packages/ckeditor5-link/tests/utils.js @@ -5,6 +5,7 @@ /* global window */ +import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor.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'; @@ -20,7 +21,9 @@ import { isLinkableElement, isEmail, addLinkProtocolIfApplicable, - openLink + openLink, + isScrollableToTarget, + scrollToTarget } from '../src/utils.js'; describe( 'utils', () => { @@ -370,4 +373,103 @@ describe( 'utils', () => { expect( stub.calledWith( url, '_blank', 'noopener' ) ).to.be.true; } ); } ); + + describe( 'isScrollableToTarget()', () => { + it( 'should return false if the BookmarkEditing feature is not loaded', async () => { + const editor = await VirtualTestEditor.create( {} ); + + expect( isScrollableToTarget( editor, 'http://ckeditor.com' ) ).to.be.false; + + await editor.destroy(); + } ); + + describe( 'with the BookmarkEditing feature loaded', () => { + let editor; + + beforeEach( async () => { + function BookmarkEditingMock() { + this.getElementForBookmarkId = id => id === 'foo'; + } + + BookmarkEditingMock.pluginName = 'BookmarkEditing'; + + editor = await VirtualTestEditor.create( { plugins: [ BookmarkEditingMock ] } ); + } ); + + afterEach( async () => { + await editor.destroy(); + } ); + + it( 'should return false if url is not provided', () => { + expect( isScrollableToTarget( editor, '' ) ).to.be.false; + } ); + + it( 'should return false if url does not start with the hash', () => { + expect( isScrollableToTarget( editor, 'http://ckeditor.com#foo' ) ).to.be.false; + } ); + + it( 'should return false if the BookmarkEditing#getElementForBookmarkId() returns false', () => { + expect( isScrollableToTarget( editor, '#bar' ) ).to.be.false; + } ); + + it( 'should return true if the BookmarkEditing#getElementForBookmarkId() returns true', () => { + expect( isScrollableToTarget( editor, '#foo' ) ).to.be.true; + } ); + } ); + } ); + + describe( 'scrollToTarget()', () => { + it( 'should not scroll and return false if the BookmarkEditing feature is not loaded', async () => { + const editor = await VirtualTestEditor.create( {} ); + const scrollToTheSelectionSpy = sinon.spy( editor.editing.view, 'scrollToTheSelection' ); + + expect( scrollToTarget( editor, 'http://ckeditor.com' ) ).to.be.false; + sinon.assert.notCalled( scrollToTheSelectionSpy ); + + scrollToTheSelectionSpy.restore(); + await editor.destroy(); + } ); + + describe( 'with the BookmarkEditing feature loaded', () => { + let editor, scrollToTheSelectionStub, setSelectionStub; + + beforeEach( async () => { + function BookmarkEditingMock() { + this.getElementForBookmarkId = id => id === 'foo'; + } + + BookmarkEditingMock.pluginName = 'BookmarkEditing'; + + editor = await VirtualTestEditor.create( { plugins: [ BookmarkEditingMock ] } ); + scrollToTheSelectionStub = sinon.stub( editor.editing.view, 'scrollToTheSelection' ); + setSelectionStub = sinon.stub( editor.model.document.selection, '_setTo' ); + } ); + + afterEach( async () => { + scrollToTheSelectionStub.restore(); + setSelectionStub.restore(); + await editor.destroy(); + } ); + + it( 'should not scroll and return false if url is not provided', () => { + expect( scrollToTarget( editor, '' ) ).to.be.false; + sinon.assert.notCalled( scrollToTheSelectionStub ); + } ); + + it( 'should not scroll and return false if url does not start with the hash', () => { + expect( scrollToTarget( editor, 'http://ckeditor.com#foo' ) ).to.be.false; + sinon.assert.notCalled( scrollToTheSelectionStub ); + } ); + + it( 'should not scroll and return false if the BookmarkEditing#getElementForBookmarkId() returns false', () => { + expect( scrollToTarget( editor, '#bar' ) ).to.be.false; + sinon.assert.notCalled( scrollToTheSelectionStub ); + } ); + + it( 'should scroll and return true if the BookmarkEditing#getElementForBookmarkId() returns true', () => { + expect( scrollToTarget( editor, '#foo' ) ).to.be.true; + sinon.assert.calledOnce( scrollToTheSelectionStub ); + } ); + } ); + } ); } ); diff --git a/packages/ckeditor5-link/theme/linkactions.css b/packages/ckeditor5-link/theme/linkactions.css deleted file mode 100644 index aab028642c7..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-oss-license - */ - -@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 a3bc742ee44..a7d2e03e0e3 100644 --- a/packages/ckeditor5-link/theme/linkform.css +++ b/packages/ckeditor5-link/theme/linkform.css @@ -5,42 +5,130 @@ @import "@ckeditor/ckeditor5-ui/theme/mixins/_rwd.css"; -.ck.ck-link-form { - display: flex; - align-items: flex-start; +:root { + --ck-link-panel-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 */ +} - & .ck-label { - display: none; +.ck.ck-link__panel { + width: var(--ck-link-panel-width); + + &:focus { + outline: none; + } + + /* Header */ + & .ck-form__header { + padding: var(--ck-spacing-small) var(--ck-spacing-medium); + + & > .ck:not(:first-child) { + margin-left: var(--ck-spacing-small); + } } - @mixin ck-media-phone { - flex-wrap: wrap; + /* Form */ + & .ck-responsive-form.ck-link__form { + display: flex; + flex-direction: column; + + & > * { + margin: 0; + + @mixin ck-media-phone { + margin: var(--ck-spacing-large) var(--ck-spacing-medium) var(--ck-spacing-medium) var(--ck-spacing-medium); + } + } + + & > .ck:not(:first-child) { + margin-top: var(--ck-spacing-large); + } & .ck-labeled-field-view { - flex-basis: 100%; + flex-grow: 1; + + & input { + width: 100%; + } } - & .ck-button { - flex-basis: 50%; + /* "Link" input field and the "Insert" button */ + & .ck-link-and-submit { + display: flex; + + & > .ck-labeled-field-view { + flex: 1; + } + + & .ck-button-insert { + padding: var(--ck-spacing-tiny) var(--ck-spacing-large); + margin-left: var(--ck-spacing-medium); + height: 30px; + } + + @mixin ck-media-phone { + flex-direction: column; + + & > .ck { + margin: 0; + } + + & .ck-button-insert { + margin-top: var(--ck-spacing-large); + justify-content: center; + } + } } } -} -/* - * 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 { + /* Link list */ + & .ck-link__list { + display: flex; + flex-direction: column; + + &.ck-link__list-border-top { margin-top: var(--ck-spacing-medium); + border-top: 1px solid var(--ck-color-base-border); + } + + & .ck-link__button { + padding: var(--ck-spacing-medium) var(--ck-spacing-large); + border-radius: 0; + + & > .ck-button__label { + flex-grow: 1; + } + } + } + + & .ck-link__empty-list-info { + padding: calc( 2 * var(--ck-spacing-large) ) var(--ck-spacing-medium); + text-align: center; + font-style: italic; + } + + & .ck-link__items:empty { + display: none; + } + + & > .ck-list__bookmark-items { + max-height: min( var(--ck-link-list-view-max-height), 40vh ); + overflow-x: hidden; + overflow-y: auto; + overscroll-behavior: contain; + + & .ck-button { + & > .ck-icon { + width: var(--ck-link-list-view-icon-size); + height: var(--ck-link-list-view-icon-size); + flex-shrink: 0; + } + + & > .ck-button__label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } } } } diff --git a/packages/ckeditor5-link/theme/linktoolbar.css b/packages/ckeditor5-link/theme/linktoolbar.css new file mode 100644 index 00000000000..ef78b9dec59 --- /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-oss-license + */ + +.ck.ck-link-toolbar__preview { + display: inline-block; + + & .ck-button__label { + overflow: hidden; + } +} 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 896639df2e0..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-oss-license - */ - -@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/linktoolbar.css b/packages/ckeditor5-theme-lark/theme/ckeditor5-link/linktoolbar.css new file mode 100644 index 00000000000..3e8f049aae7 --- /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-oss-license + */ + +@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/index.css b/packages/ckeditor5-theme-lark/theme/index.css index 7d7868297fa..c654b9330cb 100644 --- a/packages/ckeditor5-theme-lark/theme/index.css +++ b/packages/ckeditor5-theme-lark/theme/index.css @@ -69,7 +69,7 @@ @import "./ckeditor5-image/imageuploadloader.css"; @import "./ckeditor5-image/imageuploadprogress.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-list/listproperties.css"; diff --git a/packages/ckeditor5-ui/src/button/buttonview.ts b/packages/ckeditor5-ui/src/button/buttonview.ts index d46839f775a..bae758c70db 100644 --- a/packages/ckeditor5-ui/src/button/buttonview.ts +++ b/packages/ckeditor5-ui/src/button/buttonview.ts @@ -239,6 +239,7 @@ export default class ButtonView extends View implements Butto class: 'ck-button__icon' } } ); + this.iconView.bind( 'content' ).to( this, 'icon' ); this.keystrokeView = this._createKeystrokeView(); @@ -340,10 +341,17 @@ export default class ButtonView extends View implements Butto super.render(); if ( this.icon ) { - this.iconView.bind( 'content' ).to( this, 'icon' ); this.children.add( this.iconView ); } + this.on( 'change:icon', ( evt, prop, newIcon, oldIcon ) => { + if ( newIcon && !oldIcon ) { + this.children.add( this.iconView, 0 ); + } else if ( !newIcon && oldIcon ) { + this.children.remove( this.iconView ); + } + } ); + this.children.add( this.labelView ); if ( this.withKeystroke && this.keystroke ) { diff --git a/packages/ckeditor5-ui/src/editorui/editorui.ts b/packages/ckeditor5-ui/src/editorui/editorui.ts index 08c2d8ebffc..6ba90a22466 100644 --- a/packages/ckeditor5-ui/src/editorui/editorui.ts +++ b/packages/ckeditor5-ui/src/editorui/editorui.ts @@ -786,7 +786,7 @@ function getToolbarDefinitionWeight( toolbarDef: FocusableToolbarDefinition ): n // Prioritize contextual toolbars. They are displayed at the selection. if ( options.isContextual ) { - weight--; + weight -= 2; } return weight; diff --git a/packages/ckeditor5-ui/src/icon/iconview.ts b/packages/ckeditor5-ui/src/icon/iconview.ts index 789435296b6..58c7d6dc064 100644 --- a/packages/ckeditor5-ui/src/icon/iconview.ts +++ b/packages/ckeditor5-ui/src/icon/iconview.ts @@ -11,7 +11,7 @@ import View from '../view.js'; -import type { ObservableChangeEvent } from '@ckeditor/ckeditor5-utils'; +import { CKEditorError, type ObservableChangeEvent } from '@ckeditor/ckeditor5-utils'; import '../../theme/components/icon/icon.css'; @@ -155,7 +155,17 @@ export default class IconView extends View { private _updateXMLContent() { if ( this.content ) { const parsed = new DOMParser().parseFromString( this.content.trim(), 'image/svg+xml' ); - const svg = parsed.querySelector( 'svg' )!; + const svg = parsed.querySelector( 'svg' ); + + if ( !svg ) { + /** + * The provided icon content is not a valid SVG. + * + * @error ui-iconview-invalid-svg + */ + throw new CKEditorError( 'ui-iconview-invalid-svg', this ); + } + const viewBox = svg.getAttribute( 'viewBox' ); if ( viewBox ) { diff --git a/packages/ckeditor5-ui/tests/button/buttonview.js b/packages/ckeditor5-ui/tests/button/buttonview.js index 3e286674256..bbe86857486 100644 --- a/packages/ckeditor5-ui/tests/button/buttonview.js +++ b/packages/ckeditor5-ui/tests/button/buttonview.js @@ -488,6 +488,36 @@ describe( 'ButtonView', () => { expect( view.iconView.content ).to.equal( 'bar' ); } ); + it( 'is added to the #children when view#icon is defined after render', () => { + view = new ButtonView( locale ); + view.render(); + + view.icon = ''; + expect( view.element.childNodes ).to.have.length( 2 ); + expect( view.element.childNodes[ 0 ] ).to.equal( view.iconView.element ); + + expect( view.iconView ).to.instanceOf( IconView ); + expect( view.iconView.content ).to.equal( '' ); + expect( view.iconView.element.classList.contains( 'ck-button__icon' ) ).to.be.true; + + view.icon = 'bar'; + expect( view.iconView.content ).to.equal( 'bar' ); + } ); + + it( 'is removed from the #children when view#icon is removed', () => { + view = new ButtonView( locale ); + view.icon = ''; + view.render(); + + expect( view.element.childNodes ).to.have.length( 2 ); + expect( view.element.childNodes[ 0 ] ).to.equal( view.iconView.element ); + expect( view.element.childNodes[ 1 ] ).to.equal( view.labelView.element ); + + view.icon = undefined; + expect( view.element.childNodes ).to.have.length( 1 ); + expect( view.element.childNodes[ 0 ] ).to.equal( view.labelView.element ); + } ); + it( 'is destroyed with the view', () => { view = new ButtonView( locale ); view.icon = ''; diff --git a/packages/ckeditor5-ui/tests/dropdown/button/splitbuttonview.js b/packages/ckeditor5-ui/tests/dropdown/button/splitbuttonview.js index 82d9832cdc0..9c1be4e3ca9 100644 --- a/packages/ckeditor5-ui/tests/dropdown/button/splitbuttonview.js +++ b/packages/ckeditor5-ui/tests/dropdown/button/splitbuttonview.js @@ -203,9 +203,9 @@ describe( 'SplitButtonView', () => { it( 'binds actionView#icon to view', () => { expect( view.actionView.icon ).to.be.undefined; - view.icon = 'foo'; + view.icon = ''; - expect( view.actionView.icon ).to.equal( 'foo' ); + expect( view.actionView.icon ).to.equal( '' ); } ); it( 'binds actionView#isEnabled to view', () => { diff --git a/packages/ckeditor5-ui/tests/icon/iconview.js b/packages/ckeditor5-ui/tests/icon/iconview.js index 2f40e18374d..acb9c7454f0 100644 --- a/packages/ckeditor5-ui/tests/icon/iconview.js +++ b/packages/ckeditor5-ui/tests/icon/iconview.js @@ -5,6 +5,7 @@ import IconView from '../../src/icon/iconview.js'; import normalizeHtml from '@ckeditor/ckeditor5-utils/tests/_utils/normalizehtml.js'; +import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror.js'; describe( 'IconView', () => { let view; @@ -118,6 +119,12 @@ describe( 'IconView', () => { sinon.assert.calledTwice( removeChildSpy ); } ); + it( 'should throw an error on invalid SVG', () => { + expect( () => { + view.content = 'foo'; + } ).to.throw( CKEditorError, 'ui-iconview-invalid-svg' ); + } ); + describe( 'preservation of presentational attributes on the element', () => { it( 'should use the static list of attributes from the IconView class', () => { expect( IconView.presentationalAttributeNames ).to.have.length( 58 ); diff --git a/packages/ckeditor5-widget/src/widgettoolbarrepository.ts b/packages/ckeditor5-widget/src/widgettoolbarrepository.ts index 0679f6aa126..1f4b412378e 100644 --- a/packages/ckeditor5-widget/src/widgettoolbarrepository.ts +++ b/packages/ckeditor5-widget/src/widgettoolbarrepository.ts @@ -28,6 +28,7 @@ import { CKEditorError, logWarning, type ObservableChangeEvent, + type PositioningFunction, type RectSource } from '@ckeditor/ckeditor5-utils'; @@ -145,11 +146,18 @@ export default class WidgetToolbarRepository extends Plugin { */ public register( toolbarId: string, - { ariaLabel, items, getRelatedElement, balloonClassName = 'ck-toolbar-container' }: { + { + ariaLabel, + items, + getRelatedElement, + balloonClassName = 'ck-toolbar-container', + positions + }: { ariaLabel?: string; items: Array; getRelatedElement: ( selection: ViewDocumentSelection ) => ( ViewElement | null ); balloonClassName?: string; + positions?: ReadonlyArray; } ): void { // Trying to register a toolbar without any item. @@ -189,11 +197,12 @@ export default class WidgetToolbarRepository extends Plugin { throw new CKEditorError( 'widget-toolbar-duplicated', this, { toolbarId } ); } - const toolbarDefinition = { + const toolbarDefinition: WidgetRepositoryToolbarDefinition = { view: toolbarView, getRelatedElement, balloonClassName, itemsConfig: items, + positions, initialized: false }; @@ -269,9 +278,12 @@ export default class WidgetToolbarRepository extends Plugin { * It might happen here that the toolbar's view is under another view. Then do nothing as the other toolbar view * should be still visible after the {@link module:ui/editorui/editorui~EditorUI#event:update}. */ - private _showToolbar( toolbarDefinition: WidgetRepositoryToolbarDefinition, relatedElement: ViewElement ) { + private _showToolbar( + toolbarDefinition: WidgetRepositoryToolbarDefinition, + relatedElement: ViewElement + ) { if ( this._isToolbarVisible( toolbarDefinition ) ) { - repositionContextualBalloon( this.editor, relatedElement ); + repositionContextualBalloon( this.editor, relatedElement, toolbarDefinition.positions ); } else if ( !this._isToolbarInBalloon( toolbarDefinition ) ) { if ( !toolbarDefinition.initialized ) { toolbarDefinition.initialized = true; @@ -280,7 +292,7 @@ export default class WidgetToolbarRepository extends Plugin { this._balloon.add( { view: toolbarDefinition.view, - position: getBalloonPositionData( this.editor, relatedElement ), + position: getBalloonPositionData( this.editor, relatedElement, toolbarDefinition.positions ), balloonClassName: toolbarDefinition.balloonClassName } ); @@ -292,7 +304,7 @@ export default class WidgetToolbarRepository extends Plugin { for ( const definition of this._toolbarDefinitions.values() ) { if ( this._isToolbarVisible( definition ) ) { const relatedElement = definition.getRelatedElement( this.editor.editing.view.document.selection ); - repositionContextualBalloon( this.editor, relatedElement! ); + repositionContextualBalloon( this.editor, relatedElement!, toolbarDefinition.positions ); } } } ); @@ -308,20 +320,20 @@ export default class WidgetToolbarRepository extends Plugin { } } -function repositionContextualBalloon( editor: Editor, relatedElement: ViewElement ) { +function repositionContextualBalloon( editor: Editor, relatedElement: ViewElement, positions?: ReadonlyArray ) { const balloon: ContextualBalloon = editor.plugins.get( 'ContextualBalloon' ); - const position = getBalloonPositionData( editor, relatedElement ); + const position = getBalloonPositionData( editor, relatedElement, positions ); balloon.updatePosition( position ); } -function getBalloonPositionData( editor: Editor, relatedElement: ViewElement ) { +function getBalloonPositionData( editor: Editor, relatedElement: ViewElement, positions?: ReadonlyArray ) { const editingView = editor.editing.view; const defaultPositions = BalloonPanelView.defaultPositions; return { target: editingView.domConverter.mapViewToDom( relatedElement ) as RectSource | undefined, - positions: [ + positions: positions || [ defaultPositions.northArrowSouth, defaultPositions.northArrowSouthWest, defaultPositions.northArrowSouthEast, @@ -366,5 +378,7 @@ interface WidgetRepositoryToolbarDefinition { itemsConfig: Array; + positions?: ReadonlyArray; + initialized: boolean; } diff --git a/packages/ckeditor5-widget/tests/widgettoolbarrepository.js b/packages/ckeditor5-widget/tests/widgettoolbarrepository.js index 02931237658..364316b6a62 100644 --- a/packages/ckeditor5-widget/tests/widgettoolbarrepository.js +++ b/packages/ckeditor5-widget/tests/widgettoolbarrepository.js @@ -639,6 +639,113 @@ describe( 'WidgetToolbarRepository', () => { balloonClassName: 'ck-toolbar-container' } ); } ); + + it( 'should use a custom positions if provided', () => { + const editingView = editor.editing.view; + const balloonAddSpy = sinon.spy( balloon, 'add' ); + const balloonUpdatePositionSpy = sinon.spy( balloon, 'updatePosition' ); + const defaultPositions = BalloonPanelView.defaultPositions; + + widgetToolbarRepository.register( 'fake', { + items: editor.config.get( 'fake.toolbar' ), + getRelatedElement: getSelectedFakeWidget, + positions: [ + defaultPositions.southArrowNorth, + defaultPositions.northArrowSouth + ] + } ); + + setData( model, 'foo[]' ); + + const fakeWidgetToolbarView = widgetToolbarRepository._toolbarDefinitions.get( 'fake' ).view; + const widgetViewElement = editingView.document.getRoot().getChild( 1 ); + + sinon.assert.calledOnce( balloonAddSpy ); + sinon.assert.calledWithExactly( balloonAddSpy, { + view: fakeWidgetToolbarView, + position: { + target: editingView.domConverter.mapViewToDom( widgetViewElement ), + positions: [ + defaultPositions.southArrowNorth, + defaultPositions.northArrowSouth + ] + }, + balloonClassName: 'ck-toolbar-container' + } ); + + // Reposition check. + sinon.assert.notCalled( balloonUpdatePositionSpy ); + + editor.ui.update(); + + sinon.assert.calledOnce( balloonUpdatePositionSpy ); + sinon.assert.calledWithExactly( balloonUpdatePositionSpy, { + target: editingView.domConverter.mapViewToDom( widgetViewElement ), + positions: [ + defaultPositions.southArrowNorth, + defaultPositions.northArrowSouth + ] + } ); + } ); + + it( 'should update balloon custom position when stack with toolbar is switched in rotator to visible', () => { + const view = editor.editing.view; + const customView = new View(); + const defaultPositions = BalloonPanelView.defaultPositions; + + sinon.spy( balloon.view, 'pin' ); + + widgetToolbarRepository.register( 'fake', { + items: editor.config.get( 'fake.toolbar' ), + getRelatedElement: getSelectedFakeWidget, + positions: [ + defaultPositions.southArrowNorth, + defaultPositions.northArrowSouth + ] + } ); + + setData( model, + 'foo' + + '[]' + ); + + const fakeViewElement = view.document.getRoot().getChild( 1 ); + const fakeDomElement = editor.editing.view.domConverter.mapViewToDom( fakeViewElement ); + const fakeWidgetToolbarView = widgetToolbarRepository._toolbarDefinitions.get( 'fake' ).view; + + expect( balloon.view.pin.lastCall.args[ 0 ].target ).to.equal( fakeDomElement ); + + balloon.add( { + stackId: 'custom', + view: customView, + position: { target: {} } + } ); + + balloon.showStack( 'custom' ); + + expect( balloon.visibleView ).to.equal( customView ); + expect( balloon.hasView( fakeWidgetToolbarView ) ).to.equal( true ); + + editor.execute( 'blockQuote' ); + balloon.showStack( 'main' ); + + expect( balloon.visibleView ).to.equal( fakeWidgetToolbarView ); + expect( balloon.hasView( customView ) ).to.equal( true ); + expect( balloon.view.pin.lastCall.args[ 0 ].target ).to.not.equal( fakeDomElement ); + expect( balloon.view.pin.lastCall.args[ 0 ].positions ).to.deep.equal( [ + defaultPositions.southArrowNorth, + defaultPositions.northArrowSouth + ] ); + + const newFakeViewElement = view.document.getRoot().getChild( 1 ).getChild( 0 ); + const newFakeDomElement = editor.editing.view.domConverter.mapViewToDom( newFakeViewElement ); + + expect( balloon.view.pin.lastCall.args[ 0 ].target ).to.equal( newFakeDomElement ); + expect( balloon.view.pin.lastCall.args[ 0 ].positions ).to.deep.equal( [ + defaultPositions.southArrowNorth, + defaultPositions.northArrowSouth + ] ); + } ); } ); } ); diff --git a/tests/manual/all-features.js b/tests/manual/all-features.js index 94e4a17816b..13ff2943771 100644 --- a/tests/manual/all-features.js +++ b/tests/manual/all-features.js @@ -120,7 +120,7 @@ ClassicEditor } ], toolbar: [ - 'imageTextAlternative', 'toggleImageCaption', '|', + 'linkImage', 'imageTextAlternative', 'toggleImageCaption', '|', 'imageStyle:inline', 'imageStyle:breakText', 'imageStyle:wrapText', '|', 'resizeImage' ]