Skip to content

Commit

Permalink
Merge fields related improvements and adjustments (#16615)
Browse files Browse the repository at this point in the history
Other (ui): Added the `LabelWithHighlightView` and `ButtonLabelWithHighlightView` components to the UI library.

Other (ui): Added the `filterGroupAndItemNames()` helper to the UI library.

Other (ui): The `SearchTextView#reset()` method will also reset the scroll of its `filteredView` to the top.

Internal (code-block): Support for inline elements in code block. By default code block does not allow elements but some exceptions (like merge fields) are allowed.

Internal (engine): Fixed whitespace handling around `dataPipeline:transparentRendering` elements in downcast.

Internal (engine): Added `inlineObjectElements` option to `dev-utils` to correctly set model data with custom inline object elements.
  • Loading branch information
Dumluregn authored Jul 16, 2024
1 parent 77efbba commit 3351b36
Show file tree
Hide file tree
Showing 28 changed files with 1,759 additions and 125 deletions.
57 changes: 36 additions & 21 deletions packages/ckeditor5-clipboard/src/pasteplaintext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

import { Plugin } from '@ckeditor/ckeditor5-core';

import type { DocumentFragment, Schema, ViewDocumentKeyDownEvent } from '@ckeditor/ckeditor5-engine';
import type { DocumentFragment, Model, Element } from '@ckeditor/ckeditor5-engine';

import ClipboardObserver from './clipboardobserver.js';
import ClipboardPipeline, { type ClipboardContentInsertionEvent } from './clipboardpipeline.js';
Expand Down Expand Up @@ -41,21 +41,12 @@ export default class PastePlainText extends Plugin {
const editor = this.editor;
const model = editor.model;
const view = editor.editing.view;
const viewDocument = view.document;
const selection = model.document.selection;

let shiftPressed = false;

view.addObserver( ClipboardObserver );

this.listenTo<ViewDocumentKeyDownEvent>( viewDocument, 'keydown', ( evt, data ) => {
shiftPressed = data.shiftKey;
} );

editor.plugins.get( ClipboardPipeline ).on<ClipboardContentInsertionEvent>( 'contentInsertion', ( evt, data ) => {
// Plain text can be determined based on the event flag (#7799) or auto-detection (#1006). If detected,
// preserve selection attributes on pasted items.
if ( !shiftPressed && !isPlainTextFragment( data.content, model.schema ) ) {
if ( !isUnformattedInlineContent( data.content, model ) ) {
return;
}

Expand All @@ -76,8 +67,10 @@ export default class PastePlainText extends Plugin {
const range = writer.createRangeIn( data.content );

for ( const item of range.getItems() ) {
if ( item.is( '$textProxy' ) ) {
writer.setAttributes( textAttributes, item );
for ( const attribute of textAttributes ) {
if ( model.schema.checkAttribute( item, attribute[ 0 ] ) ) {
writer.setAttribute( attribute[ 0 ], attribute[ 1 ], item );
}
}
}
} );
Expand All @@ -86,18 +79,40 @@ export default class PastePlainText extends Plugin {
}

/**
* Returns true if specified `documentFragment` represents a plain text.
* Returns true if specified `documentFragment` represents the unformatted inline content.
*/
function isPlainTextFragment( documentFragment: DocumentFragment, schema: Schema ): boolean {
if ( documentFragment.childCount > 1 ) {
return false;
function isUnformattedInlineContent( documentFragment: DocumentFragment, model: Model ): boolean {
let range = model.createRangeIn( documentFragment );

// We consider three scenarios here. The document fragment may include:
//
// 1. Only text and inline objects. Then it could be unformatted inline content.
// 2. Exactly one block element on top-level, eg. <p>Foobar</p> or <h2>Title</h2>.
// In this case, check this element content, it could be treated as unformatted inline content.
// 3. More block elements or block objects, then it is not unformatted inline content.
//
// We will check for scenario 2. specifically, and if it happens, we will unwrap it and follow with the regular algorithm.
//
if ( documentFragment.childCount == 1 ) {
const child = documentFragment.getChild( 0 )!;

if ( child.is( 'element' ) && model.schema.isBlock( child ) && !model.schema.isObject( child ) && !model.schema.isLimit( child ) ) {
// Scenario 2. as described above.
range = model.createRangeIn( child as Element );
}
}

const child = documentFragment.getChild( 0 )!;
for ( const child of range.getItems() ) {
if ( !model.schema.isInline( child ) ) {
return false;
}

const attributeKeys = Array.from( child.getAttributeKeys() );

if ( schema.isObject( child ) ) {
return false;
if ( attributeKeys.find( key => model.schema.getAttributeProperties( key ).isFormatting ) ) {
return false;
}
}

return Array.from( child.getAttributeKeys() ).length == 0;
return true;
}
8 changes: 8 additions & 0 deletions packages/ckeditor5-clipboard/src/utils/viewtoplaintext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,14 @@ function newLinePadding(
return '\n';
}

// Do not add padding around the elements that won't be rendered.
if (
element.is( 'element' ) && element.getCustomProperty( 'dataPipeline:transparentRendering' ) ||
previous.is( 'element' ) && previous.getCustomProperty( 'dataPipeline:transparentRendering' )
) {
return '';
}

// Add empty lines between container elements.
return '\n\n';
}
69 changes: 16 additions & 53 deletions packages/ckeditor5-clipboard/tests/pasteplaintext.js
Original file line number Diff line number Diff line change
Expand Up @@ -150,42 +150,36 @@ describe( 'PastePlainText', () => {
expect( getModelData( model ) ).to.equal( '<paragraph><$text bold="true">Bolded foo[]text.</$text></paragraph>' );
} );

it( 'should inherit selection attributes if shift key was pressed while pasting', () => {
it( 'should inherit selection attributes if only one block element was in the clipboard', () => {
setModelData( model, '<paragraph><$text bold="true">Bolded []text.</$text></paragraph>' );

const dataTransferMock = createDataTransfer( {
'text/html': 'foo<br>bar',
'text/plain': 'foo\nbar'
} );

fireKeyEvent( 'v', {
shiftKey: true,
ctrlKey: true
'text/html': '<p>foo</p>',
'text/plain': 'foo'
} );

viewDocument.fire( 'clipboardInput', {
viewDocument.fire( 'paste', {
dataTransfer: dataTransferMock,
stopPropagation() {},
preventDefault() {}
} );

expect( getModelData( model ) ).to.equal(
'<paragraph>' +
'<$text bold="true">Bolded foo</$text>' +
'<softBreak></softBreak>' +
'<$text bold="true">bar[]text.</$text>' +
'</paragraph>'
);
expect( getModelData( model ) ).to.equal( '<paragraph><$text bold="true">Bolded foo[]text.</$text></paragraph>' );
} );

it( 'should discard selection attributes if shift key was not pressed while pasting', () => {
it( 'should inherit selection attributes if shift key was pressed while pasting', () => {
setModelData( model, '<paragraph><$text bold="true">Bolded []text.</$text></paragraph>' );

const dataTransferMock = createDataTransfer( {
'text/html': 'foo<br>bar',
'text/plain': 'foo\nbar'
} );

fireKeyEvent( 'v', {
shiftKey: true,
ctrlKey: true
} );

viewDocument.fire( 'clipboardInput', {
dataTransfer: dataTransferMock,
stopPropagation() {},
Expand All @@ -194,10 +188,11 @@ describe( 'PastePlainText', () => {

expect( getModelData( model ) ).to.equal(
'<paragraph>' +
'<$text bold="true">Bolded </$text>' +
'foo<softBreak></softBreak>bar[]' +
'<$text bold="true">text.</$text>' +
'</paragraph>' );
'<$text bold="true">Bolded foo</$text>' +
'<softBreak></softBreak>' +
'<$text bold="true">bar[]text.</$text>' +
'</paragraph>'
);
} );

it( 'should work if the insertContent event is cancelled', () => {
Expand Down Expand Up @@ -279,38 +274,6 @@ describe( 'PastePlainText', () => {
);
} );

it( 'ignores clipboard input as plain text when shift was released', () => {
setModelData( model, '<paragraph><$text bold="true">Bolded []text.</$text></paragraph>' );

const dataTransferMock = createDataTransfer( {
'text/html': 'foo<br>bar',
'text/plain': 'foo\nbar'
} );

fireKeyEvent( 'a', {
shiftKey: true
} );

fireKeyEvent( 'v', {
shiftKey: false,
ctrlKey: true
} );

viewDocument.fire( 'clipboardInput', {
dataTransfer: dataTransferMock,
stopPropagation() {},
preventDefault() {}
} );

expect( getModelData( model ) ).to.equal(
'<paragraph>' +
'<$text bold="true">Bolded </$text>' +
'foo<softBreak></softBreak>bar[]' +
'<$text bold="true">text.</$text>' +
'</paragraph>'
);
} );

function createDataTransfer( data ) {
return {
getData( type ) {
Expand Down
12 changes: 12 additions & 0 deletions packages/ckeditor5-clipboard/tests/utils/viewtoplaintext.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,18 @@ describe( 'viewToPlainText()', () => {
);
} );

it( 'should not put empty line before or after the element with `dataPipeline:transparentRendering` property', () => {
const viewString = 'Abc <container:h1>Header</container:h1> xyz';
const expectedText = 'Abc Header xyz';

const view = parseView( viewString );
view.getChild( 1 )._setCustomProperty( 'dataPipeline:transparentRendering', true );

const text = viewToPlainText( view );

expect( text ).to.equal( expectedText );
} );

it( 'should turn a soft break into a single empty line', () => {
testViewToPlainText(
'<container:p>Foo<empty:br />Bar</container:p>',
Expand Down
3 changes: 1 addition & 2 deletions packages/ckeditor5-code-block/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,7 @@
"type": "module",
"main": "src/index.ts",
"dependencies": {
"ckeditor5": "42.0.1",
"lodash-es": "4.17.21"
"ckeditor5": "42.0.1"
},
"devDependencies": {
"@ckeditor/ckeditor5-alignment": "42.0.1",
Expand Down
35 changes: 31 additions & 4 deletions packages/ckeditor5-code-block/src/codeblockediting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@
* @module code-block/codeblockediting
*/

import { lowerFirst, upperFirst } from 'lodash-es';

import { Plugin, type Editor, type MultiCommand } from 'ckeditor5/src/core.js';
import { ShiftEnter, type ViewDocumentEnterEvent } from 'ckeditor5/src/enter.js';

Expand All @@ -24,6 +22,7 @@ import {
type Element,
type SelectionChangeRangeEvent
} from 'ckeditor5/src/engine.js';
import { ClipboardPipeline, type ClipboardContentInsertionEvent } from 'ckeditor5/src/clipboard.js';

import type { ListEditing } from '@ckeditor/ckeditor5-list';

Expand All @@ -34,7 +33,8 @@ import {
getNormalizedAndLocalizedLanguageDefinitions,
getLeadingWhiteSpaces,
rawSnippetTextToViewDocumentFragment,
getCodeBlockAriaAnnouncement
getCodeBlockAriaAnnouncement,
getTextNodeAtLineStart
} from './utils.js';
import {
modelToViewCodeBlockInsertion,
Expand Down Expand Up @@ -189,6 +189,31 @@ export default class CodeBlockEditing extends Plugin {
data.content = rawSnippetTextToViewDocumentFragment( writer, text );
} );

if ( editor.plugins.has( 'ClipboardPipeline' ) ) {
// Elements may have a plain textual representation (hence be present in the 'text/plain' data transfer),
// but not be allowed in the code block.
// Filter them out before inserting the content to the model.
editor.plugins.get( ClipboardPipeline ).on<ClipboardContentInsertionEvent>( 'contentInsertion', ( evt, data ) => {
const model = editor.model;
const selection = model.document.selection;

if ( !selection.anchor!.parent.is( 'element', 'codeBlock' ) ) {
return;
}

model.change( writer => {
const contentRange = writer.createRangeIn( data.content );

for ( const item of [ ...contentRange.getItems() ] ) {
// Remove all nodes disallowed in the code block.
if ( item.is( 'node' ) && !schema.checkChild( selection.anchor!, item ) ) {
writer.remove( item );
}
}
} );
} );
}

// Make sure multi–line selection is always wrapped in a code block when `getSelectedContent()`
// is used (e.g. clipboard copy). Otherwise, only the raw text will be copied to the clipboard and,
// upon next paste, this bare text will not be inserted as a code block, which is not the best UX.
Expand Down Expand Up @@ -325,10 +350,12 @@ export default class CodeBlockEditing extends Plugin {
function breakLineOnEnter( editor: Editor ): void {
const model = editor.model;
const modelDoc = model.document;
// Use last position as other mechanisms (e.g. condition deciding whether this function should be called) also check that.
const lastSelectionPosition = modelDoc.selection.getLastPosition()!;
const node = lastSelectionPosition.nodeBefore || lastSelectionPosition.textNode;
let leadingWhiteSpaces: string | undefined;

const node = getTextNodeAtLineStart( lastSelectionPosition, model );

// Figure out the indentation (white space chars) at the beginning of the line.
if ( node && node.is( '$text' ) ) {
leadingWhiteSpaces = getLeadingWhiteSpaces( node );
Expand Down
26 changes: 4 additions & 22 deletions packages/ckeditor5-code-block/src/outdentcodeblockcommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,14 @@
* @module code-block/outdentcodeblockcommand
*/

import type { Model, Position, Range, Text } from 'ckeditor5/src/engine.js';
import type { Model, Position, Range } from 'ckeditor5/src/engine.js';
import { Command, type Editor } from 'ckeditor5/src/core.js';

import {
getLeadingWhiteSpaces,
getIndentOutdentPositions,
isModelSelectionInCodeBlock
isModelSelectionInCodeBlock,
getTextNodeAtLineStart
} from './utils.js';

/**
Expand Down Expand Up @@ -129,7 +130,7 @@ export default class OutdentCodeBlockCommand extends Command {
// @returns {<module:engine/model/range~Range>|null}
function getLastOutdentableSequenceRange( model: Model, position: Position, sequence: string ): Range | null {
// Positions start before each text node (code line). Get the node corresponding to the position.
const nodeAtPosition = getCodeLineTextNodeAtPosition( position );
const nodeAtPosition = getTextNodeAtLineStart( position, model );

if ( !nodeAtPosition ) {
return null;
Expand Down Expand Up @@ -168,22 +169,3 @@ function getLastOutdentableSequenceRange( model: Model, position: Position, sequ
model.createPositionAt( parent!, startOffset! + lastIndexOfSequence + sequence.length )
);
}

function getCodeLineTextNodeAtPosition( position: Position ): Text | null {
// Positions start before each text node (code line). Get the node corresponding to the position.
let nodeAtPosition = position.parent.getChild( position.index );

// <codeBlock>foo^</codeBlock>
// <codeBlock>foo^<softBreak></softBreak>bar</codeBlock>
if ( !nodeAtPosition || nodeAtPosition.is( 'element', 'softBreak' ) ) {
nodeAtPosition = position.nodeBefore;
}

// <codeBlock>^</codeBlock>
// <codeBlock>foo^<softBreak></softBreak>bar</codeBlock>
if ( !nodeAtPosition || nodeAtPosition.is( 'element', 'softBreak' ) ) {
return null;
}

return nodeAtPosition as Text;
}
Loading

0 comments on commit 3351b36

Please sign in to comment.