diff --git a/packages/ckeditor5-image/src/image/insertimagecommand.ts b/packages/ckeditor5-image/src/image/insertimagecommand.ts index 3fe27225c93..b11e5dcbd9a 100644 --- a/packages/ckeditor5-image/src/image/insertimagecommand.ts +++ b/packages/ckeditor5-image/src/image/insertimagecommand.ts @@ -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>; imageType?: 'imageBlock' | 'imageInline' | null; + breakBlock?: boolean; } ): void { const sourceDefinitions = toArray>( options.source ); @@ -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 ); } diff --git a/packages/ckeditor5-image/src/index.ts b/packages/ckeditor5-image/src/index.ts index 3a06bd534fe..b6f82a810d5 100644 --- a/packages/ckeditor5-image/src/index.ts +++ b/packages/ckeditor5-image/src/index.ts @@ -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'; diff --git a/packages/ckeditor5-image/tests/image/insertimagecommand.js b/packages/ckeditor5-image/tests/image/insertimagecommand.js index c0053361d25..0fa3d98d578 100644 --- a/packages/ckeditor5-image/tests/image/insertimagecommand.js +++ b/packages/ckeditor5-image/tests/image/insertimagecommand.js @@ -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, 'f[]oo' ); + + command.execute( { + imageType: 'imageBlock', + source: imgSrc, + breakBlock: true + } ); + + expect( getModelData( model ) ).to.equal( + `f[]oo` + ); + } ); + 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'; diff --git a/packages/ckeditor5-mention/src/mentioncommand.ts b/packages/ckeditor5-mention/src/mentioncommand.ts index 08609394a75..6738fcee3ac 100644 --- a/packages/ckeditor5-mention/src/mentioncommand.ts +++ b/packages/ckeditor5-mention/src/mentioncommand.ts @@ -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: * diff --git a/packages/ckeditor5-mention/src/mentionui.ts b/packages/ckeditor5-mention/src/mentionui.ts index 4a870b241a1..f2059476a98 100644 --- a/packages/ckeditor5-mention/src/mentionui.ts +++ b/packages/ckeditor5-mention/src/mentionui.ts @@ -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' ); } @@ -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; } /** diff --git a/packages/ckeditor5-mention/tests/mentioncommand.js b/packages/ckeditor5-mention/tests/mentioncommand.js index 87e972c1be9..7bde46aa036 100644 --- a/packages/ckeditor5-mention/tests/mentioncommand.js +++ b/packages/ckeditor5-mention/tests/mentioncommand.js @@ -151,19 +151,6 @@ describe( 'MentionCommand', () => { expect( textNode.hasAttribute( 'bold' ) ).to.be.true; } ); - it( 'should throw if marker is not one character', () => { - setData( model, 'foo @Jo[]bar' ); - - 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, 'foo @Jo[]bar' ); diff --git a/packages/ckeditor5-mention/tests/mentionui.js b/packages/ckeditor5-mention/tests/mentionui.js index 9ad1a580be9..065d203f0e8 100644 --- a/packages/ckeditor5-mention/tests/mentionui.js +++ b/packages/ckeditor5-mention/tests/mentionui.js @@ -86,10 +86,8 @@ describe( 'MentionUI', () => { } ); } ); - it( 'should throw if marker is longer then 1 character', () => { - return createClassicTestEditor( { feeds: [ { marker: '$$', feed: [ 'a' ] } ] } ).catch( error => { - assertCKEditorError( error, /mentionconfig-incorrect-marker/, null, { marker: '$$' } ); - } ); + it( 'should not throw if marker is longer then 1 character', () => { + expect( () => createClassicTestEditor( { feeds: [ { marker: '$$', feed: [ 'a' ] } ] } ) ).to.not.throw(); } ); } ); @@ -401,28 +399,35 @@ describe( 'MentionUI', () => { env.features.isRegExpUnicodePropertySupported = false; createRegExp( '@', 2 ); sinon.assert.calledOnce( regExpStub ); - sinon.assert.calledWithExactly( regExpStub, '(?:^|[ \\(\\[{"\'])([@])(.{2,})$', 'u' ); + sinon.assert.calledWithExactly( regExpStub, '(?:^|[ \\(\\[{"\'])(@)(.{2,})$', 'u' ); } ); it( 'returns a ES2018 RegExp for browsers supporting Unicode punctuation groups', () => { env.features.isRegExpUnicodePropertySupported = true; createRegExp( '@', 2 ); sinon.assert.calledOnce( regExpStub ); - sinon.assert.calledWithExactly( regExpStub, '(?:^|[ \\p{Ps}\\p{Pi}"\'])([@])(.{2,})$', 'u' ); + sinon.assert.calledWithExactly( regExpStub, '(?:^|[ \\p{Ps}\\p{Pi}"\'])(@)(.{2,})$', 'u' ); + } ); + + it( 'returns a proper regexp for markers longer than 1 character', () => { + env.features.isRegExpUnicodePropertySupported = true; + createRegExp( '@@', 2 ); + sinon.assert.calledOnce( regExpStub ); + sinon.assert.calledWithExactly( regExpStub, '(?:^|[ \\p{Ps}\\p{Pi}"\'])(@@)(.{2,})$', 'u' ); } ); it( 'correctly escapes passed marker #1', () => { env.features.isRegExpUnicodePropertySupported = true; createRegExp( ']', 2 ); sinon.assert.calledOnce( regExpStub ); - sinon.assert.calledWithExactly( regExpStub, '(?:^|[ \\p{Ps}\\p{Pi}"\'])([\\]])(.{2,})$', 'u' ); + sinon.assert.calledWithExactly( regExpStub, '(?:^|[ \\p{Ps}\\p{Pi}"\'])(\\])(.{2,})$', 'u' ); } ); it( 'correctly escapes passed marker #2', () => { env.features.isRegExpUnicodePropertySupported = true; createRegExp( '\\', 2 ); sinon.assert.calledOnce( regExpStub ); - sinon.assert.calledWithExactly( regExpStub, '(?:^|[ \\p{Ps}\\p{Pi}"\'])([\\\\])(.{2,})$', 'u' ); + sinon.assert.calledWithExactly( regExpStub, '(?:^|[ \\p{Ps}\\p{Pi}"\'])(\\\\)(.{2,})$', 'u' ); } ); } ); @@ -470,6 +475,45 @@ describe( 'MentionUI', () => { } ); } ); + it( 'should show panel after the whole marker is matched', () => { + return createClassicTestEditor( { + feeds: [ { marker: '@@', feed: [ '@Barney', '@Lily', '@Marshall', '@Robin', '@Ted' ] } ] + } ) + .then( () => { + setData( editor.model, 'foo []' ); + + model.change( writer => { + writer.insertText( '@', doc.selection.getFirstPosition() ); + } ); + } ) + .then( waitForDebounce ) + .then( () => { + expect( panelView.isVisible ).to.be.false; + expect( editor.model.markers.has( 'mention' ) ).to.be.false; + } ) + .then( () => { + model.change( writer => { + writer.insertText( '@', doc.selection.getFirstPosition() ); + } ); + } ) + .then( waitForDebounce ) + .then( () => { + expect( panelView.isVisible ).to.be.true; + expect( editor.model.markers.has( 'mention' ) ).to.be.true; + expect( mentionsView.items ).to.have.length( 5 ); + + model.change( writer => { + writer.insertText( 't', doc.selection.getFirstPosition() ); + } ); + } ) + .then( waitForDebounce ) + .then( () => { + expect( panelView.isVisible ).to.be.true; + expect( editor.model.markers.has( 'mention' ) ).to.be.true; + expect( mentionsView.items ).to.have.length( 1 ); + } ); + } ); + it( 'should update the marker if the selection was moved from one valid position to another', () => { const spy = sinon.spy(); diff --git a/packages/ckeditor5-ui/src/tooltipmanager.ts b/packages/ckeditor5-ui/src/tooltipmanager.ts index 21834edeb4a..e149b3f5664 100644 --- a/packages/ckeditor5-ui/src/tooltipmanager.ts +++ b/packages/ckeditor5-ui/src/tooltipmanager.ts @@ -56,13 +56,21 @@ const BALLOON_CLASS = 'ck-tooltip'; * * # Disabling tooltips * - * In order to disable the tooltip temporarily, use the `data-cke-tooltip-disabled` attribute: + * In order to disable the tooltip temporarily, use the `data-cke-tooltip-disabled` attribute: * * ```ts * domElement.dataset.ckeTooltipText = 'Disabled. For now.'; * domElement.dataset.ckeTooltipDisabled = 'true'; * ``` * + * # Instant tooltips + * + * To remove the delay before showing or hiding the tooltip, use the `data-cke-tooltip-instant` attribute: + * + * ```ts + * domElement.dataset.ckeTooltipInstant = 'true'; + * ``` + * * # Styling tooltips * * By default, the tooltip has `.ck-tooltip` class and its text inner `.ck-tooltip__text`. @@ -294,6 +302,8 @@ export default class TooltipManager extends /* #__PURE__ */ DomEmitterMixin() { // * a tooltip is displayed for a focused element, then the same element gets mouseentered, // * a tooltip is displayed for an element via mouseenter, then the focus moves to the same element. if ( elementWithTooltipAttribute === this._currentElementWithTooltip ) { + this._unpinTooltipDebounced.cancel(); + return; } @@ -302,7 +312,13 @@ export default class TooltipManager extends /* #__PURE__ */ DomEmitterMixin() { // The tooltip should be pinned immediately when the element gets focused using keyboard. // If it is focused using the mouse, the tooltip should be pinned after a delay to prevent flashing. // See https://github.com/ckeditor/ckeditor5/issues/16383 - if ( evt.name === 'focus' && !elementWithTooltipAttribute.matches( ':hover' ) ) { + // Also, if the element has an attribute `data-cke-tooltip-instant`, the tooltip should be pinned immediately. + // This is useful for elements that have their content partially hidden (e.g. a long text in a small container) + // and should show a tooltip on hover, like merge field. + if ( + evt.name === 'focus' && !elementWithTooltipAttribute.matches( ':hover' ) || + elementWithTooltipAttribute.matches( '[data-cke-tooltip-instant]' ) + ) { this._pinTooltip( elementWithTooltipAttribute, getTooltipData( elementWithTooltipAttribute ) ); } else { this._pinTooltipDebounced( elementWithTooltipAttribute, getTooltipData( elementWithTooltipAttribute ) ); @@ -329,6 +345,7 @@ export default class TooltipManager extends /* #__PURE__ */ DomEmitterMixin() { // Do not hide the tooltip when the user moves the cursor over it. if ( isEnteringBalloon ) { this._unpinTooltipDebounced.cancel(); + return; } @@ -347,7 +364,17 @@ export default class TooltipManager extends /* #__PURE__ */ DomEmitterMixin() { // Note that this should happen whether the tooltip is already visible or not, for instance, // it could be invisible but queued (debounced): it should get canceled. if ( isLeavingBalloon || ( descendantWithTooltip && descendantWithTooltip !== relatedDescendantWithTooltip ) ) { - this._unpinTooltipDebounced(); + this._pinTooltipDebounced.cancel(); + + // If the currently visible tooltip is instant, unpin it immediately. + if ( + this._currentElementWithTooltip && this._currentElementWithTooltip.matches( '[data-cke-tooltip-instant]' ) || + descendantWithTooltip && descendantWithTooltip.matches( '[data-cke-tooltip-instant]' ) + ) { + this._unpinTooltip(); + } else { + this._unpinTooltipDebounced(); + } } } else { // If a tooltip is currently visible, don't act for a targets other than the one it is attached to. @@ -358,6 +385,7 @@ export default class TooltipManager extends /* #__PURE__ */ DomEmitterMixin() { // Note that unpinning should happen whether the tooltip is already visible or not, for instance, it could be invisible but // queued (debounced): it should get canceled (e.g. quick focus then quick blur using the keyboard). + this._pinTooltipDebounced.cancel(); this._unpinTooltipDebounced(); } } diff --git a/packages/ckeditor5-ui/tests/tooltip/tooltipmanager.js b/packages/ckeditor5-ui/tests/tooltip/tooltipmanager.js index edac519dd7d..60be18c4f4c 100644 --- a/packages/ckeditor5-ui/tests/tooltip/tooltipmanager.js +++ b/packages/ckeditor5-ui/tests/tooltip/tooltipmanager.js @@ -306,6 +306,18 @@ describe( 'TooltipManager', () => { } ); } ); + it( 'should pin a tooltip instantly if element has a `data-cke-tooltip-instant` attribute', () => { + elements.a.dataset.ckeTooltipInstant = true; + + utils.dispatchMouseEnter( elements.a ); + + sinon.assert.calledOnce( pinSpy ); + sinon.assert.calledWith( pinSpy, { + target: elements.a, + positions: sinon.match.array + } ); + } ); + it( 'should pin just a single tooltip (singleton)', async () => { const secondEditor = await ClassicTestEditor.create( element, { plugins: [ Paragraph, Bold, Italic ], @@ -700,6 +712,19 @@ describe( 'TooltipManager', () => { sinon.assert.calledOnce( unpinSpy ); } ); + it( 'should remove the tooltip immediately if the element has `data-cke-tooltip-instant` attribute', () => { + elements.a.dataset.ckeTooltipInstant = true; + + utils.dispatchMouseEnter( elements.a ); + + sinon.assert.calledOnce( pinSpy ); + + unpinSpy = sinon.spy( tooltipManager.balloonPanelView, 'unpin' ); + utils.dispatchMouseLeave( tooltipManager.balloonPanelView.element, elements.b ); + + sinon.assert.calledOnce( unpinSpy ); + } ); + it( 'should not work if the tooltip is currently pinned and the event target is different than the current element', () => { utils.dispatchMouseEnter( elements.a ); utils.waitForTheTooltipToShow( clock );