Ckeditor5: Upcast/Downcast with different inner text

Created on 10 Sep 2019  ·  4Comments  ·  Source: ckeditor/ckeditor5

I am working on a plugin for my employer which will allow apps to specify template placeholders which will be inserted into the text later (via Handlebars or something similar). The Mention plugin had almost everything I need for the basic functionality, so I used that as a starting point for my plugin. But I am running into an issue trying to improve the UX of the editor itself.

I need to display a user-friendly label (provided in the configs), but upcast from and downcast to the Handlebars syntax for the placeholder itself.

Here are a few screenshots to better illustrate what I'm trying to do.

This is how I would like to display the node in the editor itself:
friendly-display

But when I do that, this is what the output looks like:
friendly-out

This is my desired output:
template-out

But to get that, I have to display the node in the editor like this:
template-display

I assume I need to do some magic with the upcast and downcast converters, but I have been messing with them for nearly a full work day without much progress. Hopefully someone here can let me know that what I'm doing is not possible, or better yet help me figure out where I'm going wrong.

engine question

All 4 comments

@chimericdream it would be easier to help if you'd post your code with what you have.

Sure thing. Here is where I'm registering the upcast and downcast helpers in my plugin's init:

/*
 * The data here is a set of key:value pairs in this format
 *
 * {
 *     IDENT_1: 'Pretty label',
 *     IDENT_2: 'Another label',
 *     ...
 * }
 */
const placeholders = editor.config.get('placeholder.options');

model.schema.extend('$text', {allowAttributes: 'placeholder'});

editor.conversion.for('upcast').elementToAttribute({
    view: element => {
        if (element.name === 'span') {
            if (element.hasAttribute('data-placeholder')) {
                return {
                    name: true,
                    attribute: ['data-placeholder'],
                };
            }
        }

        return null;
    },
    model: {
        key: 'placeholder',
        value: (...args) => _toHbsPlaceholderAttribute(placeholders, ...args),
    },
});

editor.conversion.for('editingDowncast').attributeToElement({
    model: 'placeholder',
    view: createViewHbsPlaceholderElement,
});

editor.conversion.for('dataDowncast').attributeToElement({
    model: 'placeholder',
    view: createDataHbsPlaceholderElement,
});

Here's the code to upcast placeholders:

export const _addHbsPlaceholderAttributes = (baseHbsPlaceholderData, data) => ({
    _uid: uid(),
    ...baseHbsPlaceholderData,
    ...data || {},
});

export const _toHbsPlaceholderAttribute = (placeholders, viewElementOrHbsPlaceholder, data) => {
    const dataHbsPlaceholder = viewElementOrHbsPlaceholder.getAttribute('data-placeholder');
    const textNode = viewElementOrHbsPlaceholder.getChild(0);

    // Do not convert empty placeholders.
    if (!textNode) {
        return undefined;
    }

    const baseHbsPlaceholderData = {
        id: dataHbsPlaceholder,
        _text: textNode.data,
    };

    return _addHbsPlaceholderAttributes(baseHbsPlaceholderData, data);
};

And here are the downcast functions:

const createDataHbsPlaceholderElement = (placeholder, viewWriter) => {
    if (!placeholder) {
        return undefined;
    }

    const attributes = {'data-placeholder': placeholder.id};

    const options = {
        id: placeholder._uid,
        text: `{{${placeholder.id}}}`,
        priority: 20,
    };

    return viewWriter.createAttributeElement('span', attributes, options);
};

const createViewHbsPlaceholderElement = (placeholder, viewWriter) => {
    if (!placeholder) {
        return undefined;
    }

    const attributes = {
        'class': 'placeholder',
        'data-placeholder': placeholder.id,
    };

    const options = {
        id: placeholder._uid,
        priority: 20,
    };

    return viewWriter.createAttributeElement('span', attributes, options);
};

Finally, here's the execute() method of the placeholder command:

const model = this.editor.model;
const document = model.document;
const selection = document.selection;

const placeholderData = typeof options.placeholder === 'string' ? {id: options.placeholder} : options.placeholder;
const placeholderID = placeholderData.id;

const range = options.range || selection.getFirstRange();

// const placeholderText = options.text || placeholderID;
const placeholderText = `{{${placeholderID.replace('%', '')}}}`;

const placeholder = _addHbsPlaceholderAttributes({_text: placeholderText, id: placeholderID}, placeholderData);

if (placeholderID.charAt(0) !== '%') {
    throw new CKEditorError(
        'placeholdercommand-incorrect-id: The item id must start with the marker character.',
        this
    );
}

model.change(writer => {
    const currentAttributes = toMap(selection.getAttributes());
    const attributesWithHbsPlaceholder = new Map(currentAttributes.entries());

    attributesWithHbsPlaceholder.set('placeholder', placeholder);

    // Replace a range with the text with a placeholder.
    model.insertContent(writer.createText(placeholderText, attributesWithHbsPlaceholder), range);
});

I used the Mentions plugin as a starting point for mine, since my needs are for something very similar (albeit without some of the extras like creating links and whatnot), so much of the code may look familiar.

@chimericdream Did you check the inline widget guide? I think that this is more suitable for what you need (placeholders). From what I can see in the first post you don't want to allow users to edit the text inside a placeholder.

In that demo you could customize the createPlaceholderView() function to output one format for the editing view (ie. without the {{}} markup and for the data view output what you want for the handlebars. Something like this:

function createPlaceholderView( modelItem, viewWriter, isData = true ) {
    const name = modelItem.getAttribute( 'name' );

    const placeholderView = viewWriter.createContainerElement( 'span', {
        class: 'placeholder'
    } );

    const output = isData ? `{{${ name }}}` : name;

    const innerText = viewWriter.createText( output );
    viewWriter.insert( viewWriter.createPositionAt( placeholderView, 0 ), innerText );

    return placeholderView;
}

conversion.for( 'dataDowncast' ).elementToElement( {
    model: 'placeholder',
    view: ( modelItem, viewWriter ) => createPlaceholderView( modelItem, viewWriter, true )
} );

conversion.for( 'editingDowncast' ).elementToElement( {
    model: 'placeholder',
    view: ( modelItem, viewWriter ) => {
        const widgetElement = createPlaceholderView( modelItem, viewWriter, false );

        // Enable widget handling on a placeholder element inside the editing view.
        return toWidget( widgetElement, viewWriter );
    }
} );

You will build a “placeholder” feature that allows the users to insert predefined placeholders, like a date or a surname, into the document.

@jodator, that is _exactly_ what I need, thank you! I'll probably be able to take the final version of that how-to and use it with little or no change.

Closing this ticket, since I don't think there is anything that needs to happen in the library itself.

Was this page helpful?
0 / 5 - 0 ratings

Related issues

benjismith picture benjismith  ·  3Comments

devaptas picture devaptas  ·  3Comments

oleq picture oleq  ·  3Comments

wwalc picture wwalc  ·  3Comments

msamsel picture msamsel  ·  3Comments