ckeditor / ckeditor5

Powerful rich text editor framework with a modular architecture, modern integrations, and features like collaborative editing.
https://ckeditor.com/ckeditor-5
Other
9.36k stars 3.68k forks source link

I totally miss an example on how to upcast a single view element to multiple/nested model elements #14546

Open rgpublic opened 1 year ago

rgpublic commented 1 year ago

I currently have sth. like this:

editor.conversion.for( 'upcast' ).add( dispatcher => {
            dispatcher.on( 'element', ( evt, data, conversionApi ) => {
                const { consumable, schema, writer } = conversionApi;

                let name=data.viewItem.name;
                if (name=='input') {
                    if ( !consumable.test( data.viewItem, {name} ) ) return;
                    consumable.consume( data.viewItem, {name} );
                    let nameElement=writer.createElement( 'formsName');
                    let div=writer.createElement( 'forms' , {type: 'text'}, [nameElement] );
                    conversionApi.safeInsert( div, data.modelCursor );
                    conversionApi.updateConversionResult( div, data );

                }
            });
        } );`

But it seems createElement doesn't have a third argument for children. And there's no createContainerElement either. It's very confusing. What I also found surprising is that the "writer" you get here is not an UpcastWriter but a normal Writer. All Writer classes seem to have different methods...? But I cannot for the life of me figure out how to create a nested structure here.

Eventually, I'd like to create a widget during downcast. When I try to do that with a single model element though, I cannot focus the inner element even though it has contenteditable set to true. If I click, the caret appears and is then quickly moved away. This is way I thought it might be necessary to have a separate model.

Unfortunately, the docs only talk about how to turn existing nested DIVs into a model. But there is no example on how to turn sth. like this:

<input type="text" name="HELLO" />

into this:

<forms type="text">
    <formName>HELLO</formName>
</forms>

This is what I'd like to achieve as a widget to have the name attribute editable right in the widget. Isn't this possible?

niegowski commented 1 year ago

But it seems createElement doesn't have a third argument for children. And there's no createContainerElement either. It's very confusing. What I also found surprising is that the "writer" you get here is not an UpcastWriter but a normal Writer. All Writer classes seem to have different methods...? But I cannot for the life of me figure out how to create a nested structure here.

While upcasting you are converting from data view to editor model so the writer is creating and manipulating only model nodes. We have an issue for aligning those writers so the API would be easier to understand: https://github.com/ckeditor/ckeditor5/issues/7316

I created a simple plugin according to your description:

import { Plugin } from 'ckeditor5/src/core';
import { toWidget, toWidgetEditable } from 'ckeditor5/src/widget';

class Forms extends Plugin {
    init() {
        const editor = this.editor;
        const schema = editor.model.schema;

        schema.register( 'forms', {
            inheritAllFrom: '$inlineObject',
            allowAttributes: 'type'
        } );

        schema.register( 'formName', {
            allowIn: 'forms',
            allowChildren: '$text',
            isLimit: true
        } );

        // Disallow all attributes on $text inside `formName` (there won't be any bold/italic etc. inside).
        schema.addAttributeCheck( context => {
            if ( context.endsWith( 'formName $text' ) ) {
                return false;
            }
        } );

        // Allow only text nodes inside `formName` (without any elements that could be down-casted to HTML elements).
        schema.addChildCheck( ( context, childDefinition ) => {
            if ( context.endsWith( 'formName' ) && childDefinition.name !== '$text' ) {
                return false;
            }
        } );

        // Data upcast. Convert a single element loaded by the editor to a structure of model elements.
        editor.conversion.for( 'upcast' ).elementToElement( {
            view: {
                name: 'input',
                attributes: [ 'type', 'name' ]
            },
            model: ( viewElement, { writer } ) => {
                const modelElement = writer.createElement( 'forms', {
                    type: viewElement.getAttribute( 'type' )
                } );
                const nameModelElement = writer.createElement( 'formName' );

                // Build model structure out of a single view element.
                writer.insert( nameModelElement, modelElement, 0 );
                writer.insertText( viewElement.getAttribute( 'name' ), nameModelElement, 0 );

                return modelElement;
            }
        } );

        // Editing downcast. Convert model elements separately to widget and to widget-editable nested inside.
        editor.conversion.for( 'editingDowncast' )
            .elementToElement( {
                model: 'forms',
                view: ( modelElement, { writer } ) => {
                    const viewElement = writer.createContainerElement( 'span', {
                        'data-type': modelElement.getAttribute( 'type' ),
                        style: 'display: inline-block'
                    } );

                    return toWidget( viewElement, writer );
                }
            } )
            .elementToElement( {
                model: 'formName',
                view: ( modelElement, { writer } ) => {
                    const viewElement = writer.createEditableElement( 'span' );

                    return toWidgetEditable( viewElement, writer );
                }
            } );

        // Data downcast. Convert the outermost model element and all its content into a single view element.
        editor.conversion.for( 'dataDowncast' )
            .elementToElement( {
                model: 'forms',
                view: ( modelElement, { writer, consumable } ) => {
                    let nameModelElement;

                    // Find the `formName` model element and consume everything inside the model element range,
                    // so it won't get converted by any other downcast converters.
                    for ( const { item } of editor.model.createRangeIn( modelElement ) ) {
                        if ( item.is( 'element', 'formName' ) ) {
                            nameModelElement = modelElement.getChild( 0 );
                        }

                        consumable.consume( item, 'insert' );
                    }

                    return writer.createContainerElement( 'input', {
                        type: modelElement.getAttribute( 'type' ),
                        name: nameModelElement.getChild( 0 ).data
                    } );
                }
            } );
    }
}
Witoso commented 1 year ago

@apadol-cksource it would be great to somehow use the example above in the docs. Please let me know what you think.

apadol-cksource commented 1 year ago

Totally. Maybe we could use it to extend https://ckeditor.com/docs/ckeditor5/latest/framework/deep-dive/conversion/upcast.html#converting-structures with an example showing the conversion of a single view element into many model elements.

rgpublic commented 1 year ago

Yes +1. Thanks a lot for the code BTW - super, super helpful. This is what I would have expected here:

https://ckeditor.com/docs/ckeditor5/latest/framework/tutorials/implementing-an-inline-widget.html

It's confusing when you are coming from a block widget and then you'd expect that an inline widget was described. But then there's only an example about placeholders...

Particulary interesting for me - and not immediately apparent from the docs is that you can just style the "block widget" as inline block to behave properly. A toInlineWidget() would probably be even better, but at least this should be documented somewhere.

What's more, I guess the mentioned ways of restricting things (no bold, only textnodes) are very interesting and not self-explanatory from the docs.

rgpublic commented 1 year ago

PS: Is it just me or aren't the buttons on the inline widget still wrong somehow? I don't want to insert a new paragraph before or after, of course, but a character before or after the inline widget. And if the inline widget is alone inside a paragraph it seems I cannot click before or after the widget to type. Perhaps a toInlineWidget() would really be the best approach going forward...

Witoso commented 1 year ago

@rgpublic I think it's related to the schema definition and that insert new line handles automatically appear in some case. But it would be great if @niegowski could confirm.

rgpublic commented 1 year ago

@Witoso : Thank you. Yeah, that's what I figured. In the meantime I'm using sth. like this in the editing downcast:

  const viewElement = conversionApi.writer.createContainerElement( 'span',  {class:'forms' );
  const viewElementAfter = conversionApi.writer.createContainerElement( 'span', {class:'forms-inline'});
  const viewElementBefore = conversionApi.writer.createContainerElement( 'span', {class:'forms-inline'});
  conversionApi.writer.insert( viewPosition, viewElementAfter );
  conversionApi.writer.insert( viewPosition, toWidget(viewElement,writer) );
  conversionApi.writer.insert( viewPosition, viewElementBefore );

That is, I'm inserting an element before and after. If I style those before/after elements a min-width of 1px and also inline-block, then there's always a small gap where you can click. I hid the usual insert paragraph handles with CSS as well.

But: This all seems like a very ugly workaround. So I'd like to +1 on adding a dedicated toInlineWidget() which would solve all these weird things.

CKEditorBot commented 2 months ago

There has been no activity on this issue for the past year. We've marked it as stale and will close it in 30 days. We understand it may still be relevant, so if you're interested in the solution, leave a comment or reaction under this issue.

rgpublic commented 2 months ago

Yes, I still think this is a valid feature request. The docs for the inline widget should be more in line with the block widget - just "inline". The docs currently talking about a placeholder inline widget have a note that says they're soon to be deprecated anyway. And I still think to have an toInlineWidget() would be nice. This issue could be closed as a duplicate for #1729 though.

Witoso commented 2 months ago

Thanks for the bump, docs won't be deprecated, it's still a valid learning material, we will just have a feature that is a bit more advanced.

We will keep it open, we do plan some additional improvements to the framework, I will add it to my list.