Skip to content

Commit

Permalink
Merge pull request #17177 from ckeditor/cc/image-merge-fields
Browse files Browse the repository at this point in the history
Feature (image): Added a possibility to break the current block by `InsertImageCommand` with `breakBlock` flag. Closes #17742.

Feature (ui): The `.ck-with-instant-tooltip` class can now be used to display the tooltip without the delay. Closes #17743.

Feature (mention): Allow the [mention marker](https://ckeditor.com/docs/ckeditor5/latest/api/module_mention_mentionconfig-MentionFeed.html#member-marker) to be longer than 1 character. Closes #17744.

Feature (clipboard): Pass information to the downcast converter when clipboard pipeline is used to allow for customization. Closes #17745.

Feature (clipboard): `viewToPlainText()` helper will now parse the view `RawElement` instances. Closes #17746.

Fix (ui): Tooltip will no longer show after quickly hovering and moving the mouse away before the tooltip shows. Closes  
#16949.

Fix (ui): Tooltip will no longer disappear if cursor is moved back over the element with visible tooltip. Closes #17256.
  • Loading branch information
scofalik authored Jan 10, 2025
2 parents 9333cc9 + e4f326f commit 06cf625
Show file tree
Hide file tree
Showing 13 changed files with 225 additions and 57 deletions.
4 changes: 2 additions & 2 deletions packages/ckeditor5-clipboard/src/clipboardpipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -318,7 +318,7 @@ export default class ClipboardPipeline extends Plugin {
}, { priority: 'low' } );

this.listenTo<ClipboardOutputTransformationEvent>( this, 'outputTransformation', ( evt, data ) => {
const content = editor.data.toView( data.content );
const content = editor.data.toView( data.content, { isClipboardPipeline: true } );

viewDocument.fire<ViewDocumentClipboardOutputEvent>( 'clipboardOutput', {
dataTransfer: data.dataTransfer,
Expand All @@ -330,7 +330,7 @@ export default class ClipboardPipeline extends Plugin {
this.listenTo<ViewDocumentClipboardOutputEvent>( viewDocument, 'clipboardOutput', ( evt, data ) => {
if ( !data.content.isEmpty ) {
data.dataTransfer.setData( 'text/html', this.editor.data.htmlProcessor.toData( data.content ) );
data.dataTransfer.setData( 'text/plain', viewToPlainText( data.content ) );
data.dataTransfer.setData( 'text/plain', viewToPlainText( editor.data.htmlProcessor.domConverter, data.content ) );
}

if ( data.method == 'cut' ) {
Expand Down
38 changes: 35 additions & 3 deletions packages/ckeditor5-clipboard/src/utils/viewtoplaintext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
* @module clipboard/utils/viewtoplaintext
*/

import type { ViewDocumentFragment, ViewElement, ViewItem } from '@ckeditor/ckeditor5-engine';
import type { DomConverter, ViewDocumentFragment, ViewElement, ViewItem } from '@ckeditor/ckeditor5-engine';

// Elements which should not have empty-line padding.
// Most `view.ContainerElement` want to be separate by new-line, but some are creating one structure
Expand All @@ -19,10 +19,14 @@ const listElements = [ 'ol', 'ul' ];
/**
* Converts {@link module:engine/view/item~Item view item} and all of its children to plain text.
*
* @param converter The converter instance.
* @param viewItem View item to convert.
* @returns Plain text representation of `viewItem`.
*/
export default function viewToPlainText( viewItem: ViewItem | ViewDocumentFragment ): string {
export default function viewToPlainText(
converter: DomConverter,
viewItem: ViewItem | ViewDocumentFragment
): string {
if ( viewItem.is( '$text' ) || viewItem.is( '$textProxy' ) ) {
return viewItem.data;
}
Expand All @@ -44,10 +48,38 @@ export default function viewToPlainText( viewItem: ViewItem | ViewDocumentFragme
let prev: ViewElement | null = null;

for ( const child of ( viewItem as ViewElement | ViewDocumentFragment ).getChildren() ) {
text += newLinePadding( child as ViewElement, prev ) + viewToPlainText( child );
text += newLinePadding( child as ViewElement, prev ) + viewToPlainText( converter, child );
prev = child as ViewElement;
}

// If item is a raw element, the only way to get its content is to render it and read the text directly from DOM.
if ( viewItem.is( 'rawElement' ) ) {
const tempElement = document.createElement( 'div' );

viewItem.render( tempElement, converter );

text += domElementToPlainText( tempElement );
}

return text;
}

/**
* Recursively converts DOM element and all of its children to plain text.
*/
function domElementToPlainText( element: HTMLElement ): string {
let text = '';

if ( element.nodeType === Node.TEXT_NODE ) {
return element.textContent!;
} else if ( element.tagName === 'BR' ) {
return '\n';
}

for ( const child of element.childNodes ) {
text += domElementToPlainText( child as HTMLElement );
}

return text;
}

Expand Down
20 changes: 19 additions & 1 deletion packages/ckeditor5-clipboard/tests/clipboardpipeline.js
Original file line number Diff line number Diff line change
Expand Up @@ -485,7 +485,6 @@ describe( 'ClipboardPipeline feature', () => {
expect( data.dataTransfer ).to.equal( dataTransferMock );
expect( data.content ).is.instanceOf( ModelDocumentFragment );
expect( stringifyModel( data.content ) ).to.equal( '<paragraph>bc</paragraph><paragraph>de</paragraph>' );

done();
} );

Expand All @@ -495,6 +494,25 @@ describe( 'ClipboardPipeline feature', () => {
} );
} );

it( 'triggers the conversion with the `isClipboardPipeline` flag', done => {
const dataTransferMock = createDataTransfer();
const preventDefaultSpy = sinon.spy();
const toViewSpy = sinon.spy( editor.data, 'toView' );

setModelData( editor.model, '<paragraph>a[bc</paragraph><paragraph>de]f</paragraph>' );

clipboardPlugin.on( 'outputTransformation', ( evt, data ) => {
expect( toViewSpy ).calledWithExactly( data.content, { isClipboardPipeline: true } );

done();
}, { priority: 'lowest' } );

viewDocument.fire( 'copy', {
dataTransfer: dataTransferMock,
preventDefault: preventDefaultSpy
} );
} );

it( 'fires clipboardOutput for copy with the selected content and correct method', done => {
const dataTransferMock = createDataTransfer();
const preventDefaultSpy = sinon.spy();
Expand Down
26 changes: 24 additions & 2 deletions packages/ckeditor5-clipboard/tests/utils/viewtoplaintext.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,26 @@
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/

import { DomConverter, StylesProcessor, ViewDocument, DowncastWriter } from '@ckeditor/ckeditor5-engine';
import viewToPlainText from '../../src/utils/viewtoplaintext.js';

import { parse as parseView } from '@ckeditor/ckeditor5-engine/src/dev-utils/view.js';

describe( 'viewToPlainText()', () => {
let converter, viewDocument;

beforeEach( () => {
viewDocument = new ViewDocument( new StylesProcessor() );
converter = new DomConverter( viewDocument );
} );

afterEach( () => {
viewDocument.destroy();
} );

function testViewToPlainText( viewString, expectedText ) {
const view = parseView( viewString );
const text = viewToPlainText( view );
const text = viewToPlainText( converter, view );

expect( text ).to.equal( expectedText );
}
Expand Down Expand Up @@ -41,7 +53,7 @@ describe( 'viewToPlainText()', () => {
const view = parseView( viewString );
view.getChild( 1 )._setCustomProperty( 'dataPipeline:transparentRendering', true );

const text = viewToPlainText( view );
const text = viewToPlainText( converter, view );

expect( text ).to.equal( expectedText );
} );
Expand Down Expand Up @@ -126,4 +138,14 @@ describe( 'viewToPlainText()', () => {
'Foo\n\nA\n\nB\n\nBar'
);
} );

it( 'should convert a view RawElement', () => {
const writer = new DowncastWriter( viewDocument );
const rawElement = writer.createRawElement( 'div', { 'data-foo': 'bar' }, function( domElement ) {
domElement.innerHTML = '<p>Foo</p><br><p>Bar</p>';
} );
const text = viewToPlainText( converter, rawElement );

expect( text ).to.equal( 'Foo\nBar' );
} );
} );
4 changes: 4 additions & 0 deletions packages/ckeditor5-image/src/image/insertimagecommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,12 +97,14 @@ export default class InsertImageCommand extends Command {
* @param options Options for the executed command.
* @param options.imageType The type of the image to insert. If not specified, the type will be determined automatically.
* @param options.source The image source or an array of image sources to insert.
* @param options.breakBlock If set to `true`, the block at the selection start will be broken before inserting the image.
* See the documentation of the command to learn more about accepted formats.
*/
public override execute(
options: {
source: ArrayOrItem<string | Record<string, unknown>>;
imageType?: 'imageBlock' | 'imageInline' | null;
breakBlock?: boolean;
}
): void {
const sourceDefinitions = toArray<string | Record<string, unknown>>( options.source );
Expand Down Expand Up @@ -132,6 +134,8 @@ export default class InsertImageCommand extends Command {
const position = this.editor.model.createPositionAfter( selectedElement );

imageUtils.insertImage( { ...sourceDefinition, ...selectionAttributes }, position, options.imageType );
} else if ( options.breakBlock ) {
imageUtils.insertImage( { ...sourceDefinition, ...selectionAttributes }, selection.getFirstPosition(), options.imageType );
} else {
imageUtils.insertImage( { ...sourceDefinition, ...selectionAttributes }, null, options.imageType );
}
Expand Down
1 change: 1 addition & 0 deletions packages/ckeditor5-image/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export { default as ImageCaptionUI } from './imagecaption/imagecaptionui.js';
export { createImageTypeRegExp } from './imageupload/utils.js';

export type { ImageConfig } from './imageconfig.js';
export type { ImageLoadedEvent } from './image/imageloadobserver.js';
export type { default as ImageTypeCommand } from './image/imagetypecommand.js';
export type { default as InsertImageCommand } from './image/insertimagecommand.js';
export type { default as ReplaceImageSourceCommand } from './image/replaceimagesourcecommand.js';
Expand Down
16 changes: 16 additions & 0 deletions packages/ckeditor5-image/tests/image/insertimagecommand.js
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,22 @@ describe( 'InsertImageCommand', () => {
);
} );

it( 'should be possible to break the block with an inserted image', () => {
const imgSrc = 'foo/bar.jpg';

setModelData( model, '<paragraph>f[]oo</paragraph>' );

command.execute( {
imageType: 'imageBlock',
source: imgSrc,
breakBlock: true
} );

expect( getModelData( model ) ).to.equal(
`<paragraph>f</paragraph>[<imageBlock src="${ imgSrc }"></imageBlock>]<paragraph>oo</paragraph>`
);
} );

it( 'should insert multiple images at selection position as other widgets for inline type images', () => {
const imgSrc1 = 'foo/bar.jpg';
const imgSrc2 = 'foo/baz.jpg';
Expand Down
22 changes: 2 additions & 20 deletions packages/ckeditor5-mention/src/mentioncommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,27 +113,9 @@ export default class MentionCommand extends Command {

const mention = _addMentionAttributes( { _text: mentionText, id: mentionID }, mentionData );

if ( options.marker.length != 1 ) {
if ( !mentionID.startsWith( options.marker ) ) {
/**
* The marker must be a single character.
*
* Correct markers: `'@'`, `'#'`.
*
* Incorrect markers: `'@@'`, `'[@'`.
*
* See {@link module:mention/mentionconfig~MentionConfig}.
*
* @error mentioncommand-incorrect-marker
*/
throw new CKEditorError(
'mentioncommand-incorrect-marker',
this
);
}

if ( mentionID.charAt( 0 ) != options.marker ) {
/**
* The feed item ID must start with the marker character.
* The feed item ID must start with the marker character(s).
*
* Correct mention feed setting:
*
Expand Down
10 changes: 5 additions & 5 deletions packages/ckeditor5-mention/src/mentionui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -722,12 +722,12 @@ export function createRegExp( marker: string, minimumCharacters: number ): RegEx
// The pattern consists of 3 groups:
//
// - 0 (non-capturing): Opening sequence - start of the line, space or an opening punctuation character like "(" or "\"",
// - 1: The marker character,
// - 1: The marker character(s),
// - 2: Mention input (taking the minimal length into consideration to trigger the UI),
//
// The pattern matches up to the caret (end of string switch - $).
// (0: opening sequence )(1: marker )(2: typed mention )$
const pattern = `(?:^|[ ${ openAfterCharacters }])([${ marker }])(${ mentionCharacters }${ numberOfCharacters })$`;
// (0: opening sequence )(1: marker )(2: typed mention )$
const pattern = `(?:^|[ ${ openAfterCharacters }])(${ marker })(${ mentionCharacters }${ numberOfCharacters })$`;

return new RegExp( pattern, 'u' );
}
Expand Down Expand Up @@ -822,8 +822,8 @@ function isMarkerInExistingMention( markerPosition: Position ): boolean | null {
/**
* Checks if string is a valid mention marker.
*/
function isValidMentionMarker( marker: string ): boolean | string {
return marker && marker.length == 1;
function isValidMentionMarker( marker: string ): boolean {
return !!marker;
}

/**
Expand Down
13 changes: 0 additions & 13 deletions packages/ckeditor5-mention/tests/mentioncommand.js
Original file line number Diff line number Diff line change
Expand Up @@ -151,19 +151,6 @@ describe( 'MentionCommand', () => {
expect( textNode.hasAttribute( 'bold' ) ).to.be.true;
} );

it( 'should throw if marker is not one character', () => {
setData( model, '<paragraph>foo @Jo[]bar</paragraph>' );

const testCases = [
{ marker: '##', mention: '##foo' },
{ marker: '', mention: '@foo' }
];

for ( const options of testCases ) {
expectToThrowCKEditorError( () => command.execute( options ), /mentioncommand-incorrect-marker/, editor );
}
} );

it( 'should throw if marker does not match mention id', () => {
setData( model, '<paragraph>foo @Jo[]bar</paragraph>' );

Expand Down
Loading

0 comments on commit 06cf625

Please sign in to comment.