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

13985 - Resolve Link plugin manual decorators quirks #13988

Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
143 changes: 126 additions & 17 deletions packages/ckeditor5-link/src/linkcommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,13 @@ import { Collection, first, toMap } from 'ckeditor5/src/utils';
import type { Range, DocumentSelection, Model, Writer } from 'ckeditor5/src/engine';

import AutomaticDecorators from './utils/automaticdecorators';
import { isLinkableElement } from './utils';
import {
getLinkAttributeName,
isLinkableElement,
htmlAttributeNameToModelAttributeName,
mergeMultiValueAttributes,
modelAttributeNameToHtmlAttributeName
} from './utils';
import type ManualDecorator from './utils/manualdecorator';

/**
Expand Down Expand Up @@ -74,6 +80,55 @@ export default class LinkCommand extends Command {
}
}

/**
* Merges the decorators
* @param truthyManualDecorators decorators whose state value is true
* @param falsyManualDecorators decorators whose state value is falsy
* @returns
*/
private _mergeAttributes(
truthyManualDecorators: Array<string>,
falsyManualDecorators: Array<string> ):
{ attributesToBeAdded: Array<[string, string]>; attributesToBeRemoved: Array<string> } {
// attributes to be added or updated
const attributesToBeAdded: Array<[string, string]> =
truthyManualDecorators.reduce<Array<[string, string]>>(
( attrsToAdd, decoratorName ) => {
// can never null since it's a reduce on truthyManualDecorators
const manualDecorator =
this.manualDecorators.get( decoratorName ) as ManualDecorator;
const decoratorAttributes = Object.entries(
manualDecorator.attributes ?? {}
);
return mergeMultiValueAttributes(
attrsToAdd,
decoratorAttributes
);
},
[]
);

// attributes exclusively to be removed
const attributesToBeRemoved: Array<string> = falsyManualDecorators
.reduce<Array<string>>( ( attrsToRemove, decoratorName ) => {
// can never null since it's a reduce on falsyManualDecorators
const manualDecorator = this.manualDecorators.get( decoratorName ) as ManualDecorator;
const decoratorAttributes = Object.keys(
manualDecorator.attributes ?? {}
);
return [ ...attrsToRemove, ...decoratorAttributes ];
}, [] )
.filter(
attributeName =>
attributesToBeAdded.findIndex(
( [ name ] ) => name === attributeName
) === -1
)
.map( htmlAttributeNameToModelAttributeName );

return { attributesToBeAdded, attributesToBeRemoved };
}

/**
* Executes the command.
*
Expand Down Expand Up @@ -171,13 +226,26 @@ export default class LinkCommand extends Command {

writer.setAttribute( 'linkHref', href, linkRange );

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

falsyManualDecorators.forEach( item => {
writer.removeAttribute( item, linkRange );
} );
const { attributesToBeAdded, attributesToBeRemoved } = this._mergeAttributes(
truthyManualDecorators,
falsyManualDecorators
);

// 2. add attributes in attributesToBeAdde
writer.setAttributes(
Object.fromEntries(
attributesToBeAdded.map( ( [ attr, value ] ) => [
htmlAttributeNameToModelAttributeName( attr ),
value
] )
),
linkRange
);

for ( const attributeName of attributesToBeRemoved ) {
const linkAttributeName = getLinkAttributeName( attributeName );
writer.removeAttribute( linkAttributeName, linkRange );
}

// Put the selection at the end of the updated link.
writer.setSelection( writer.createPositionAfter( linkRange.end.nodeBefore! ) );
Expand Down Expand Up @@ -246,13 +314,26 @@ export default class LinkCommand extends Command {

writer.setAttribute( 'linkHref', href, linkRange );

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

falsyManualDecorators.forEach( item => {
writer.removeAttribute( item, linkRange );
} );
const { attributesToBeAdded, attributesToBeRemoved } = this._mergeAttributes(
truthyManualDecorators,
falsyManualDecorators
);

// 2. add attributes in attributesToBeAdde
writer.setAttributes(
Object.fromEntries(
attributesToBeAdded.map( ( [ attr, value ] ) => [
htmlAttributeNameToModelAttributeName( attr ),
value
] )
),
linkRange
);

for ( const attributeName of attributesToBeRemoved ) {
const linkAttributeName = getLinkAttributeName( attributeName );
writer.removeAttribute( linkAttributeName, linkRange );
}
}
}
} );
Expand All @@ -264,18 +345,46 @@ export default class LinkCommand extends Command {
* @param decoratorName The name of the manual decorator used in the model
* @returns The information whether a given decorator is currently present in the selection.
*/
private _getDecoratorStateFromModel( decoratorName: string ): boolean | undefined {
/**
* Provides information whether a decorator with a given name is present in the currently processed selection.
*
* @param decoratorName The name of the manual decorator used in the model
* @returns The information whether a given decorator is currently present in the selection.
*/
private _getDecoratorStateFromModel(
decoratorName: string
): boolean | undefined {
const model = this.editor.model;
const selection = model.document.selection;
const selectedElement = selection.getSelectedElement();
// manualDecorator can never be null
const manualDecorator = this.manualDecorators.get( decoratorName ) as ManualDecorator;
const decoratorAttributes = manualDecorator.attributes ?? {};

// A check for the `LinkImage` plugin. If the selection contains an element, get values from the element.
// Currently the selection reads attributes from text nodes only. See #7429 and #7465.
if ( isLinkableElement( selectedElement, model.schema ) ) {
return selectedElement.getAttribute( decoratorName ) as boolean | undefined;
}
// TODO 1. get decorator by name
// 2 Check attributes one by one (maybe use hasAttribute?)
// 3 return as boolean (AND?)
const attributes = Array.from( selection.getAttributes() ).filter(
( [ ename ] ) => ename !== 'linkHref' // skip href special case
) as Array<[string, string]>;
return attributes.reduce<boolean | undefined>(
( state, [ selectionAttrName, selectionAttrValue ] ) => {
const decoratorAttrName =
modelAttributeNameToHtmlAttributeName( selectionAttrName );
const decoratorAttrValue = decoratorAttributes[ decoratorAttrName ];
if ( typeof decoratorAttrValue === 'string' ) {
state = selectionAttrValue.includes( decoratorAttrValue );
}

return selection.getAttribute( decoratorName ) as boolean | undefined;
return state;
},
undefined
);
}

/**
Expand Down
81 changes: 74 additions & 7 deletions packages/ckeditor5-link/src/linkediting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ import ManualDecorator from './utils/manualdecorator';
import {
createLinkElement,
ensureSafeUrl,
ensureSafeRel,
ensureSafeTarget,
getLocalizedDecorators,
normalizeDecorators,
openLink,
Expand Down Expand Up @@ -98,15 +100,53 @@ export default class LinkEditing extends Plugin {
const editor = this.editor;

// Allow link attribute on all inline nodes.
editor.model.schema.extend( '$text', { allowAttributes: 'linkHref' } );
editor.model.schema.extend( '$text', { allowAttributes: [
'linkHref',
// 'linkHreflang',
// 'linkPing',
// 'linkReferrerpolicy',
'linkRel',
'linkTarget'
] } );

editor.conversion.for( 'dataDowncast' )
.attributeToElement( { model: 'linkHref', view: createLinkElement } );
.attributeToElement( {
model: 'linkHref',
view: ( href, conversionApi ) =>
createLinkElement( { href }, conversionApi )
} )
.attributeToElement( {
model: 'linkRel',
view: ( rel, conversionApi ) => createLinkElement( { rel }, conversionApi )
} )
.attributeToElement( {
model: 'linkTarget',
view: ( target, conversionApi ) =>
createLinkElement( { target }, conversionApi )
} );

editor.conversion.for( 'editingDowncast' )
.attributeToElement( { model: 'linkHref', view: ( href, conversionApi ) => {
return createLinkElement( ensureSafeUrl( href ), conversionApi );
} } );
.attributeToElement( {
model: 'linkHref',
view: ( hrefValue, conversionApi ) => {
const href = ensureSafeUrl( hrefValue );
return createLinkElement( { href }, conversionApi );
}
} )
.attributeToElement( {
model: 'linkRel',
view: ( relValue, conversionApi ) => {
const rel = ensureSafeRel( relValue );
return createLinkElement( { rel }, conversionApi );
}
} )
.attributeToElement( {
model: 'linkTarget',
view: ( targetValue, conversionApi ) => {
const target = ensureSafeTarget( targetValue );
return createLinkElement( { target }, conversionApi );
}
} );

editor.conversion.for( 'upcast' )
.elementToAttribute( {
Expand All @@ -118,7 +158,34 @@ export default class LinkEditing extends Plugin {
},
model: {
key: 'linkHref',
value: ( viewElement: ViewElement ) => viewElement.getAttribute( 'href' )
value: ( viewElement: ViewElement ) =>
viewElement.getAttribute( 'href' )
}
} )
.elementToAttribute( {
view: {
name: 'a',
attributes: {
rel: true
}
},
model: {
key: 'linkRel',
value: ( viewElement: ViewElement ) =>
viewElement.getAttribute( 'rel' )
}
} )
.elementToAttribute( {
view: {
name: 'a',
attributes: {
target: true
}
},
model: {
key: 'linkTarget',
value: ( viewElement: ViewElement ) =>
viewElement.getAttribute( 'target' )
}
} );

Expand Down Expand Up @@ -469,7 +536,7 @@ export default class LinkEditing extends Plugin {
const view = editor.editing.view;

// Selection attributes when started typing over the link.
let selectionAttributes: IterableIterator<[ string, unknown ]> | null = null;
let selectionAttributes: IterableIterator<[string, unknown]> | null = null;

// Whether pressed `Backspace` or `Delete`. If so, attributes should not be preserved.
let deletedContent = false;
Expand Down
Loading