diff --git a/packages/ckeditor5-core/theme/icons/settings.svg b/packages/ckeditor5-core/theme/icons/settings.svg index fa09c6753fe..fc0ebb84abf 100644 --- a/packages/ckeditor5-core/theme/icons/settings.svg +++ b/packages/ckeditor5-core/theme/icons/settings.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/packages/ckeditor5-link/lang/contexts.json b/packages/ckeditor5-link/lang/contexts.json index ddb4d07e000..83965236d48 100644 --- a/packages/ckeditor5-link/lang/contexts.json +++ b/packages/ckeditor5-link/lang/contexts.json @@ -4,6 +4,7 @@ "Link URL": "Label for the URL input in the Link URL editing balloon.", "Link URL must not be empty.": "An error text displayed when user attempted to enter an empty URL.", "Link image": "Label for the image link button.", + "Link properties": "Label for the link properties link balloon title.", "Edit link": "Button opening the Link URL editing balloon.", "Open link in new tab": "Button opening the link in new browser tab.", "This link has no URL": "Label explaining that a link has no URL set (the URL is empty).", @@ -14,7 +15,6 @@ "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/linkcommand.ts b/packages/ckeditor5-link/src/linkcommand.ts index 1fa44fbf7ca..d3c7703f8c2 100644 --- a/packages/ckeditor5-link/src/linkcommand.ts +++ b/packages/ckeditor5-link/src/linkcommand.ts @@ -10,10 +10,10 @@ import { Command } from 'ckeditor5/src/core.js'; import { findAttributeRange } from 'ckeditor5/src/typing.js'; import { Collection, first, toMap } from 'ckeditor5/src/utils.js'; -import type { Range, DocumentSelection, Model, Writer } from 'ckeditor5/src/engine.js'; +import { LivePosition, type Range } from 'ckeditor5/src/engine.js'; import AutomaticDecorators from './utils/automaticdecorators.js'; -import { isLinkableElement } from './utils.js'; +import { extractTextFromLinkRange, isLinkableElement } from './utils.js'; import type ManualDecorator from './utils/manualdecorator.js'; /** @@ -135,13 +135,33 @@ export default class LinkCommand extends Command { * **Note**: {@link module:link/unlinkcommand~UnlinkCommand#execute `UnlinkCommand#execute()`} removes all * decorator attributes. * + * An optional parameter called `displayedText` is to add or update text of the link that represents the `href`. For example: + * ```ts + * const linkCommand = editor.commands.get( 'link' ); + * + * // Adding a new link with `displayedText` attribute. + * linkCommand.execute( 'http://example.com', {}, 'Example' ); + * ``` + * + * The above code will create an anchor like this: + * + * ```html + * Example + * ``` + * * @fires execute * @param href Link destination. * @param manualDecoratorIds The information about manual decorator attributes to be applied or removed upon execution. + * @param displayedText Text of the link. */ - public override execute( href: string, manualDecoratorIds: Record = {} ): void { + public override execute( + href: string, + manualDecoratorIds: Record = {}, + displayedText?: string + ): void { const model = this.editor.model; const selection = model.document.selection; + // Stores information about manual decorators to turn them on/off when command is applied. const truthyManualDecorators: Array = []; const falsyManualDecorators: Array = []; @@ -155,32 +175,62 @@ export default class LinkCommand extends Command { } model.change( writer => { + const updateLinkAttributes = ( range: Range ): void => { + writer.setAttribute( 'linkHref', href, range ); + + truthyManualDecorators.forEach( item => writer.setAttribute( item, true, range ) ); + falsyManualDecorators.forEach( item => writer.removeAttribute( item, range ) ); + }; + + const updateLinkTextIfNeeded = ( range: Range, linkHref?: string ): Range | undefined => { + const linkText = extractTextFromLinkRange( range ); + + if ( !linkText ) { + return range; + } + + // Make a copy not to override the command param value. + let newText = displayedText; + + if ( !newText ) { + // Replace the link text with the new href if previously href was equal to text. + // For example: `http://ckeditor.com/`. + newText = linkHref && linkHref == linkText ? href : linkText; + } + + // Only if needed. + if ( newText != linkText ) { + return model.insertContent( writer.createText( newText ), range ); + } + }; + + const collapseSelectionAtLinkEnd = ( linkRange: Range ): void => { + writer.setSelection( linkRange.end ); + + // Remove the `linkHref` attribute and all link decorators from the selection. + // It stops adding a new content into the link element. + for ( const key of [ 'linkHref', ...truthyManualDecorators, ...falsyManualDecorators ] ) { + writer.removeSelectionAttribute( key ); + } + }; + // If selection is collapsed then update selected link or insert new one at the place of caret. if ( selection.isCollapsed ) { const position = selection.getFirstPosition()!; // When selection is inside text with `linkHref` attribute. if ( selection.hasAttribute( 'linkHref' ) ) { - const linkText = extractTextFromSelection( selection ); - // Then update `linkHref` value. - let linkRange = findAttributeRange( position, 'linkHref', selection.getAttribute( 'linkHref' ), model ); - - if ( selection.getAttribute( 'linkHref' ) === linkText ) { - linkRange = this._updateLinkContent( model, writer, linkRange, href ); - } - - writer.setAttribute( 'linkHref', href, linkRange ); - - truthyManualDecorators.forEach( item => { - writer.setAttribute( item, true, linkRange ); - } ); + const linkHref = selection.getAttribute( 'linkHref' ) as string; + const linkRange = findAttributeRange( position, 'linkHref', linkHref, model ); + const newLinkRange = updateLinkTextIfNeeded( linkRange, linkHref ); - falsyManualDecorators.forEach( item => { - writer.removeAttribute( item, linkRange ); - } ); + updateLinkAttributes( newLinkRange || linkRange ); - // Put the selection at the end of the updated link. - writer.setSelection( writer.createPositionAfter( linkRange.end.nodeBefore! ) ); + // Put the selection at the end of the updated link only when text was changed. + // When text was not altered we keep the original selection. + if ( newLinkRange ) { + collapseSelectionAtLinkEnd( newLinkRange ); + } } // If not then insert text node with `linkHref` attribute in place of caret. // However, since selection is collapsed, attribute value will be used as data for text node. @@ -194,22 +244,19 @@ export default class LinkCommand extends Command { attributes.set( item, true ); } ); - const { end: positionAfter } = model.insertContent( writer.createText( href, attributes ), position ); + const newLinkRange = model.insertContent( writer.createText( displayedText || href, attributes ), position ); // Put the selection at the end of the inserted link. // Using end of range returned from insertContent in case nodes with the same attributes got merged. - writer.setSelection( positionAfter ); + collapseSelectionAtLinkEnd( newLinkRange ); } - - // Remove the `linkHref` attribute and all link decorators from the selection. - // It stops adding a new content into the link element. - [ 'linkHref', ...truthyManualDecorators, ...falsyManualDecorators ].forEach( item => { - writer.removeSelectionAttribute( item ); - } ); } else { + // Non-collapsed selection. + // If selection has non-collapsed ranges, we change attribute on nodes inside those ranges // omitting nodes where the `linkHref` attribute is disallowed. - const ranges = model.schema.getValidRanges( selection.getRanges(), 'linkHref' ); + const selectionRanges = Array.from( selection.getRanges() ); + const ranges = model.schema.getValidRanges( selectionRanges, 'linkHref' ); // But for the first, check whether the `linkHref` attribute is allowed on selected blocks (e.g. the "image" element). const allowedRanges = []; @@ -231,29 +278,30 @@ export default class LinkCommand extends Command { } } - for ( const range of rangesToUpdate ) { - let linkRange = range; + // Store the selection ranges in a pseudo live range array (stickiness to the outside of the range). + const stickyPseudoRanges = selectionRanges.map( range => ( { + start: LivePosition.fromPosition( range.start, 'toPrevious' ), + end: LivePosition.fromPosition( range.end, 'toNext' ) + } ) ); - if ( rangesToUpdate.length === 1 ) { - // Current text of the link in the document. - const linkText = extractTextFromSelection( selection ); + // Update or set links (including text update if needed). + for ( let range of rangesToUpdate ) { + const linkHref = ( range.start.textNode || range.start.nodeAfter! ).getAttribute( 'linkHref' ) as string | undefined; - if ( selection.getAttribute( 'linkHref' ) === linkText ) { - linkRange = this._updateLinkContent( model, writer, range, href ); - writer.setSelection( writer.createSelection( linkRange ) ); - } - } + range = updateLinkTextIfNeeded( range, linkHref ) || range; + updateLinkAttributes( range ); + } - writer.setAttribute( 'linkHref', href, linkRange ); + // The original selection got trimmed by replacing content so we need to restore it. + writer.setSelection( stickyPseudoRanges.map( pseudoRange => { + const start = pseudoRange.start.toPosition(); + const end = pseudoRange.end.toPosition(); - truthyManualDecorators.forEach( item => { - writer.setAttribute( item, true, linkRange ); - } ); + pseudoRange.start.detach(); + pseudoRange.end.detach(); - falsyManualDecorators.forEach( item => { - writer.removeAttribute( item, linkRange ); - } ); - } + return model.createRange( start, end ); + } ) ); } } ); } @@ -294,41 +342,4 @@ export default class LinkCommand extends Command { return true; } - - /** - * Updates selected link with a new value as its content and as its href attribute. - * - * @param model Model is need to insert content. - * @param writer Writer is need to create text element in model. - * @param range A range where should be inserted content. - * @param href A link value which should be in the href attribute and in the content. - */ - private _updateLinkContent( model: Model, writer: Writer, range: Range, href: string ): Range { - const text = writer.createText( href, { linkHref: href } ); - - return model.insertContent( text, range ); - } -} - -// Returns a text of a link under the collapsed selection or a selection that contains the entire link. -function extractTextFromSelection( selection: DocumentSelection ): string | null { - if ( selection.isCollapsed ) { - const firstPosition = selection.getFirstPosition(); - - return firstPosition!.textNode && firstPosition!.textNode.data; - } else { - const rangeItems = Array.from( selection.getFirstRange()!.getItems() ); - - if ( rangeItems.length > 1 ) { - return null; - } - - const firstNode = rangeItems[ 0 ]; - - if ( firstNode.is( '$text' ) || firstNode.is( '$textProxy' ) ) { - return firstNode.data; - } - - return null; - } } diff --git a/packages/ckeditor5-link/src/linkconfig.ts b/packages/ckeditor5-link/src/linkconfig.ts index 8256e0b8127..edf2bcf4010 100644 --- a/packages/ckeditor5-link/src/linkconfig.ts +++ b/packages/ckeditor5-link/src/linkconfig.ts @@ -189,16 +189,20 @@ export interface LinkConfig { * * * `'linkPreview'`, * * `'editLink'`, + * * `'linkProperties'` * * `'unlink'`. * * The default configuration for link toolbar is: * * ```ts * const linkConfig = { - * toolbar: [ 'linkPreview', '|', 'editLink', 'unlink' ] + * toolbar: [ 'linkPreview', '|', 'editLink', 'linkProperties', 'unlink' ] * }; * ``` * + * The `linkProperties` toolbar item is only available when at least one manual decorator is defined in the + * {@link module:link/linkconfig~LinkConfig#decorators decorators configuration}. + * * Of course, the same buttons can also be used in the * {@link module:core/editor/editorconfig~EditorConfig#toolbar main editor toolbar}. * diff --git a/packages/ckeditor5-link/src/linkediting.ts b/packages/ckeditor5-link/src/linkediting.ts index 2cb910967df..b0a33f4e437 100644 --- a/packages/ckeditor5-link/src/linkediting.ts +++ b/packages/ckeditor5-link/src/linkediting.ts @@ -90,7 +90,7 @@ export default class LinkEditing extends Plugin { editor.config.define( 'link', { allowCreatingEmptyLinks: false, addTargetToExternalLinks: false, - toolbar: [ 'linkPreview', '|', 'editLink', 'unlink' ] + toolbar: [ 'linkPreview', '|', 'editLink', 'linkProperties', 'unlink' ] } ); } diff --git a/packages/ckeditor5-link/src/linkui.ts b/packages/ckeditor5-link/src/linkui.ts index c600544ea19..44faca8fafb 100644 --- a/packages/ckeditor5-link/src/linkui.ts +++ b/packages/ckeditor5-link/src/linkui.ts @@ -33,7 +33,7 @@ import { isWidget } from 'ckeditor5/src/widget.js'; import LinkPreviewButtonView, { type LinkPreviewButtonNavigateEvent } from './ui/linkpreviewbuttonview.js'; import LinkFormView, { type LinkFormValidatorCallback } from './ui/linkformview.js'; import LinkBookmarksView from './ui/linkbookmarksview.js'; -import LinkAdvancedView from './ui/linkadvancedview.js'; +import LinkPropertiesView from './ui/linkpropertiesview.js'; import LinkButtonView from './ui/linkbuttonview.js'; import type LinkCommand from './linkcommand.js'; import type UnlinkCommand from './unlinkcommand.js'; @@ -44,6 +44,7 @@ import { isLinkElement, isScrollableToTarget, scrollToTarget, + extractTextFromLinkRange, LINK_KEYSTROKE } from './utils.js'; @@ -77,9 +78,20 @@ export default class LinkUI extends Plugin { public bookmarksView: LinkBookmarksView | null = null; /** - * The form view displaying advanced link settings. + * The form view displaying properties link settings. */ - public advancedView: LinkAdvancedView | null = null; + public propertiesView: LinkPropertiesView & ViewWithCssTransitionDisabler | null = null; + + /** + * The selected text of the link or text that is selected and can become a link. + * + * Note: It is `undefined` when the current selection does not allow for text, + * for example any non text node is selected or multiple blocks are selected. + * + * @observable + * @readonly + */ + declare public selectedLinkableText: string | undefined; /** * The contextual balloon plugin instance. @@ -114,6 +126,8 @@ export default class LinkUI extends Plugin { const editor = this.editor; const t = this.editor.t; + this.set( 'selectedLinkableText', undefined ); + editor.editing.view.addObserver( ClickObserver ); this._balloon = editor.plugins.get( ContextualBalloon ); @@ -174,8 +188,8 @@ 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.propertiesView ) { + this.propertiesView.destroy(); } if ( this.formView ) { @@ -195,9 +209,14 @@ export default class LinkUI extends Plugin { * Creates views. */ private _createViews() { + const linkCommand: LinkCommand = this.editor.commands.get( 'link' )!; + this.toolbarView = this._createToolbarView(); this.formView = this._createFormView(); - this.advancedView = this._createAdvancedView(); + + if ( linkCommand.manualDecorators.length ) { + this.propertiesView = this._createPropertiesView(); + } if ( this.editor.plugins.has( 'BookmarkEditing' ) ) { this.bookmarksView = this._createBookmarksView(); @@ -214,10 +233,18 @@ export default class LinkUI extends Plugin { private _createToolbarView(): ToolbarView { const editor = this.editor; const toolbarView = new ToolbarView( editor.locale ); + const linkCommand: LinkCommand = editor.commands.get( 'link' )!; toolbarView.class = 'ck-link-toolbar'; - toolbarView.fillFromConfig( editor.config.get( 'link.toolbar' )!, editor.ui.componentFactory ); + // Remove the linkProperties button if there are no manual decorators, as it would be useless. + let toolbarItems = editor.config.get( 'link.toolbar' )!; + + if ( !linkCommand.manualDecorators.length ) { + toolbarItems = toolbarItems.filter( item => item !== 'linkProperties' ); + } + + toolbarView.fillFromConfig( toolbarItems, editor.ui.componentFactory ); // Close the panel on esc key press when the **link toolbar have focus**. toolbarView.keystrokes.set( 'Esc', ( data, cancel ) => { @@ -259,9 +286,7 @@ export default class LinkUI extends Plugin { const formView = new ( CssTransitionDisablerMixin( LinkFormView ) )( editor.locale, getFormValidators( editor ) ); - formView.urlInputView.fieldView.bind( 'value' ).to( linkCommand, 'value' ); - - // TODO: Bind to the "Displayed text" input + formView.displayedTextInputView.bind( 'isEnabled' ).to( this, 'selectedLinkableText', value => value !== undefined ); // Form elements should be read-only when corresponding commands are disabled. formView.urlInputView.bind( 'isEnabled' ).to( linkCommand, 'isEnabled' ); @@ -269,20 +294,22 @@ export default class LinkUI extends Plugin { // 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, this._getDecoratorSwitchesState() ); + const url = formView.urlInputView.fieldView.element!.value; + const parsedUrl = addLinkProtocolIfApplicable( url, defaultProtocol ); + const displayedText = formView.displayedTextInputView.fieldView.element!.value; + + editor.execute( + 'link', + parsedUrl, + this._getDecoratorSwitchesState(), + displayedText !== this.selectedLinkableText ? displayedText : undefined + ); this._closeFormView(); } @@ -298,15 +325,6 @@ 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(); @@ -337,6 +355,7 @@ export default class LinkUI extends Plugin { } ); buttonView.on( 'execute', () => { + this.formView!.resetFormStatus(); this.formView!.urlInputView.fieldView.value = '#' + bookmarkName; // Set focus to the editing view to prevent from losing it while current view is removed. @@ -374,21 +393,20 @@ export default class LinkUI extends Plugin { } /** - * Creates the {@link module:link/ui/linkadvancedview~LinkAdvancedView} instance. + * Creates the {@link module:link/ui/linkpropertiesview~LinkPropertiesView} instance. */ - private _createAdvancedView(): LinkAdvancedView { + private _createPropertiesView(): LinkPropertiesView & ViewWithCssTransitionDisabler { const editor = this.editor; const linkCommand: LinkCommand = this.editor.commands.get( 'link' )!; - const view = new LinkAdvancedView( this.editor.locale ); + + const view = new ( CssTransitionDisablerMixin( LinkPropertiesView ) )( 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. + // Move focus back to the editing view to prevent from losing it while current view is removed. editor.editing.view.focus(); - this._removeAdvancedView(); - this.formView!.focus(); + this._removePropertiesView(); } ); view.listChildren.bindTo( linkCommand.manualDecorators ).using( manualDecorator => { @@ -407,6 +425,7 @@ export default class LinkUI extends Plugin { button.on( 'execute', () => { manualDecorator.set( 'value', !button.isOn ); + editor.execute( 'link', linkCommand.value!, this._getDecoratorSwitchesState() ); } ); return button; @@ -538,6 +557,31 @@ export default class LinkUI extends Plugin { return button; } ); + + editor.ui.componentFactory.add( 'linkProperties', locale => { + const linkCommand: LinkCommand = editor.commands.get( 'link' )!; + const button = new ButtonView( locale ); + const t = locale.t; + + button.set( { + label: t( 'Link properties' ), + icon: icons.settings, + tooltip: true + } ); + + button.bind( 'isEnabled' ).to( + linkCommand, 'isEnabled', + linkCommand, 'value', + linkCommand, 'manualDecorators', + ( isEnabled, href, manualDecorators ) => isEnabled && !!href && manualDecorators.length > 0 + ); + + this.listenTo( button, 'execute', () => { + this._addPropertiesView(); + } ); + + return button; + } ); } /** @@ -694,12 +738,16 @@ export default class LinkUI extends Plugin { position: this._getBalloonPositionData() } ); - // Make sure that each time the panel shows up, the URL field remains in sync with the value of + // Make sure that each time the panel shows up, the fields remains in sync with the value of // the command. If the user typed in the input, then canceled the balloon (`urlInputView.fieldView#value` stays // unaltered) and re-opened it without changing the value of the link command (e.g. because they // clicked the same link), they would see the old value instead of the actual value of the command. // https://github.com/ckeditor/ckeditor5-link/issues/78 // https://github.com/ckeditor/ckeditor5-link/issues/123 + + this.selectedLinkableText = this._getSelectedLinkableText(); + + this.formView!.displayedTextInputView.fieldView.value = this.selectedLinkableText || ''; this.formView!.urlInputView.fieldView.value = linkCommand.value || ''; // Select input when form view is currently visible. @@ -710,6 +758,29 @@ export default class LinkUI extends Plugin { this.formView!.enableCssTransitions(); } + /** + * Adds the {@link #propertiesView} to the {@link #_balloon}. + */ + private _addPropertiesView(): void { + if ( !this.propertiesView ) { + this._createViews(); + } + + if ( this._arePropertiesInPanel ) { + return; + } + + this.propertiesView!.disableCssTransitions(); + + this._balloon.add( { + view: this.propertiesView!, + position: this._getBalloonPositionData() + } ); + + this.propertiesView!.enableCssTransitions(); + this.propertiesView!.focus(); + } + /** * Adds the {@link #bookmarksView} to the {@link #_balloon}. */ @@ -731,19 +802,13 @@ export default class LinkUI extends Plugin { /** * Closes the form view. Decides whether the balloon should be hidden completely or if the action view should be shown. This is * decided upon the link command value (which has a value if the document selection is in the link). - * - * Additionally, if any {@link module:link/linkconfig~LinkConfig#decorators} are defined in the editor configuration, the state of - * switch buttons responsible for manual decorator handling is restored. */ private _closeFormView(): void { const linkCommand: LinkCommand = this.editor.commands.get( 'link' )!; - // Restore manual decorator states to represent the current model state. This case is important to reset the switch buttons - // when the user cancels the editing form. - linkCommand.restoreManualDecoratorStates(); + this.selectedLinkableText = undefined; if ( linkCommand.value !== undefined ) { - this._removeAdvancedView(); this._removeFormView(); } else { this._hideUI(); @@ -751,11 +816,11 @@ export default class LinkUI extends Plugin { } /** - * Removes the {@link #advancedView} from the {@link #_balloon}. + * Removes the {@link #propertiesView} from the {@link #_balloon}. */ - private _removeAdvancedView(): void { - if ( this._isAdvancedInPanel ) { - this._balloon.remove( this.advancedView! ); + private _removePropertiesView(): void { + if ( this._arePropertiesInPanel ) { + this._balloon.remove( this.propertiesView! ); } } @@ -777,7 +842,8 @@ export default class LinkUI extends Plugin { // See https://github.com/ckeditor/ckeditor5/issues/1501. this.formView!.saveButtonView.focus(); - // Reset the URL field to update the state of the submit button. + // Reset fields to update the state of the submit button. + this.formView!.displayedTextInputView.fieldView.reset(); this.formView!.urlInputView.fieldView.reset(); this._balloon.remove( this.formView! ); @@ -857,15 +923,13 @@ export default class LinkUI extends Plugin { 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(); - // If the advanced form view is visible, remove it because it can be on top of the stack. - this._removeAdvancedView(); + // If the properties form view is visible, remove it because it can be on top of the stack. + this._removePropertiesView(); - // Then remove the form view because it's beneath the advanced form. + // Then remove the form view because it's beneath the properties form. this._removeFormView(); // Finally, remove the link toolbar view because it's last in the stack. @@ -933,10 +997,10 @@ export default class LinkUI extends Plugin { } /** - * Returns `true` when {@link #advancedView} is in the {@link #_balloon}. + * Returns `true` when {@link #propertiesView} is in the {@link #_balloon}. */ - private get _isAdvancedInPanel(): boolean { - return !!this.advancedView && this._balloon.hasView( this.advancedView ); + private get _arePropertiesInPanel(): boolean { + return !!this.propertiesView && this._balloon.hasView( this.propertiesView ); } /** @@ -961,11 +1025,11 @@ export default class LinkUI extends Plugin { } /** - * Returns `true` when {@link #advancedView} is in the {@link #_balloon} and it is + * Returns `true` when {@link #propertiesView} is in the {@link #_balloon} and it is * currently visible. */ - private get _isAdvancedVisible(): boolean { - return !!this.advancedView && this._balloon.visibleView === this.advancedView; + private get _isPropertiesVisible(): boolean { + return !!this.propertiesView && this._balloon.visibleView === this.propertiesView; } /** @@ -993,19 +1057,19 @@ export default class LinkUI extends Plugin { } /** - * Returns `true` when {@link #advancedView}, {@link #toolbarView}, {@link #bookmarksView} + * Returns `true` when {@link #propertiesView}, {@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._arePropertiesInPanel || this._areBookmarksInPanel || this._isFormInPanel || this._isToolbarInPanel; } /** - * Returns `true` when {@link #advancedView}, {@link #bookmarksView}, {@link #toolbarView} + * Returns `true` when {@link #propertiesView}, {@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; + return this._isPropertiesVisible || this._areBookmarksVisible || this._isFormVisible || this._isToolbarVisible; } /** @@ -1017,25 +1081,33 @@ export default class LinkUI extends Plugin { */ private _getBalloonPositionData(): Partial { const view = this.editor.editing.view; - const model = this.editor.model; const viewDocument = view.document; - let target: PositionOptions[ 'target' ]; + const model = this.editor.model; if ( model.markers.has( VISUAL_SELECTION_MARKER_NAME ) ) { // There are cases when we highlight selection using a marker (#7705, #4721). - const markerViewElements = Array.from( this.editor.editing.mapper.markerNameToElements( VISUAL_SELECTION_MARKER_NAME )! ); - const newRange = view.createRange( - view.createPositionBefore( markerViewElements[ 0 ] ), - view.createPositionAfter( markerViewElements[ markerViewElements.length - 1 ] ) - ); + const markerViewElements = this.editor.editing.mapper.markerNameToElements( VISUAL_SELECTION_MARKER_NAME ); + + // Marker could be removed by link text override and end up in the graveyard. + if ( markerViewElements ) { + const markerViewElementsArray = Array.from( markerViewElements ); + const newRange = view.createRange( + view.createPositionBefore( markerViewElementsArray[ 0 ] ), + view.createPositionAfter( markerViewElementsArray[ markerViewElementsArray.length - 1 ] ) + ); - target = view.domConverter.viewRangeToDom( newRange ); - } else { - // Make sure the target is calculated on demand at the last moment because a cached DOM range - // (which is very fragile) can desynchronize with the state of the editing view if there was - // any rendering done in the meantime. This can happen, for instance, when an inline widget - // gets unlinked. - target = () => { + return { + target: view.domConverter.viewRangeToDom( newRange ) + }; + } + } + + // Make sure the target is calculated on demand at the last moment because a cached DOM range + // (which is very fragile) can desynchronize with the state of the editing view if there was + // any rendering done in the meantime. This can happen, for instance, when an inline widget + // gets unlinked. + return { + target: () => { const targetLink = this._getSelectedLinkElement(); return targetLink ? @@ -1043,10 +1115,8 @@ export default class LinkUI extends Plugin { view.domConverter.mapViewToDom( targetLink )! : // Otherwise attach panel to the selection. view.domConverter.viewRangeToDom( viewDocument.selection.getFirstRange()! ); - }; - } - - return { target }; + } + }; } /** @@ -1086,6 +1156,26 @@ export default class LinkUI extends Plugin { } } + /** + * Returns selected link text content. + * If link is not selected it returns the selected text. + * If selection or link includes non text node (inline object or block) then returns undefined. + */ + private _getSelectedLinkableText(): string | undefined { + const model = this.editor.model; + const editing = this.editor.editing; + const selectedLink = this._getSelectedLinkElement(); + + if ( !selectedLink ) { + return extractTextFromLinkRange( model.document.selection.getFirstRange()! ); + } + + const viewLinkRange = editing.view.createRangeOn( selectedLink ); + const linkRange = editing.mapper.toModelRange( viewLinkRange ); + + return extractTextFromLinkRange( linkRange ); + } + /** * Displays a fake visual selection when the contextual balloon is displayed. * diff --git a/packages/ckeditor5-link/src/ui/linkformview.ts b/packages/ckeditor5-link/src/ui/linkformview.ts index 08e0b44002c..65666034088 100644 --- a/packages/ckeditor5-link/src/ui/linkformview.ts +++ b/packages/ckeditor5-link/src/ui/linkformview.ts @@ -52,11 +52,6 @@ export default class LinkFormView extends View { */ public backButtonView: ButtonView; - /** - * The Settings button view displayed in the header. - */ - public settingsButtonView: ButtonView; - /** * The Save button view. */ @@ -120,7 +115,6 @@ export default class LinkFormView extends View { // Create buttons this.backButtonView = this._createBackButton(); - this.settingsButtonView = this._createSettingsButton(); this.saveButtonView = this._createSaveButton(); // Create input fields @@ -183,7 +177,6 @@ export default class LinkFormView extends View { this.saveButtonView, ...this.listChildren, this.backButtonView, - this.settingsButtonView, this.displayedTextInputView ]; @@ -265,22 +258,6 @@ export default class LinkFormView extends View { return backButton; } - /** - * Creates a settings button view that opens the advanced settings panel. - */ - private _createSettingsButton(): ButtonView { - const t = this.locale!.t; - const settingsButton = new ButtonView( this.locale ); - - settingsButton.set( { - label: t( 'Advanced' ), - icon: icons.settings, - tooltip: true - } ); - - return settingsButton; - } - /** * Creates a save button view that inserts the link. */ @@ -310,7 +287,6 @@ export default class LinkFormView extends View { } ); header.children.add( this.backButtonView, 0 ); - header.children.add( this.settingsButtonView ); return header; } diff --git a/packages/ckeditor5-link/src/ui/linkadvancedview.ts b/packages/ckeditor5-link/src/ui/linkpropertiesview.ts similarity index 90% rename from packages/ckeditor5-link/src/ui/linkadvancedview.ts rename to packages/ckeditor5-link/src/ui/linkpropertiesview.ts index 2bd10d15b47..9ad593069ae 100644 --- a/packages/ckeditor5-link/src/ui/linkadvancedview.ts +++ b/packages/ckeditor5-link/src/ui/linkpropertiesview.ts @@ -4,7 +4,7 @@ */ /** - * @module link/ui/linkadvancedview + * @module link/ui/linkpropertiesview */ import { @@ -31,11 +31,11 @@ import '@ckeditor/ckeditor5-ui/theme/components/responsive-form/responsiveform.c import '../../theme/linkform.css'; /** - * The link form view controller class. + * The link properties view controller class. * - * See {@link module:link/ui/linkadvancedview~LinkAdvancedView}. + * See {@link module:link/ui/linkpropertiesview~LinkPropertiesView}. */ -export default class LinkAdvancedView extends View { +export default class LinkPropertiesView extends View { /** * Tracks information about DOM focus in the form. */ @@ -74,7 +74,7 @@ export default class LinkAdvancedView extends View { private readonly _focusCycler: FocusCycler; /** - * Creates an instance of the {@link module:link/ui/linkadvancedview~LinkAdvancedView} class. + * Creates an instance of the {@link module:link/ui/linkpropertiesview~LinkPropertiesView} class. * * Also see {@link #render}. * @@ -108,7 +108,7 @@ export default class LinkAdvancedView extends View { tag: 'div', attributes: { - class: [ 'ck', 'ck-link__panel', 'ck-link__advanced' ], + class: [ 'ck', 'ck-link__panel', 'ck-link__properties' ], // https://github.com/ckeditor/ckeditor5-link/issues/90 tabindex: '-1' @@ -190,7 +190,7 @@ export default class LinkAdvancedView extends View { const t = this.locale!.t; const header = new FormHeaderView( this.locale, { - label: t( 'Advanced' ) + label: t( 'Link properties' ) } ); header.children.add( this.backButtonView, 0 ); @@ -225,9 +225,9 @@ export default class LinkAdvancedView extends View { } /** - * Fired when the {@link ~LinkAdvancedView#backButtonView} is pressed. + * Fired when the {@link ~LinkPropertiesView#backButtonView} is pressed. * - * @eventName ~LinkAdvancedView#back + * @eventName ~LinkPropertiesView#back */ export type BackEvent = { name: 'back'; diff --git a/packages/ckeditor5-link/src/utils.ts b/packages/ckeditor5-link/src/utils.ts index b37c2bc1aa3..7da136106bd 100644 --- a/packages/ckeditor5-link/src/utils.ts +++ b/packages/ckeditor5-link/src/utils.ts @@ -15,7 +15,8 @@ import type { Schema, ViewAttributeElement, ViewNode, - ViewDocumentFragment + ViewDocumentFragment, + Range } from 'ckeditor5/src/engine.js'; import type { Editor } from 'ckeditor5/src/core.js'; @@ -237,6 +238,25 @@ export function scrollToTarget( editor: Editor, link: string ): boolean { return true; } +/** + * Returns a text of a link range. + * + * If the returned value is `undefined`, it means that the range contains elements other than text nodes. + */ +export function extractTextFromLinkRange( range: Range ): string | undefined { + let text = ''; + + for ( const item of range.getItems() ) { + if ( !item.is( '$text' ) && !item.is( '$textProxy' ) ) { + return; + } + + text += item.data; + } + + return text; +} + export type NormalizedLinkDecoratorAutomaticDefinition = LinkDecoratorAutomaticDefinition & { id: string }; export type NormalizedLinkDecoratorManualDefinition = LinkDecoratorManualDefinition & { id: string }; export type NormalizedLinkDecoratorDefinition = NormalizedLinkDecoratorAutomaticDefinition | NormalizedLinkDecoratorManualDefinition; diff --git a/packages/ckeditor5-link/tests/linkcommand.js b/packages/ckeditor5-link/tests/linkcommand.js index b507d51345c..170599cb6b1 100644 --- a/packages/ckeditor5-link/tests/linkcommand.js +++ b/packages/ckeditor5-link/tests/linkcommand.js @@ -293,6 +293,17 @@ describe( 'LinkCommand', () => { expect( command.value ).to.equal( 'url' ); } ); + it( 'should set `linkHref` attribute to selected text and change the selected text', () => { + setData( model, 'f[ooba]r' ); + + expect( command.value ).to.be.undefined; + + command.execute( 'url', {}, 'xyz' ); + + expect( getData( model ) ).to.equal( 'f[<$text linkHref="url">xyz]r' ); + expect( command.value ).to.equal( 'url' ); + } ); + it( 'should set `linkHref` attribute to selected text when text already has attributes', () => { setData( model, 'f[o<$text bold="true">oba]r' ); @@ -361,6 +372,18 @@ describe( 'LinkCommand', () => { expect( command.value ).to.equal( 'url' ); } ); + it( 'should overwrite existing `linkHref` attribute and displayed text' + + 'when whole text with `linkHref` attribute is selected', () => { + setData( model, 'fo[<$text linkHref="other url">o]bar' ); + + expect( command.value ).to.equal( 'other url' ); + + command.execute( 'url', {}, 'xyz' ); + + expect( getData( model ) ).to.equal( 'fo[<$text linkHref="url">xyz]bar' ); + expect( command.value ).to.equal( 'url' ); + } ); + it( 'should set `linkHref` attribute to selected text when text is split by $block element', () => { setData( model, 'f[ooba]r' ); @@ -374,6 +397,38 @@ describe( 'LinkCommand', () => { expect( command.value ).to.equal( 'url' ); } ); + it( 'should update all links which is equal its href if selection is on more than one element', () => { + setData( model, + '' + + 'abc<$text linkHref="foo">[foo' + + '' + + '' + + '<$text linkHref="foo">foo' + + '' + + '' + + '<$text linkHref="foo">www' + + '' + + 'baz]xyz' + ); + + command.execute( 'bar123' ); + + expect( getData( model ) ).to.equal( + '' + + 'abc[<$text linkHref="bar123">bar123' + + '' + + '' + + '<$text linkHref="bar123">bar123' + + '' + + '' + + '<$text linkHref="bar123">www' + + '' + + '' + + '<$text linkHref="bar123">baz]xyz' + + '' + ); + } ); + describe( 'for block elements allowing linkHref', () => { it( 'should set `linkHref` attribute to allowed elements', () => { model.schema.register( 'linkableBlock', { @@ -548,6 +603,31 @@ describe( 'LinkCommand', () => { '' ); } ); + + it( 'should set `linkHref` attribute to allowed elements and not allow to change the displayed text', () => { + model.schema.register( 'linkableInline', { + isObject: true, + isInline: true, + allowWhere: '$text', + allowAttributes: [ 'linkHref' ] + } ); + + setData( model, 'f[ooba]r' ); + + expect( command.value ).to.be.undefined; + + command.execute( 'url', {}, 'xyz' ); + + expect( getData( model ) ).to.equal( + '' + + 'f[<$text linkHref="url">oo' + + '' + + '<$text linkHref="url">ba]r' + + '' + ); + + expect( command.value ).to.equal( 'url' ); + } ); } ); } ); @@ -572,12 +652,12 @@ describe( 'LinkCommand', () => { ); } ); - it( 'should update `linkHref` attribute (text with `linkHref` attribute) and put the selection after the node', () => { + it( 'should update `linkHref` attribute (text with `linkHref` attribute) and keep the selection as was', () => { setData( model, '<$text linkHref="other url">foo[]bar' ); command.execute( 'url' ); - expect( getData( model ) ).to.equal( '<$text linkHref="url">foobar[]' ); + expect( getData( model ) ).to.equal( '<$text linkHref="url">foo[]bar' ); } ); it( 'should not insert text with `linkHref` attribute when is not allowed in parent', () => { @@ -597,7 +677,7 @@ describe( 'LinkCommand', () => { it( 'should not insert text node if link is empty', () => { setData( model, 'foo[]bar' ); - command.execute( '' ); + command.execute( '', {}, '' ); expect( getData( model ) ).to.equal( 'foo[]bar' ); } ); @@ -612,6 +692,16 @@ describe( 'LinkCommand', () => { expect( getData( model ) ).to.equal( '<$text linkHref="url">foourl[]bar' ); } ); + + it( 'should overwrite existing `linkHref` attribute and displayed text', () => { + setData( model, 'fo<$text linkHref="other url">o[]bar' ); + + expect( command.value ).to.equal( 'other url' ); + + command.execute( 'url', {}, 'xyz' ); + + expect( getData( model ) ).to.equal( 'fo<$text linkHref="url">xyz[]ar' ); + } ); } ); } ); @@ -690,7 +780,7 @@ describe( 'LinkCommand', () => { command.execute( 'url', { linkIsFoo: true, linkIsBar: true, linkIsSth: true } ); expect( getData( model ) ).to - .equal( 'f<$text linkHref="url" linkIsBar="true" linkIsFoo="true" linkIsSth="true">ooba[]r' ); + .equal( 'f<$text linkHref="url" linkIsBar="true" linkIsFoo="true" linkIsSth="true">o[]obar' ); } ); it( 'should remove additional attributes to link if those are falsy', () => { @@ -698,7 +788,7 @@ describe( 'LinkCommand', () => { command.execute( 'url', { linkIsFoo: false, linkIsBar: false } ); - expect( getData( model ) ).to.equal( 'foo<$text linkHref="url">url[]bar' ); + expect( getData( model ) ).to.equal( 'foo<$text linkHref="url">u[]rlbar' ); } ); it( 'should update content if href is equal to content', () => { @@ -768,7 +858,7 @@ describe( 'LinkCommand', () => { ); } ); - it( 'should update content if href is equal to content', () => { + it( 'should not update content even if href is equal to content', () => { setData( model, '[<$text linkHref="url">url]' ); command.execute( 'url2', { linkIsFoo: true, linkIsBar: true, linkIsSth: true } ); @@ -807,30 +897,6 @@ describe( 'LinkCommand', () => { expect( getData( model ) ).to .equal( '[<$text linkHref="url2">url2]' ); } ); - - it( 'should not update link which is equal its href if selection is on more than one element', () => { - setData( model, - '' + - '<$text linkHref="foo">[foo' + - '' + - 'bar' + - 'baz]' - ); - - command.execute( 'foooo' ); - - expect( getData( model ) ).to - .equal( '' + - '[<$text linkHref="foooo">foo' + - '' + - '' + - '<$text linkHref="foooo">bar' + - '' + - '' + - '<$text linkHref="foooo">baz]' + - '' - ); - } ); } ); describe( 'restoreManualDecoratorStates()', () => { diff --git a/packages/ckeditor5-link/tests/linkui.js b/packages/ckeditor5-link/tests/linkui.js index 3b28353fc59..b9fde469fa4 100644 --- a/packages/ckeditor5-link/tests/linkui.js +++ b/packages/ckeditor5-link/tests/linkui.js @@ -19,6 +19,7 @@ import env from '@ckeditor/ckeditor5-utils/src/env.js'; import Essentials from '@ckeditor/ckeditor5-essentials/src/essentials.js'; import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph.js'; import BlockQuote from '@ckeditor/ckeditor5-block-quote/src/blockquote.js'; +import BoldEditing from '@ckeditor/ckeditor5-basic-styles/src/bold/boldediting.js'; import ClickObserver from '@ckeditor/ckeditor5-engine/src/view/observer/clickobserver.js'; import ContextualBalloon from '@ckeditor/ckeditor5-ui/src/panel/balloon/contextualballoon.js'; import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview.js'; @@ -31,64 +32,33 @@ import LinkUI from '../src/linkui.js'; import LinkFormView from '../src/ui/linkformview.js'; 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 LinkPropertiesView from '../src/ui/linkpropertiesview.js'; +import ManualDecorator from '../src/utils/manualdecorator.js'; import { MenuBarMenuListItemButtonView, ToolbarView } from '@ckeditor/ckeditor5-ui'; import linkIcon from '../theme/icons/link.svg'; describe( 'LinkUI', () => { - let editor, linkUIFeature, linkButton, balloon, formView, toolbarView, advancedView, editorElement; + let editor, linkUIFeature, linkButton, balloon, formView, toolbarView, editorElement, propertiesView; testUtils.createSinonSandbox(); - beforeEach( () => { + beforeEach( async () => { editorElement = document.createElement( 'div' ); document.body.appendChild( editorElement ); - return ClassicTestEditor - .create( editorElement, { - plugins: [ Essentials, LinkEditing, LinkUI, Paragraph, BlockQuote ], - 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; + editor = await ClassicTestEditor.create( editorElement, { + plugins: [ Essentials, LinkEditing, LinkUI, Paragraph, BlockQuote, BoldEditing ] + } ); - linkUIFeature = editor.plugins.get( LinkUI ); - linkButton = editor.ui.componentFactory.create( 'link' ); - balloon = editor.plugins.get( ContextualBalloon ); + linkUIFeature = editor.plugins.get( LinkUI ); + linkButton = editor.ui.componentFactory.create( 'link' ); + balloon = editor.plugins.get( ContextualBalloon ); - // There is no point to execute BalloonPanelView attachTo and pin methods so lets override it. - testUtils.sinon.stub( balloon.view, 'attachTo' ).returns( {} ); - testUtils.sinon.stub( balloon.view, 'pin' ).returns( {} ); - } ); + // There is no point to execute BalloonPanelView attachTo and pin methods so lets override it. + testUtils.sinon.stub( balloon.view, 'attachTo' ).returns( {} ); + testUtils.sinon.stub( balloon.view, 'pin' ).returns( {} ); } ); afterEach( () => { @@ -332,6 +302,79 @@ describe( 'LinkUI', () => { sinon.assert.calledOnce( stubAddForm ); } ); } ); + + describe( 'the "linkProperties" toolbar button', () => { + let button; + + beforeEach( () => { + button = editor.ui.componentFactory.create( 'linkProperties' ); + editor.commands.get( 'link' ).manualDecorators.add( new ManualDecorator( { + id: 'linkIsBar', + label: 'Bar', + attributes: { + target: '_blank' + } + } ) ); + } ); + + it( 'should be a ButtonView instance', () => { + expect( button ).to.be.instanceOf( ButtonView ); + } ); + + it( 'should set button properties', () => { + expect( button.label ).to.equal( 'Link properties' ); + expect( button.tooltip ).to.be.true; + expect( button.icon ).to.not.be.undefined; + } ); + + it( 'should be disabled if link value is empty or command is disabled', () => { + const linkCommand = editor.commands.get( 'link' ); + + linkCommand.value = 'http://ckeditor.com'; + expect( button.isEnabled ).to.be.true; + + linkCommand.isEnabled = false; + expect( button.isEnabled ).to.be.false; + + linkCommand.isEnabled = true; + linkCommand.value = ''; + expect( button.isEnabled ).to.be.false; + + linkCommand.value = null; + expect( button.isEnabled ).to.be.false; + } ); + + it( 'should be disabled if there are no manual decorators', () => { + const linkCommand = editor.commands.get( 'link' ); + + linkCommand.isEnabled = false; + + expect( button.isEnabled ).to.be.false; + + linkCommand.manualDecorators.clear(); + linkCommand.isEnabled = true; + + expect( button.isEnabled ).to.be.false; + } ); + + it( 'should add properties view to the balloon on execute', () => { + const stubAddProperties = sinon.stub( linkUIFeature, '_addPropertiesView' ); + + button.fire( 'execute' ); + + sinon.assert.calledOnce( stubAddProperties ); + } ); + + it( 'should not be available in the toolbar if there are no manual decorators', () => { + let items = Array.from( linkUIFeature._createToolbarView().items ).map( item => item.label ); + + expect( items ).to.include( 'Link properties' ); + + editor.commands.get( 'link' ).manualDecorators.clear(); + items = Array.from( linkUIFeature._createToolbarView().items ).map( item => item.label ); + expect( items ).not.to.include( 'Link properties' ); + } ); + } ); } ); describe( '_showUI()', () => { @@ -1133,27 +1176,47 @@ describe( 'LinkUI', () => { } ); } ); - describe( '_createAdvancedView()', () => { + describe( '_addPropertiesView()', () => { beforeEach( () => { editor.editing.view.document.isFocused = true; + editor.commands.get( 'link' ).manualDecorators.add( new ManualDecorator( { + id: 'linkIsBar', + label: 'Bar', + attributes: { + target: '_blank' + } + } ) ); } ); - it( 'should create #advancedView', () => { + it( 'should create #propertiesView', () => { setModelData( editor.model, 'f[o]o' ); linkUIFeature._showUI(); - expect( linkUIFeature.advancedView ).to.be.instanceOf( LinkAdvancedView ); + expect( linkUIFeature.propertiesView ).to.be.instanceOf( LinkPropertiesView ); } ); - it( 'should add #advancedView to the balloon and attach the balloon', () => { + it( 'should add #propertiesView to the balloon and attach the balloon', () => { setModelData( editor.model, 'f[o]o' ); - linkUIFeature._showUI(); - linkUIFeature.formView.settingsButtonView.fire( 'execute' ); - advancedView = linkUIFeature.advancedView; + linkUIFeature._addPropertiesView(); + propertiesView = linkUIFeature.propertiesView; - expect( balloon.visibleView ).to.equal( advancedView ); + expect( balloon.visibleView ).to.equal( propertiesView ); + } ); + + it( 'should not add #propertiesView to the balloon again when it is already added', () => { + setModelData( editor.model, 'f[o]o' ); + + linkUIFeature._addPropertiesView(); + propertiesView = linkUIFeature.propertiesView; + + const addSpy = sinon.spy( balloon, 'add' ); + + linkUIFeature._addPropertiesView(); + + expect( addSpy ).not.to.be.called; + expect( balloon.visibleView ).to.equal( propertiesView ); } ); } ); @@ -1179,7 +1242,6 @@ describe( 'LinkUI', () => { formView = linkUIFeature.formView; toolbarView = linkUIFeature.toolbarView; - advancedView = linkUIFeature.advancedView; } ); it( 'should remove the UI from the balloon', () => { @@ -1248,23 +1310,6 @@ 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', () => { @@ -1809,52 +1854,26 @@ describe( 'LinkUI', () => { describe( 'link form view', () => { let focusEditableSpy; - const createEditorWithDefaultProtocol = defaultProtocol => { - return ClassicTestEditor - .create( editorElement, { - plugins: [ LinkEditing, LinkUI, Paragraph, BlockQuote ], - link: { defaultProtocol } - } ) - .then( editor => { - const linkUIFeature = editor.plugins.get( LinkUI ); - - linkUIFeature._createViews(); - - const formView = linkUIFeature.formView; - - formView.render(); - - editor.model.schema.extend( '$text', { - allowIn: '$root', - allowAttributes: 'linkHref' - } ); - - return { editor, formView }; - } ); - }; + const createEditorWithLinkConfig = async link => { + const editor = await ClassicTestEditor.create( editorElement, { + plugins: [ LinkEditing, LinkUI, Paragraph, BlockQuote ], + link + } ); - const createEditorWithEmptyLinks = allowCreatingEmptyLinks => { - return ClassicTestEditor - .create( editorElement, { - plugins: [ LinkEditing, LinkUI, Paragraph, BlockQuote ], - link: { allowCreatingEmptyLinks } - } ) - .then( editor => { - const linkUIFeature = editor.plugins.get( LinkUI ); + const linkUIFeature = editor.plugins.get( LinkUI ); - linkUIFeature._createViews(); + linkUIFeature._createViews(); - const formView = linkUIFeature.formView; + const formView = linkUIFeature.formView; - formView.render(); + formView.render(); - editor.model.schema.extend( '$text', { - allowIn: '$root', - allowAttributes: 'linkHref' - } ); + editor.model.schema.extend( '$text', { + allowIn: '$root', + allowAttributes: 'linkHref' + } ); - return { editor, formView }; - } ); + return { editor, formView }; }; beforeEach( () => { @@ -1888,52 +1907,48 @@ describe( 'LinkUI', () => { expect( allowCreatingEmptyLinks ).to.equal( false ); } ); - it( 'should allow enabling empty links', () => { - return createEditorWithEmptyLinks( true ).then( ( { editor } ) => { - const allowCreatingEmptyLinks = editor.config.get( 'link.allowCreatingEmptyLinks' ); + it( 'should allow enabling empty links', async () => { + const { editor } = await createEditorWithLinkConfig( { allowCreatingEmptyLinks: true } ); + const allowCreatingEmptyLinks = editor.config.get( 'link.allowCreatingEmptyLinks' ); - expect( allowCreatingEmptyLinks ).to.equal( true ); + expect( allowCreatingEmptyLinks ).to.equal( true ); - return editor.destroy(); - } ); + return editor.destroy(); } ); - it( 'should not allow submitting empty form when link is required', () => { - return createEditorWithEmptyLinks( false ).then( ( { editor, formView } ) => { - const executeSpy = sinon.spy( editor, 'execute' ); + it( 'should not allow submitting empty form when link is required', async () => { + const { editor, formView } = await createEditorWithLinkConfig( { allowCreatingEmptyLinks: false } ); + const executeSpy = sinon.spy( editor, 'execute' ); - formView.urlInputView.fieldView.value = ''; - formView.fire( 'submit' ); + formView.urlInputView.fieldView.value = ''; + formView.fire( 'submit' ); - expect( executeSpy ).not.to.be.called; - return editor.destroy(); - } ); + expect( executeSpy ).not.to.be.called; + return editor.destroy(); } ); - it( 'should allow submitting empty form when link is not required', () => { - return createEditorWithEmptyLinks( true ).then( ( { editor, formView } ) => { - expect( formView.saveButtonView.isEnabled ).to.be.true; + it( 'should allow submitting empty form when link is not required', async () => { + const { editor, formView } = await createEditorWithLinkConfig( { allowCreatingEmptyLinks: true } ); - return editor.destroy(); - } ); + expect( formView.saveButtonView.isEnabled ).to.be.true; + + return editor.destroy(); } ); } ); describe( 'link protocol', () => { - it( 'should use a default link protocol from the `config.link.defaultProtocol` when provided', () => { - return ClassicTestEditor - .create( editorElement, { - link: { - defaultProtocol: 'https://' - } - } ) - .then( editor => { - const defaultProtocol = editor.config.get( 'link.defaultProtocol' ); + it( 'should use a default link protocol from the `config.link.defaultProtocol` when provided', async () => { + const editor = await ClassicTestEditor.create( editorElement, { + link: { + defaultProtocol: 'https://' + } + } ); - expect( defaultProtocol ).to.equal( 'https://' ); + const defaultProtocol = editor.config.get( 'link.defaultProtocol' ); - return editor.destroy(); - } ); + expect( defaultProtocol ).to.equal( 'https://' ); + + return editor.destroy(); } ); it( 'should not add a protocol without the configuration', () => { @@ -1947,96 +1962,96 @@ describe( 'LinkUI', () => { return editor.destroy(); } ); - it( 'should not add a protocol to the local links even when `config.link.defaultProtocol` configured', () => { - return createEditorWithDefaultProtocol( 'http://' ).then( ( { editor, formView } ) => { - const linkCommandSpy = sinon.spy( editor.commands.get( 'link' ), 'execute' ); - formView.urlInputView.fieldView.value = '#test'; - formView.fire( 'submit' ); + it( 'should not add a protocol to the local links even when `config.link.defaultProtocol` configured', async () => { + const { editor, formView } = await createEditorWithLinkConfig( { defaultProtocol: 'http://' } ); + const linkCommandSpy = sinon.spy( editor.commands.get( 'link' ), 'execute' ); - sinon.assert.calledWith( linkCommandSpy, '#test', sinon.match.any ); + formView.urlInputView.fieldView.value = '#test'; + formView.fire( 'submit' ); - return editor.destroy(); - } ); + sinon.assert.calledWith( linkCommandSpy, '#test', sinon.match.any ); + + return editor.destroy(); } ); - it( 'should not add a protocol to the relative links even when `config.link.defaultProtocol` configured', () => { - return createEditorWithDefaultProtocol( 'http://' ).then( ( { editor, formView } ) => { - const linkCommandSpy = sinon.spy( editor.commands.get( 'link' ), 'execute' ); - formView.urlInputView.fieldView.value = '/test.html'; - formView.fire( 'submit' ); + it( 'should not add a protocol to the relative links even when `config.link.defaultProtocol` configured', async () => { + const { editor, formView } = await createEditorWithLinkConfig( { defaultProtocol: 'http://' } ); + const linkCommandSpy = sinon.spy( editor.commands.get( 'link' ), 'execute' ); + + formView.urlInputView.fieldView.value = '/test.html'; + formView.fire( 'submit' ); - sinon.assert.calledWith( linkCommandSpy, '/test.html', sinon.match.any ); + sinon.assert.calledWith( linkCommandSpy, '/test.html', sinon.match.any ); - return editor.destroy(); - } ); + return editor.destroy(); } ); - it( 'should not add a protocol when given provided within the value even when `config.link.defaultProtocol` configured', () => { - return createEditorWithDefaultProtocol( 'http://' ).then( ( { editor, formView } ) => { - const linkCommandSpy = sinon.spy( editor.commands.get( 'link' ), 'execute' ); - formView.urlInputView.fieldView.value = 'http://example.com'; - formView.fire( 'submit' ); + it( 'should not add a protocol when given provided within the value ' + + 'even when `config.link.defaultProtocol` configured', async () => { + const { editor, formView } = await createEditorWithLinkConfig( { defaultProtocol: 'http://' } ); + const linkCommandSpy = sinon.spy( editor.commands.get( 'link' ), 'execute' ); - sinon.assert.calledWith( linkCommandSpy, 'http://example.com', sinon.match.any ); + formView.urlInputView.fieldView.value = 'http://example.com'; + formView.fire( 'submit' ); - return editor.destroy(); - } ); + sinon.assert.calledWith( linkCommandSpy, 'http://example.com', sinon.match.any ); + + return editor.destroy(); } ); - it( 'should use the "http://" protocol when it\'s configured', () => { - return createEditorWithDefaultProtocol( 'http://' ).then( ( { editor, formView } ) => { - const linkCommandSpy = sinon.spy( editor.commands.get( 'link' ), 'execute' ); + it( 'should use the "http://" protocol when it\'s configured', async () => { + const { editor, formView } = await createEditorWithLinkConfig( { defaultProtocol: 'http://' } ); + const linkCommandSpy = sinon.spy( editor.commands.get( 'link' ), 'execute' ); - formView.urlInputView.fieldView.value = 'ckeditor.com'; - formView.fire( 'submit' ); + formView.urlInputView.fieldView.value = 'ckeditor.com'; + formView.fire( 'submit' ); - sinon.assert.calledWith( linkCommandSpy, 'http://ckeditor.com', sinon.match.any ); + sinon.assert.calledWith( linkCommandSpy, 'http://ckeditor.com', sinon.match.any ); - return editor.destroy(); - } ); + return editor.destroy(); } ); - it( 'should use the "http://" protocol when it\'s configured and form input value contains "www."', () => { - return createEditorWithDefaultProtocol( 'http://' ).then( ( { editor, formView } ) => { - const linkCommandSpy = sinon.spy( editor.commands.get( 'link' ), 'execute' ); + it( 'should use the "http://" protocol when it\'s configured and form input value contains "www."', async () => { + const { editor, formView } = await createEditorWithLinkConfig( { defaultProtocol: 'http://' } ); + const linkCommandSpy = sinon.spy( editor.commands.get( 'link' ), 'execute' ); - formView.urlInputView.fieldView.value = 'www.ckeditor.com'; - formView.fire( 'submit' ); + formView.urlInputView.fieldView.value = 'www.ckeditor.com'; + formView.fire( 'submit' ); - sinon.assert.calledWith( linkCommandSpy, 'http://www.ckeditor.com', sinon.match.any ); + sinon.assert.calledWith( linkCommandSpy, 'http://www.ckeditor.com', sinon.match.any ); - return editor.destroy(); - } ); + return editor.destroy(); } ); - it( 'should propagate the protocol to the link\'s `linkHref` attribute in model', () => { - return createEditorWithDefaultProtocol( 'http://' ).then( ( { editor, formView } ) => { - setModelData( editor.model, '[ckeditor.com]' ); + it( 'should propagate the protocol to the link\'s `linkHref` attribute in model', async () => { + const { editor, formView } = await createEditorWithLinkConfig( { defaultProtocol: 'http://' } ); - formView.urlInputView.fieldView.value = 'ckeditor.com'; - formView.fire( 'submit' ); + setModelData( editor.model, '[ckeditor.com]' ); - expect( getModelData( editor.model ) ).to.equal( - '[<$text linkHref="http://ckeditor.com">ckeditor.com]' - ); + formView.urlInputView.fieldView.value = 'ckeditor.com'; + formView.fire( 'submit' ); - return editor.destroy(); - } ); + expect( getModelData( editor.model ) ).to.equal( + '[<$text linkHref="http://ckeditor.com">ckeditor.com]' + ); + + return editor.destroy(); } ); - it( 'should detect an email on submitting the form and add "mailto:" protocol automatically to the provided value', () => { - return createEditorWithDefaultProtocol( 'http://' ).then( ( { editor, formView } ) => { - setModelData( editor.model, '[email@example.com]' ); + it( 'should detect an email on submitting the form and add "mailto:" ' + + 'protocol automatically to the provided value', async () => { + const { editor, formView } = await createEditorWithLinkConfig( { defaultProtocol: 'http://' } ); - formView.urlInputView.fieldView.value = 'email@example.com'; - formView.fire( 'submit' ); + setModelData( editor.model, '[email@example.com]' ); - expect( getModelData( editor.model ) ).to.equal( - '[<$text linkHref="mailto:email@example.com">email@example.com]' - ); + formView.urlInputView.fieldView.value = 'email@example.com'; + formView.fire( 'submit' ); - return editor.destroy(); - } ); + expect( getModelData( editor.model ) ).to.equal( + '[<$text linkHref="mailto:email@example.com">email@example.com]' + ); + + return editor.destroy(); } ); it( 'should detect an email on submitting the form and add "mailto:" protocol automatically to the provided value ' + @@ -2047,22 +2062,21 @@ describe( 'LinkUI', () => { formView.fire( 'submit' ); expect( getModelData( editor.model ) ).to.equal( - '[<$text linkDecorator2="true" linkHref="mailto:email@example.com">email@example.com]' + '[<$text linkHref="mailto:email@example.com">email@example.com]' ); } ); it( 'should not add an email protocol when given provided within the value ' + - 'even when `config.link.defaultProtocol` configured', () => { - return createEditorWithDefaultProtocol( 'mailto:' ).then( ( { editor, formView } ) => { - const linkCommandSpy = sinon.spy( editor.commands.get( 'link' ), 'execute' ); + 'even when `config.link.defaultProtocol` configured', async () => { + const { editor, formView } = await createEditorWithLinkConfig( { defaultProtocol: 'mailto:' } ); + const linkCommandSpy = sinon.spy( editor.commands.get( 'link' ), 'execute' ); - formView.urlInputView.fieldView.value = 'mailto:test@example.com'; - formView.fire( 'submit' ); + formView.urlInputView.fieldView.value = 'mailto:test@example.com'; + formView.fire( 'submit' ); - sinon.assert.calledWith( linkCommandSpy, 'mailto:test@example.com', sinon.match.any ); + sinon.assert.calledWith( linkCommandSpy, 'mailto:test@example.com', sinon.match.any ); - return editor.destroy(); - } ); + return editor.destroy(); } ); } ); @@ -2071,33 +2085,404 @@ describe( 'LinkUI', () => { setModelData( editor.model, 'f[o]o' ); } ); - it( 'should bind formView.urlInputView#value to link command value', () => { - const command = editor.commands.get( 'link' ); + it( 'should populate form on open on collapsed selection in text', () => { + setModelData( editor.model, 'fo[]o' ); + + const executeSpy = testUtils.sinon.spy( editor, 'execute' ); + + linkUIFeature._showUI(); // FormView + + expect( formView.urlInputView.fieldView.value ).to.equal( '' ); + expect( formView.displayedTextInputView.fieldView.value ).to.equal( '' ); + expect( formView.displayedTextInputView.isEnabled ).to.be.true; + + formView.urlInputView.fieldView.value = 'http://ckeditor.com'; + formView.displayedTextInputView.fieldView.value = 'CKEditor 5'; + + expect( formView.urlInputView.fieldView.value ).to.equal( 'http://ckeditor.com' ); + expect( formView.displayedTextInputView.fieldView.value ).to.equal( 'CKEditor 5' ); + + formView.fire( 'submit' ); + + expect( executeSpy.calledOnce ).to.be.true; + expect( executeSpy.calledWithExactly( + 'link', + 'http://ckeditor.com', + {}, + 'CKEditor 5' + ) ).to.be.true; + + expect( getModelData( editor.model ) ).to.equal( + 'fo<$text linkHref="http://ckeditor.com">CKEditor 5[]o' + ); + } ); + + it( 'should populate form on open on collapsed selection in text (without providing displayed text)', () => { + setModelData( editor.model, 'fo[]o' ); + + const executeSpy = testUtils.sinon.spy( editor, 'execute' ); + + linkUIFeature._showUI(); // FormView + + expect( formView.urlInputView.fieldView.value ).to.equal( '' ); + expect( formView.displayedTextInputView.fieldView.value ).to.equal( '' ); + expect( formView.displayedTextInputView.isEnabled ).to.be.true; + + formView.urlInputView.fieldView.value = 'http://ckeditor.com'; + + expect( formView.urlInputView.fieldView.value ).to.equal( 'http://ckeditor.com' ); + expect( formView.displayedTextInputView.fieldView.value ).to.equal( '' ); + + formView.fire( 'submit' ); + + expect( executeSpy.calledOnce ).to.be.true; + expect( executeSpy.calledWithExactly( + 'link', + 'http://ckeditor.com', + {}, + undefined + ) ).to.be.true; + + expect( getModelData( editor.model ) ).to.equal( + 'fo<$text linkHref="http://ckeditor.com">http://ckeditor.com[]o' + ); + } ); + + it( 'should populate form on open on non-collapsed selection in text', () => { + setModelData( editor.model, 'f[o]o' ); + + const executeSpy = testUtils.sinon.spy( editor, 'execute' ); + + linkUIFeature._showUI(); // FormView + + expect( formView.urlInputView.fieldView.value ).to.equal( '' ); + expect( formView.displayedTextInputView.fieldView.value ).to.equal( 'o' ); + expect( formView.displayedTextInputView.isEnabled ).to.be.true; + + formView.urlInputView.fieldView.value = 'http://ckeditor.com'; + formView.displayedTextInputView.fieldView.value = 'CKEditor 5'; + + expect( formView.urlInputView.fieldView.value ).to.equal( 'http://ckeditor.com' ); + expect( formView.displayedTextInputView.fieldView.value ).to.equal( 'CKEditor 5' ); + + formView.fire( 'submit' ); + + expect( executeSpy.calledOnce ).to.be.true; + expect( executeSpy.calledWithExactly( + 'link', + 'http://ckeditor.com', + {}, + 'CKEditor 5' + ) ).to.be.true; + + expect( getModelData( editor.model ) ).to.equal( + 'f[<$text linkHref="http://ckeditor.com">CKEditor 5]o' + ); + } ); + + it( 'should populate form on open on non-collapsed selection in text (without providing displayed text)', () => { + setModelData( editor.model, 'f[o]o' ); + + const executeSpy = testUtils.sinon.spy( editor, 'execute' ); + + linkUIFeature._showUI(); // FormView + + expect( formView.urlInputView.fieldView.value ).to.equal( '' ); + expect( formView.displayedTextInputView.fieldView.value ).to.equal( 'o' ); + expect( formView.displayedTextInputView.isEnabled ).to.be.true; - expect( formView.urlInputView.fieldView.value ).to.be.undefined; + formView.urlInputView.fieldView.value = 'http://ckeditor.com'; + + expect( formView.urlInputView.fieldView.value ).to.equal( 'http://ckeditor.com' ); + expect( formView.displayedTextInputView.fieldView.value ).to.equal( 'o' ); + + formView.fire( 'submit' ); + + expect( executeSpy.calledOnce ).to.be.true; + expect( executeSpy.calledWithExactly( + 'link', + 'http://ckeditor.com', + {}, + undefined + ) ).to.be.true; + + expect( getModelData( editor.model ) ).to.equal( + 'f[<$text linkHref="http://ckeditor.com">o]o' + ); + } ); + + it( 'should populate form on open on collapsed selection in link', () => { + setModelData( editor.model, 'fo<$text linkHref="abc">o[]bar' ); + + const executeSpy = testUtils.sinon.spy( editor, 'execute' ); + + linkUIFeature._showUI(); // ToolbarView + linkUIFeature._showUI(); // FormView + + expect( formView.urlInputView.fieldView.value ).to.equal( 'abc' ); + expect( formView.displayedTextInputView.fieldView.value ).to.equal( 'ob' ); + expect( formView.displayedTextInputView.isEnabled ).to.be.true; + + formView.urlInputView.fieldView.value = 'http://ckeditor.com'; + formView.displayedTextInputView.fieldView.value = 'CKEditor 5'; + + expect( formView.urlInputView.fieldView.value ).to.equal( 'http://ckeditor.com' ); + expect( formView.displayedTextInputView.fieldView.value ).to.equal( 'CKEditor 5' ); + + formView.fire( 'submit' ); + + expect( executeSpy.calledOnce ).to.be.true; + expect( executeSpy.calledWithExactly( + 'link', + 'http://ckeditor.com', + {}, + 'CKEditor 5' + ) ).to.be.true; + + expect( getModelData( editor.model ) ).to.equal( + 'fo<$text linkHref="http://ckeditor.com">CKEditor 5[]ar' + ); + } ); + + it( 'should populate form on open on collapsed selection in link (without providing displayed text)', () => { + setModelData( editor.model, 'fo<$text linkHref="abc">o[]bar' ); + + const executeSpy = testUtils.sinon.spy( editor, 'execute' ); + + linkUIFeature._showUI(); // ToolbarView + linkUIFeature._showUI(); // FormView + + expect( formView.urlInputView.fieldView.value ).to.equal( 'abc' ); + expect( formView.displayedTextInputView.fieldView.value ).to.equal( 'ob' ); + expect( formView.displayedTextInputView.isEnabled ).to.be.true; + + formView.urlInputView.fieldView.value = 'http://ckeditor.com'; + + expect( formView.urlInputView.fieldView.value ).to.equal( 'http://ckeditor.com' ); + expect( formView.displayedTextInputView.fieldView.value ).to.equal( 'ob' ); + + formView.fire( 'submit' ); + + expect( executeSpy.calledOnce ).to.be.true; + expect( executeSpy.calledWithExactly( + 'link', + 'http://ckeditor.com', + {}, + undefined + ) ).to.be.true; + + expect( getModelData( editor.model ) ).to.equal( + 'fo<$text linkHref="http://ckeditor.com">o[]bar' + ); + } ); + + it( 'should populate form on open on non-collapsed selection in link', () => { + setModelData( editor.model, 'fo<$text linkHref="abc">[ob]ar' ); + + const executeSpy = testUtils.sinon.spy( editor, 'execute' ); + + linkUIFeature._showUI(); // ToolbarView + linkUIFeature._showUI(); // FormView + + expect( formView.urlInputView.fieldView.value ).to.equal( 'abc' ); + expect( formView.displayedTextInputView.fieldView.value ).to.equal( 'ob' ); + expect( formView.displayedTextInputView.isEnabled ).to.be.true; + + formView.urlInputView.fieldView.value = 'http://ckeditor.com'; + formView.displayedTextInputView.fieldView.value = 'CKEditor 5'; + + expect( formView.urlInputView.fieldView.value ).to.equal( 'http://ckeditor.com' ); + expect( formView.displayedTextInputView.fieldView.value ).to.equal( 'CKEditor 5' ); + + formView.fire( 'submit' ); + + expect( executeSpy.calledOnce ).to.be.true; + expect( executeSpy.calledWithExactly( + 'link', + 'http://ckeditor.com', + {}, + 'CKEditor 5' + ) ).to.be.true; + + expect( getModelData( editor.model ) ).to.equal( + 'fo[<$text linkHref="http://ckeditor.com">CKEditor 5]ar' + ); + } ); + + it( 'should populate form on open on non-collapsed selection in link (without providing displayed text)', () => { + setModelData( editor.model, 'fo<$text linkHref="abc">[ob]ar' ); + + const executeSpy = testUtils.sinon.spy( editor, 'execute' ); + + linkUIFeature._showUI(); // ToolbarView + linkUIFeature._showUI(); // FormView + + expect( formView.urlInputView.fieldView.value ).to.equal( 'abc' ); + expect( formView.displayedTextInputView.fieldView.value ).to.equal( 'ob' ); + expect( formView.displayedTextInputView.isEnabled ).to.be.true; + + formView.urlInputView.fieldView.value = 'http://ckeditor.com'; + + expect( formView.urlInputView.fieldView.value ).to.equal( 'http://ckeditor.com' ); + expect( formView.displayedTextInputView.fieldView.value ).to.equal( 'ob' ); + + formView.fire( 'submit' ); + + expect( executeSpy.calledOnce ).to.be.true; + expect( executeSpy.calledWithExactly( + 'link', + 'http://ckeditor.com', + {}, + undefined + ) ).to.be.true; + + expect( getModelData( editor.model ) ).to.equal( + 'fo[<$text linkHref="http://ckeditor.com">ob]ar' + ); + } ); + + it( 'should populate form on open on collapsed selection in link with text matching href', () => { + setModelData( editor.model, + 'fo<$text linkHref="http://cksource.com">http://ck[]source.comar' + ); + + const executeSpy = testUtils.sinon.spy( editor, 'execute' ); + + linkUIFeature._showUI(); // ToolbarView + linkUIFeature._showUI(); // FormView - command.value = 'http://cksource.com'; expect( formView.urlInputView.fieldView.value ).to.equal( 'http://cksource.com' ); + expect( formView.displayedTextInputView.fieldView.value ).to.equal( 'http://cksource.com' ); + expect( formView.displayedTextInputView.isEnabled ).to.be.true; + + formView.urlInputView.fieldView.value = 'http://ckeditor.com'; + + expect( formView.urlInputView.fieldView.value ).to.equal( 'http://ckeditor.com' ); + expect( formView.displayedTextInputView.fieldView.value ).to.equal( 'http://cksource.com' ); + + formView.fire( 'submit' ); + + expect( executeSpy.calledOnce ).to.be.true; + expect( executeSpy.calledWithExactly( + 'link', + 'http://ckeditor.com', + {}, + undefined + ) ).to.be.true; + + expect( getModelData( editor.model ) ).to.equal( + 'fo<$text linkHref="http://ckeditor.com">http://ckeditor.com[]ar' + ); } ); - it( 'should execute link command on formView#submit event', () => { + it( 'should populate form on open on collapsed selection in link with text matching href but styled', () => { + setModelData( editor.model, + '' + + 'fo' + + '<$text linkHref="http://cksource.com">htt[]p://' + + '<$text linkHref="http://cksource.com" bold="true">cksource.com' + + 'ar' + + '' + ); + const executeSpy = testUtils.sinon.spy( editor, 'execute' ); + linkUIFeature._showUI(); // ToolbarView + linkUIFeature._showUI(); // FormView + + expect( formView.urlInputView.fieldView.value ).to.equal( 'http://cksource.com' ); + expect( formView.displayedTextInputView.fieldView.value ).to.equal( 'http://cksource.com' ); + expect( formView.displayedTextInputView.isEnabled ).to.be.true; + formView.urlInputView.fieldView.value = 'http://ckeditor.com'; + expect( formView.urlInputView.fieldView.value ).to.equal( 'http://ckeditor.com' ); + expect( formView.displayedTextInputView.fieldView.value ).to.equal( 'http://cksource.com' ); - formView.urlInputView.fieldView.value = 'http://cksource.com'; formView.fire( 'submit' ); expect( executeSpy.calledOnce ).to.be.true; - expect( executeSpy.calledWithExactly( 'link', 'http://cksource.com', { - linkDecorator1: false, - linkDecorator2: true, - linkDecorator3: false - } ) ).to.be.true; + expect( executeSpy.calledWithExactly( + 'link', + 'http://ckeditor.com', + {}, + undefined + ) ).to.be.true; + + expect( getModelData( editor.model ) ).to.equal( + 'fo<$text linkHref="http://ckeditor.com">http://ckeditor.com[]ar' + ); } ); - it( 'should should clear the fake visual selection on formView#submit event', () => { + it( 'should populate form on open on collapsed selection in link with text matching href but styled' + + 'and update text', () => { + setModelData( editor.model, + '' + + 'fo' + + '<$text linkHref="http://cksource.com">htt[]p://' + + '<$text linkHref="http://cksource.com" bold="true">cksource.com' + + 'ar' + + '' + ); + + const executeSpy = testUtils.sinon.spy( editor, 'execute' ); + + linkUIFeature._showUI(); // ToolbarView + linkUIFeature._showUI(); // FormView + + expect( formView.urlInputView.fieldView.value ).to.equal( 'http://cksource.com' ); + expect( formView.displayedTextInputView.fieldView.value ).to.equal( 'http://cksource.com' ); + expect( formView.displayedTextInputView.isEnabled ).to.be.true; + + formView.displayedTextInputView.fieldView.value = 'CKSource'; + + expect( formView.urlInputView.fieldView.value ).to.equal( 'http://cksource.com' ); + expect( formView.displayedTextInputView.fieldView.value ).to.equal( 'CKSource' ); + + formView.fire( 'submit' ); + + expect( executeSpy.calledOnce ).to.be.true; + expect( executeSpy.calledWithExactly( + 'link', + 'http://cksource.com', + {}, + 'CKSource' + ) ).to.be.true; + + expect( getModelData( editor.model ) ).to.equal( + 'fo<$text linkHref="http://cksource.com">CKSource[]ar' + ); + } ); + + it( 'should disable displayed text field on multi block select', () => { + setModelData( editor.model, + 'f[oo' + + 'ba]r' + ); + + linkUIFeature._showUI(); // ToolbarView + linkUIFeature._showUI(); // FormView + + expect( formView.urlInputView.fieldView.value ).to.equal( '' ); + expect( formView.displayedTextInputView.fieldView.value ).to.equal( '' ); + expect( formView.displayedTextInputView.isEnabled ).to.be.false; + } ); + + it( 'should disable displayed text field if it can not be modified as a plain text', () => { + linkUIFeature.selectedLinkableText = undefined; + expect( formView.displayedTextInputView.isEnabled ).to.be.false; + + linkUIFeature.selectedLinkableText = ''; + expect( formView.displayedTextInputView.isEnabled ).to.be.true; + + linkUIFeature.selectedLinkableText = 'foo'; + expect( formView.displayedTextInputView.isEnabled ).to.be.true; + + linkUIFeature.selectedLinkableText = undefined; + expect( formView.displayedTextInputView.isEnabled ).to.be.false; + } ); + + it( 'should clear the fake visual selection on formView#submit event', () => { linkUIFeature._showUI(); expect( editor.model.markers.has( 'link-ui' ) ).to.be.true; @@ -2181,67 +2566,65 @@ describe( 'LinkUI', () => { } ); describe( 'support manual decorators', () => { - let editorElement, editor, model, formView, advancedView, linkUIFeature; + let editorElement, editor, model, formView, propertiesView, linkUIFeature; - beforeEach( () => { + beforeEach( async () => { editorElement = document.createElement( 'div' ); document.body.appendChild( editorElement ); - return ClassicTestEditor - .create( editorElement, { - plugins: [ LinkEditing, LinkUI, Paragraph ], - link: { - decorators: { - decorator1: { - mode: 'manual', - label: 'Foo', - attributes: { - foo: 'bar' - } - }, - decorator2: { - mode: 'manual', - label: 'Download', - attributes: { - download: 'download' - }, - defaultValue: true + + editor = await ClassicTestEditor.create( editorElement, { + plugins: [ LinkEditing, LinkUI, Paragraph ], + link: { + decorators: { + decorator1: { + mode: 'manual', + label: 'Foo', + attributes: { + foo: 'bar' + } + }, + decorator2: { + mode: 'manual', + label: 'Download', + attributes: { + download: 'download' }, - decorator3: { - mode: 'manual', - label: 'Multi', - attributes: { - class: 'fancy-class', - target: '_blank', - rel: 'noopener noreferrer' - } + defaultValue: true + }, + decorator3: { + mode: 'manual', + label: 'Multi', + attributes: { + class: 'fancy-class', + target: '_blank', + rel: 'noopener noreferrer' } } } - } ) - .then( newEditor => { - editor = newEditor; - model = editor.model; + } + } ); - model.schema.extend( '$text', { - allowIn: '$root', - allowAttributes: 'linkHref' - } ); + model = editor.model; - linkUIFeature = editor.plugins.get( LinkUI ); - linkUIFeature._createViews(); + model.schema.extend( '$text', { + allowIn: '$root', + allowAttributes: 'linkHref' + } ); - const balloon = editor.plugins.get( ContextualBalloon ); + linkUIFeature = editor.plugins.get( LinkUI ); + linkUIFeature._createViews(); - formView = linkUIFeature.formView; - advancedView = linkUIFeature.advancedView; + const 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( {} ); + formView = linkUIFeature.formView; + propertiesView = linkUIFeature.propertiesView; - formView.render(); - advancedView.render(); - } ); + // 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(); + propertiesView.render(); } ); afterEach( () => { @@ -2253,7 +2636,12 @@ describe( 'LinkUI', () => { const executeSpy = testUtils.sinon.spy( editor, 'execute' ); setModelData( model, 'f[<$text linkHref="url" linkDecorator1="true">ooba]r' ); + + linkUIFeature._showUI( true ); // ToolbarView + linkUIFeature._showUI( true ); // FormView + expect( formView.urlInputView.fieldView.element.value ).to.equal( 'url' ); + expect( formView.displayedTextInputView.fieldView.value ).to.equal( 'ooba' ); expect( linkUIFeature._getDecoratorSwitchesState() ).to.deep.equal( { linkDecorator1: true, linkDecorator2: false, @@ -2261,83 +2649,75 @@ describe( 'LinkUI', () => { } ); // Switch the first decorator on. - advancedView.listChildren.get( 1 ).fire( 'execute' ); - formView.fire( 'submit' ); + linkUIFeature._createPropertiesView(); + propertiesView.listChildren.get( 1 ).fire( 'execute' ); sinon.assert.calledOnce( executeSpy ); - sinon.assert.calledWithExactly( executeSpy, 'link', 'url', { - linkDecorator1: true, - linkDecorator2: true, - linkDecorator3: false - } ); + sinon.assert.calledWithExactly( + executeSpy, + 'link', + 'url', + { + linkDecorator1: true, + linkDecorator2: true, + linkDecorator3: false + } + ); } ); - it( 'should reset switch state when form view is closed', () => { + it( 'should keep switch state when form is closed', () => { setModelData( model, 'f[<$text linkHref="url" linkIsFoo="true">ooba]r' ); + linkUIFeature._createPropertiesView(); + const manualDecorators = editor.commands.get( 'link' ).manualDecorators; const firstDecoratorModel = manualDecorators.first; - const firstDecoratorSwitch = advancedView.listChildren.first; + const firstDecoratorSwitch = propertiesView.listChildren.first; 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.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.undefined; - expect( firstDecoratorSwitch.isOn, 'Close form view without submit resets value to initial state' ).to.be.false; + + expect( firstDecoratorModel.value ).to.be.true; + expect( firstDecoratorSwitch.isOn ).to.be.true; } ); it( 'switch buttons reflects state of manual decorators', () => { - expect( linkUIFeature.advancedView.listChildren.length ).to.equal( 3 ); + expect( linkUIFeature.propertiesView.listChildren.length ).to.equal( 3 ); - expect( linkUIFeature.advancedView.listChildren.get( 0 ) ).to.deep.include( { + expect( linkUIFeature.propertiesView.listChildren.get( 0 ) ).to.deep.include( { label: 'Foo', isOn: false } ); - expect( linkUIFeature.advancedView.listChildren.get( 1 ) ).to.deep.include( { + expect( linkUIFeature.propertiesView.listChildren.get( 1 ) ).to.deep.include( { label: 'Download', isOn: true } ); - expect( linkUIFeature.advancedView.listChildren.get( 2 ) ).to.deep.include( { + expect( linkUIFeature.propertiesView.listChildren.get( 2 ) ).to.deep.include( { label: 'Multi', isOn: false } ); } ); it( 'reacts on switch button changes', () => { + setModelData( model, 'f[<$text linkHref="url" linkDecorator1="true">ooba]r' ); + const linkCommand = editor.commands.get( 'link' ); const modelItem = linkCommand.manualDecorators.first; - const viewItem = linkUIFeature.advancedView.listChildren.first; - - expect( modelItem.value ).to.be.undefined; - expect( viewItem.isOn ).to.be.false; - - viewItem.element.dispatchEvent( new Event( 'click' ) ); + const viewItem = linkUIFeature.propertiesView.listChildren.first; expect( modelItem.value ).to.be.true; expect( viewItem.isOn ).to.be.true; viewItem.element.dispatchEvent( new Event( 'click' ) ); - expect( modelItem.value ).to.be.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' ) ); @@ -2348,21 +2728,23 @@ describe( 'LinkUI', () => { describe( '_getDecoratorSwitchesState()', () => { it( 'should provide object with decorators states', () => { + setModelData( model, 'f[<$text linkHref="url" linkDecorator1="true">ooba]r' ); + expect( linkUIFeature._getDecoratorSwitchesState() ).to.deep.equal( { - linkDecorator1: false, - linkDecorator2: true, + linkDecorator1: true, + linkDecorator2: false, linkDecorator3: false } ); - linkUIFeature.advancedView.listChildren.map( item => { + linkUIFeature.propertiesView.listChildren.map( item => { item.element.dispatchEvent( new Event( 'click' ) ); } ); - linkUIFeature.advancedView.listChildren.get( 2 ).element.dispatchEvent( new Event( 'click' ) ); + linkUIFeature.propertiesView.listChildren.get( 2 ).element.dispatchEvent( new Event( 'click' ) ); expect( linkUIFeature._getDecoratorSwitchesState() ).to.deep.equal( { - linkDecorator1: true, - linkDecorator2: false, + linkDecorator1: false, + linkDecorator2: true, linkDecorator3: false } ); } ); @@ -2434,57 +2816,53 @@ describe( 'LinkUI', () => { } ); } ); - 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; + describe( 'properties view', () => { + beforeEach( () => { + editor.commands.get( 'link' ).manualDecorators.add( new ManualDecorator( { + id: 'linkIsBar', + label: 'Bar', + attributes: { + target: '_blank' + } + } ) ); } ); - it( 'can be opened by clicking the settings button', () => { + it( 'can be closed by clicking the back 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 ); - } ); + linkUIFeature._addPropertiesView(); - it( 'can be closed by clicking the back button', () => { - const spy = sinon.spy(); + expect( balloon.visibleView ).to.equal( linkUIFeature.propertiesView ); - setModelData( editor.model, 'f[o]o' ); + linkUIFeature.listenTo( linkUIFeature.propertiesView, 'back', spy ); - linkUIFeature._showUI(); - linkUIFeature.listenTo( linkUIFeature.advancedView, 'back', spy ); - linkUIFeature.formView.settingsButtonView.fire( 'execute' ); - linkUIFeature.advancedView.backButtonView.fire( 'execute' ); + const removeBalloonSpy = sinon.spy( balloon, 'remove' ); + linkUIFeature.propertiesView.backButtonView.fire( 'execute' ); sinon.assert.calledOnce( spy ); - expect( balloon.visibleView ).to.equal( linkUIFeature.formView ); + expect( removeBalloonSpy ).to.be.calledWithExactly( linkUIFeature.propertiesView ); } ); it( 'can be closed by clicking the "esc" button', () => { setModelData( editor.model, 'f[o]o' ); linkUIFeature._showUI(); - linkUIFeature.formView.settingsButtonView.fire( 'execute' ); + linkUIFeature._addPropertiesView(); - linkUIFeature.advancedView.keystrokes.press( { + expect( balloon.visibleView ).to.equal( linkUIFeature.propertiesView ); + + const removeBalloonSpy = sinon.spy( balloon, 'remove' ); + + linkUIFeature.propertiesView.keystrokes.press( { keyCode: keyCodes.esc, preventDefault: sinon.spy(), stopPropagation: sinon.spy() } ); - expect( balloon.visibleView ).to.equal( linkUIFeature.formView ); + expect( removeBalloonSpy ).to.be.calledWithExactly( linkUIFeature.propertiesView ); } ); } ); } ); @@ -2494,24 +2872,20 @@ describe( 'LinkUI with Bookmark', () => { testUtils.createSinonSandbox(); - beforeEach( () => { + beforeEach( async () => { editorElement = document.createElement( 'div' ); document.body.appendChild( editorElement ); - return ClassicTestEditor - .create( editorElement, { - plugins: [ Essentials, LinkEditing, LinkUI, Paragraph, BlockQuote, Bookmark ] - } ) - .then( newEditor => { - editor = newEditor; + editor = await ClassicTestEditor.create( editorElement, { + plugins: [ Essentials, LinkEditing, LinkUI, Paragraph, BlockQuote, Bookmark ] + } ); - linkUIFeature = editor.plugins.get( LinkUI ); - balloon = editor.plugins.get( ContextualBalloon ); + 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( {} ); - } ); + // 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( () => { @@ -2600,6 +2974,31 @@ describe( 'LinkUI with Bookmark', () => { expect( linkUIFeature._balloon.visibleView ).to.be.equal( linkUIFeature.formView ); expect( focusSpy.calledOnce ).to.be.true; } ); + + it( 'should clear the error message that appears on first attempt of submit the form ' + + 'when next action is executed after clicking the bookmark button', () => { + linkUIFeature._createViews(); + const formView = linkUIFeature.formView; + formView.render(); + + setModelData( editor.model, '[foo]' ); + linkUIFeature._showUI(); + + formView.fire( 'submit' ); + + expect( formView.urlInputView.errorText ).to.be.equal( 'Link URL must not be empty.' ); + // 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; + + expect( formView.urlInputView.errorText ).to.be.null; + } ); } ); } ); diff --git a/packages/ckeditor5-link/tests/ui/linkformview.js b/packages/ckeditor5-link/tests/ui/linkformview.js index c80b7334bcc..05cafe75d41 100644 --- a/packages/ckeditor5-link/tests/ui/linkformview.js +++ b/packages/ckeditor5-link/tests/ui/linkformview.js @@ -37,7 +37,6 @@ describe( 'LinkFormView', () => { it( 'should create child views', () => { expect( view.backButtonView ).to.be.instanceOf( View ); - expect( view.settingsButtonView ).to.be.instanceOf( View ); expect( view.saveButtonView ).to.be.instanceOf( View ); expect( view.displayedTextInputView ).to.be.instanceOf( View ); expect( view.urlInputView ).to.be.instanceOf( View ); @@ -79,7 +78,6 @@ describe( 'LinkFormView', () => { * header * backButtonView * label - * settingsButtonView * div * displayedTextInputView * div @@ -100,7 +98,6 @@ describe( 'LinkFormView', () => { 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 ); } ); } ); @@ -112,7 +109,6 @@ describe( 'LinkFormView', () => { view.urlInputView, view.saveButtonView, view.backButtonView, - view.settingsButtonView, view.displayedTextInputView ] ); } ); @@ -126,8 +122,7 @@ describe( 'LinkFormView', () => { 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.backButtonView.element ); - sinon.assert.calledWithExactly( spy.getCall( 3 ), view.settingsButtonView.element ); - sinon.assert.calledWithExactly( spy.getCall( 4 ), view.displayedTextInputView.element ); + sinon.assert.calledWithExactly( spy.getCall( 3 ), view.displayedTextInputView.element ); view.destroy(); } ); @@ -325,8 +320,7 @@ describe( 'LinkFormView', () => { 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 ); + sinon.assert.calledWithExactly( spy.getCall( 4 ), view.displayedTextInputView.element ); view.destroy(); } ); diff --git a/packages/ckeditor5-link/tests/ui/linkadvancedview.js b/packages/ckeditor5-link/tests/ui/linkpropertiesview.js similarity index 96% rename from packages/ckeditor5-link/tests/ui/linkadvancedview.js rename to packages/ckeditor5-link/tests/ui/linkpropertiesview.js index 9586282ffb6..ba403b7638b 100644 --- a/packages/ckeditor5-link/tests/ui/linkadvancedview.js +++ b/packages/ckeditor5-link/tests/ui/linkpropertiesview.js @@ -18,12 +18,12 @@ import { SwitchButtonView } from '@ckeditor/ckeditor5-ui'; import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils.js'; -import LinkAdvancedView from '../../src/ui/linkadvancedview.js'; +import LinkPropertiesView from '../../src/ui/linkpropertiesview.js'; import ManualDecorator from '../../src/utils/manualdecorator.js'; const mockLocale = { t: val => val }; -describe( 'LinkAdvancedView', () => { +describe( 'LinkPropertiesView', () => { let view, collection, linkCommand, decorator1, decorator2, decorator3; testUtils.createSinonSandbox(); @@ -64,7 +64,7 @@ describe( 'LinkAdvancedView', () => { decorator3 ] ); - view = new LinkAdvancedView( mockLocale ); + view = new LinkPropertiesView( mockLocale ); view.listChildren.bindTo( collection ).using( decorator => { const button = new SwitchButtonView(); @@ -159,7 +159,7 @@ describe( 'LinkAdvancedView', () => { } ); it( 'starts listening for #keystrokes coming from #element', () => { - const view = new LinkAdvancedView( mockLocale, linkCommand ); + const view = new LinkPropertiesView( mockLocale, linkCommand ); const spy = sinon.spy( view.keystrokes, 'listenTo' ); view.render(); diff --git a/packages/ckeditor5-link/tests/utils.js b/packages/ckeditor5-link/tests/utils.js index 40373d6d8da..6dd59d7c8f7 100644 --- a/packages/ckeditor5-link/tests/utils.js +++ b/packages/ckeditor5-link/tests/utils.js @@ -6,6 +6,7 @@ /* global window */ import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor.js'; +import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor.js'; import ViewDocument from '@ckeditor/ckeditor5-engine/src/view/document.js'; import ViewDowncastWriter from '@ckeditor/ckeditor5-engine/src/view/downcastwriter.js'; import AttributeElement from '@ckeditor/ckeditor5-engine/src/view/attributeelement.js'; @@ -13,6 +14,10 @@ import ContainerElement from '@ckeditor/ckeditor5-engine/src/view/containereleme import Text from '@ckeditor/ckeditor5-engine/src/view/text.js'; import Schema from '@ckeditor/ckeditor5-engine/src/model/schema.js'; import ModelElement from '@ckeditor/ckeditor5-engine/src/model/element.js'; +import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph.js'; +import BoldEditing from '@ckeditor/ckeditor5-basic-styles/src/bold/boldediting.js'; +import { setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model.js'; + import { createLinkElement, isLinkElement, @@ -23,7 +28,8 @@ import { addLinkProtocolIfApplicable, openLink, isScrollableToTarget, - scrollToTarget + scrollToTarget, + extractTextFromLinkRange } from '../src/utils.js'; describe( 'utils', () => { @@ -472,4 +478,74 @@ describe( 'utils', () => { } ); } ); } ); + + describe( 'extractTextFromLinkRange()', () => { + let editor; + + beforeEach( async () => { + function InlineWidget( editor ) { + editor.model.schema.register( 'inlineWidget', { inheritAllFrom: '$inlineObject' } ); + editor.conversion.elementToElement( { + view: { name: 'span', class: 'foo' }, + model: 'inlineWidget' + } ); + } + + editor = await ModelTestEditor.create( { + plugins: [ Paragraph, InlineWidget, BoldEditing ] + } ); + } ); + + afterEach( async () => { + await editor.destroy(); + } ); + + it( 'should extract text from range', () => { + setModelData( editor.model, 'foo[bar]baz' ); + + const text = extractTextFromLinkRange( editor.model.document.selection.getFirstRange() ); + + expect( text ).to.equal( 'bar' ); + } ); + + it( 'should extract text from range even when split into multiple text nodes with different style', () => { + setModelData( editor.model, + '' + + 'abc[fo' + + '<$text bold="true">ob' + + 'ar]def' + + '' + ); + + expect( editor.model.document.getRoot().getChild( 0 ).childCount ).to.equal( 3 ); + + const text = extractTextFromLinkRange( editor.model.document.selection.getFirstRange() ); + + expect( text ).to.equal( 'foobar' ); + } ); + + it( 'should return undefined if range includes an inline object', () => { + setModelData( editor.model, 'foo[bar]baz' ); + + const text = extractTextFromLinkRange( editor.model.document.selection.getFirstRange() ); + + expect( text ).to.be.undefined; + } ); + + it( 'should return undefined if range is on an inline object', () => { + setModelData( editor.model, 'fooba[]rbaz' ); + + const text = extractTextFromLinkRange( editor.model.document.selection.getFirstRange() ); + + expect( text ).to.be.undefined; + } ); + + it( 'should return undefined if range is spanning multiple blocks', () => { + setModelData( editor.model, 'f[ooba]z' ); + + const text = extractTextFromLinkRange( editor.model.document.selection.getFirstRange() ); + + expect( text ).to.be.undefined; + } ); + } ); } ); diff --git a/scripts/clean-up-svg-icons.mjs b/scripts/clean-up-svg-icons.mjs index 2ec1aa6b511..a7f215cc44a 100644 --- a/scripts/clean-up-svg-icons.mjs +++ b/scripts/clean-up-svg-icons.mjs @@ -46,6 +46,7 @@ import { execSync } from 'child_process'; // A list of icons that should not NOT be cleaned up. Their internal structure should not be changed // because, for instance, CSS animations may depend on it. const EXCLUDED_ICONS = [ + 'settings.svg', 'return-arrow.svg', 'project-logo.svg' ];