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$text>]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$text>' );
@@ -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$text>]bar' );
+
+ expect( command.value ).to.equal( 'other url' );
+
+ command.execute( 'url', {}, 'xyz' );
+
+ expect( getData( model ) ).to.equal( 'fo[<$text linkHref="url">xyz$text>]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>' +
+ '' +
+ '' +
+ '<$text linkHref="foo">foo$text>' +
+ '' +
+ '' +
+ '<$text linkHref="foo">www$text>' +
+ '' +
+ 'baz]xyz'
+ );
+
+ command.execute( 'bar123' );
+
+ expect( getData( model ) ).to.equal(
+ '' +
+ 'abc[<$text linkHref="bar123">bar123$text>' +
+ '' +
+ '' +
+ '<$text linkHref="bar123">bar123$text>' +
+ '' +
+ '' +
+ '<$text linkHref="bar123">www$text>' +
+ '' +
+ '' +
+ '<$text linkHref="bar123">baz$text>]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>' +
+ '' +
+ '<$text linkHref="url">ba$text>]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$text>' );
command.execute( 'url' );
- expect( getData( model ) ).to.equal( '<$text linkHref="url">foobar$text>[]' );
+ expect( getData( model ) ).to.equal( '<$text linkHref="url">foo[]bar$text>' );
} );
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$text>[]bar' );
} );
+
+ it( 'should overwrite existing `linkHref` attribute and displayed text', () => {
+ setData( model, 'fo<$text linkHref="other url">o[]b$text>ar' );
+
+ expect( command.value ).to.equal( 'other url' );
+
+ command.execute( 'url', {}, 'xyz' );
+
+ expect( getData( model ) ).to.equal( 'fo<$text linkHref="url">xyz$text>[]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$text>[]r' );
+ .equal( 'f<$text linkHref="url" linkIsBar="true" linkIsFoo="true" linkIsSth="true">o[]oba$text>r' );
} );
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$text>[]bar' );
+ expect( getData( model ) ).to.equal( 'foo<$text linkHref="url">u[]rl$text>bar' );
} );
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$text>]' );
command.execute( 'url2', { linkIsFoo: true, linkIsBar: true, linkIsSth: true } );
@@ -807,30 +897,6 @@ describe( 'LinkCommand', () => {
expect( getData( model ) ).to
.equal( '[<$text linkHref="url2">url2$text>]' );
} );
-
- it( 'should not update link which is equal its href if selection is on more than one element', () => {
- setData( model,
- '' +
- '<$text linkHref="foo">[foo$text>' +
- '' +
- 'bar' +
- 'baz]'
- );
-
- command.execute( 'foooo' );
-
- expect( getData( model ) ).to
- .equal( '' +
- '[<$text linkHref="foooo">foo$text>' +
- '' +
- '' +
- '<$text linkHref="foooo">bar$text>' +
- '' +
- '' +
- '<$text linkHref="foooo">baz$text>]' +
- ''
- );
- } );
} );
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$text>]'
- );
+ 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$text>]'
+ );
+
+ 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$text>]'
- );
+ 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$text>]'
+ );
+
+ 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>]'
+ '[<$text linkHref="mailto:email@example.com">email@example.com$text>]'
);
} );
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$text>[]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$text>[]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$text>]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$text>]o'
+ );
+ } );
+
+ it( 'should populate form on open on collapsed selection in link', () => {
+ setModelData( editor.model, 'fo<$text linkHref="abc">o[]b$text>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$text>[]ar'
+ );
+ } );
+
+ it( 'should populate form on open on collapsed selection in link (without providing displayed text)', () => {
+ setModelData( editor.model, 'fo<$text linkHref="abc">o[]b$text>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">o[]b$text>ar'
+ );
+ } );
+
+ it( 'should populate form on open on non-collapsed selection in link', () => {
+ setModelData( editor.model, 'fo<$text linkHref="abc">[ob]$text>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$text>]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]$text>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$text>]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.com$text>ar'
+ );
+
+ 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$text>[]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>' +
+ '<$text linkHref="http://cksource.com" bold="true">cksource.com$text>' +
+ '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$text>[]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>' +
+ '<$text linkHref="http://cksource.com" bold="true">cksource.com$text>' +
+ '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$text>[]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$text>]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$text>]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$text>]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$text>]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$text>' +
+ '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'
];