Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Basic support for "Displayed text" input. #17469

Merged
merged 31 commits into from
Nov 27, 2024
Merged
Show file tree
Hide file tree
Changes from 27 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
77f122e
Feature (link): Add basic support for "Displayed text" input
filipsobol Oct 29, 2024
85ca0f5
Update
filipsobol Oct 30, 2024
bcc09dd
Update
filipsobol Oct 30, 2024
71714fd
Extended condition while updating the link content.
pszczesniak Oct 31, 2024
e62903a
Updated behaviour of showing and extracting the displayed text.
pszczesniak Nov 14, 2024
9ebe493
Code refactor.
pszczesniak Nov 14, 2024
07485d8
Added missing test.
pszczesniak Nov 14, 2024
526b3bf
Tests update.
pszczesniak Nov 14, 2024
9311634
Code refactor.
pszczesniak Nov 14, 2024
cc34e87
Code refactor. [skip ci]
pszczesniak Nov 14, 2024
6da0d24
Merge branch 'ck/epic/17230-linking-experience' into add-displayed-text
pszczesniak Nov 14, 2024
25e2a59
Fixed condition for 'canHaveDisplayedText' property depend on selection.
pszczesniak Nov 18, 2024
c1b5155
Code refactoring WiP.
niegowski Nov 22, 2024
4c34563
Fixed multiple replacements and restoring the document selection.
niegowski Nov 23, 2024
ec8376d
Updated link marker handling on link text replacement.
niegowski Nov 25, 2024
df04f48
Collecting selected/link text for displayed link text UI.
niegowski Nov 25, 2024
aae856a
Updated selected link text extraction.
niegowski Nov 25, 2024
57f0460
Updated tests.
niegowski Nov 25, 2024
f440fc0
Merge branch 'ck/epic/17230-linking-experience' into add-displayed-text
Mati365 Nov 26, 2024
7074b35
Selection should not be modified after link url change.
niegowski Nov 26, 2024
6df0890
Link UI should not update in RTC while open.
niegowski Nov 26, 2024
46f27d3
Removed obsolete tests.
niegowski Nov 26, 2024
3e6eecb
Adjusted tests.
niegowski Nov 26, 2024
6c5524e
Updated LinkUI tests.
niegowski Nov 26, 2024
473d31b
Added tests.
niegowski Nov 26, 2024
e09416f
Added test.
niegowski Nov 26, 2024
1c019da
Updated tests.
niegowski Nov 26, 2024
f17c9e9
Move link advanced section to separate link properties balloon toolba…
Mati365 Nov 26, 2024
d6e071d
Create properties view on demand.
Mati365 Nov 27, 2024
944ab1e
Merge pull request #17534 from ckeditor/ck/epic/17230-add-standalone-…
Mati365 Nov 27, 2024
224c646
Extend the API docs for linkcommand execute method for 'displayedText…
pszczesniak Nov 27, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 82 additions & 85 deletions packages/ckeditor5-link/src/linkcommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -138,10 +138,16 @@ export default class LinkCommand extends Command {
* @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.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to extend the API docs for the displayedText argument.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 224c646.

*/
public override execute( href: string, manualDecoratorIds: Record<string, boolean> = {} ): void {
public override execute(
href: string,
manualDecoratorIds: Record<string, boolean> = {},
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<string> = [];
const falsyManualDecorators: Array<string> = [];
Expand All @@ -155,32 +161,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: `<a href="http://ckeditor.com/">http://ckeditor.com/</a>`.
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 );
const linkHref = selection.getAttribute( 'linkHref' ) as string;
const linkRange = findAttributeRange( position, 'linkHref', linkHref, model );
const newLinkRange = updateLinkTextIfNeeded( linkRange, linkHref );

truthyManualDecorators.forEach( item => {
writer.setAttribute( item, true, linkRange );
} );

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.
Expand All @@ -194,22 +230,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 = [];
Expand All @@ -231,29 +264,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 );
} ) );
}
} );
}
Expand Down Expand Up @@ -294,41 +328,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;
}
}
Loading