Ckeditor5: How do I allow ID for headers?

Created on 11 Aug 2018  路  11Comments  路  Source: ckeditor/ckeditor5

Is this a bug report or feature request? (choose one)
Other

馃捇 Version of CKEditor
11.0.1

I've developed a table of contents feature which I'd like to have work with the editor. All I need is for headers to have the id attribute allowed. It's ok that the user won't be able to edit the ID - it's populated by a background job. I've read the comments about how to register models for the schema. I've modified node_modules/@ckeditor/ckeditor5-heading/src/headingediting.js with this:

// Schema.
editor.model.schema.register( option.model, {
  allowWhere: '$block',
  allowAttributes: [ 'id' ],
  isBlock: true
} );

Building happens without error, but the header in sample/index.html has this:

<h2><br data-cke-filler="true"></h2>

When it should have this:

<h2 id="section-1">Sample</h2>

Any idea what I'm doing wrong?

question

Most helpful comment

Setting Schema is only one of steps to make. You only said to the model that it is okay to accept to id on $block elements. But the editor does not know what to do with that attribute.

You need to set up conversion for a newly added attribute. Useful links:

https://ckeditor.com/docs/ckeditor5/latest/framework/guides/architecture/editing-engine.html#conversion (don't look at the diagram, it looks scary)

https://ckeditor.com/docs/ckeditor5/latest/api/module_core_editor_editor-Editor.html#member-conversion

https://ckeditor.com/docs/ckeditor5/latest/api/module_engine_conversion_conversion-Conversion.html#function-attributeToAttribute

All 11 comments

Setting Schema is only one of steps to make. You only said to the model that it is okay to accept to id on $block elements. But the editor does not know what to do with that attribute.

You need to set up conversion for a newly added attribute. Useful links:

https://ckeditor.com/docs/ckeditor5/latest/framework/guides/architecture/editing-engine.html#conversion (don't look at the diagram, it looks scary)

https://ckeditor.com/docs/ckeditor5/latest/api/module_core_editor_editor-Editor.html#member-conversion

https://ckeditor.com/docs/ckeditor5/latest/api/module_engine_conversion_conversion-Conversion.html#function-attributeToAttribute

I'm closing as "answered". Let us know if something's unclear.

Brilliant, thank you @scofalik! For anyone else curious, this is my new init in /ckeditor5-heading/src/headingediting.js:

init() {
    const editor = this.editor;
    const options = editor.config.get( 'heading.options' );
    const schema = editor.model.schema;
    const conversion = editor.conversion;

    const modelElements = [];
    for ( const option of options ) {
        // Skip paragraph - it is defined in required Paragraph feature.
        if ( option.model !== defaultModelElement ) {
            // Schema.
            schema.register( option.model, {
                inheritAllFrom: '$block',
                allowAttributes: [ 'id' ]
            } );

            conversion.elementToElement( option );
            conversion.attributeToAttribute( { model: 'id', view: 'id' } );

            modelElements.push( option.model );
        }
    }

    // Register the heading command for this option.
    editor.commands.add( 'heading', new HeadingCommand( editor, modelElements ) );
}

Just make sure if you're using cloud editing, that the cloud version of the document isn't overwriting your header with a previous attempt (by changing the documentId).

I think that you can move the attributeToAttribute out of the loop. As of now, you register multiple same converters :)

This is purely a personal enhancement so feel free to ignore, but I need a way to generate a random ID attribute for a heading when it's inserted. Does anyone have any tips on how I could achieve that? I've tried modifying this example:

// We will convert inserting "paragraph" model element into the model.
downcastDispatcher.on( 'insert:paragraph', ( evt, data, conversionApi ) => {
    // Remember to check whether the change has not been consumed yet and consume it.
    if ( conversionApi.consumable.consume( data.item, 'insert' ) ) {
        return;
    }
    ...

    // Remember to stop the event propagation.
    evt.stop();
} );

The code inside doesn't appear to fire when inserting a paragraph so I think I'm miss-understanding the downcastDispatcher class. Is there an event I can hook into when a heading is inserted in order to call setAttribute and give it a random ID?

@archonic

The code from the snippet should fire 馃. Does it not fire at all or it does not go past the first if? Maybe try adding the callback with higher priority? (add { priority: 'highest' } as a third param of .on() call).

As for adding custom ids to headers, probably the safest way to do that is to use a post-fixer that will add those. In the post-fixer, use editor.model.document.differ and look for insert changes, check if a header is inserted and if so, check if it has an id. If not, generate an id and add it to the element. Do not forget to add the attribute to the schema and specify conversion.

Here are related docs:

I got it figured out eventually. My approach before was:

  1. Allow the ID attribute (done)
  2. Listen for inserted content and filter to inserted headers
  3. Use setAttribute on that inserted header to add a token for the id attribute.

I couldn't get downcastDispatcher.on( 'insert:paragraph', ( evt, data, conversionApi ) => { ... } ) to fire, but I did get this to filter inserted header events:

_setupHeaderId() {
  const editor = this.editor;
  const acceptableNames = Array("heading1", "heading2", "heading3", "heading4", "heading5", "heading6");

  // Listen for changes to the document
  editor.model.document.registerPostFixer( writer => {
    const changes = editor.model.document.differ.getChanges();

    // Cycle through changes
    for (const entry of changes) {
      // Filter to inserted headings
      if ( entry.type == 'insert' && ( acceptableNames.indexOf(entry.name) != -1 ) ) {
        console.log('Header inserted ' + entry.name);
        // const newHeader = editor.model.change( writer => {
        //   return writer.createElement( entry.name, { id: this._generateToken() } );
        // } );

        // editor.model.insertContent( newHeader );

        // NOTE only return true if we modified the document
        return true;
      }
    }
  } );
}

While it fired, I didn't find it very useful. It seems getChanges() was never meant to let you select the associated inserted modelItem. I was shooting in the dark with inserting the header and it was very easy to hit an infinite loop which would freeze the browser.

It was much easier to just set the ID when the initial header was being inserted by the heading plugin, instead of listening to insertions and attempting to select and modify the inserted header. I already have a modified heading plugin to allow the ID. Here's my execute method in headercommand.js:

execute( options ) {
    const model = this.editor.model;
    const document = model.document;

    const modelElement = options.value;

    const acceptableElements = Array("heading1", "heading2", "heading3", "heading4", "heading5", "heading6");
    const isHeading = (acceptableElements.indexOf(modelElement) != -1);

    model.change( writer => {
        const blocks = Array.from( document.selection.getSelectedBlocks() )
            .filter( block => {
                return checkCanBecomeHeading( block, modelElement, model.schema );
            } );

        for ( const block of blocks ) {
            if ( !block.is( modelElement ) ) {
                writer.rename( block, modelElement );

                // Write the ID if it doesn't already have one
                // It may already have one if it was previously
                // a header and we're now making it a header again
                if (isHeading && block.getAttribute( 'id' ) == undefined ) {
                    writer.setAttribute( 'id', generateToken(), block);
                }
            }
        }
    } );
}

...

function generateToken() {
    const crypto = require('crypto');
    return 's' + crypto.randomBytes(3).toString('hex');
}

Thanks for the details and resources! I'm slowly learning how to do more involved CKEditor5 stuff.

While it fired, I didn't find it very useful. It seems getChanges() was never meant to let you select the associated inserted modelItem. I was shooting in the dark with inserting the header and it was very easy to hit an infinite loop which would freeze the browser.

Maybe this is a matter of docs not being clear enough or not having deep-dive guides. We know that those more advanced subjects are hard to grasp without a proper introduction. So, kudos to anyone who are trying and succeeding!

Actually, changing/fixing the content after it changes was the reason for Differ and post-fixers.

When you are in the post-fixer, the changes are already applied to the model. So you were basically adding new headers after all the new headers :).

As far as I can see in your code, this part:

// const newHeader = editor.model.change( writer => {
//   return writer.createElement( entry.name, { id: this._generateToken() } );
// } );

// editor.model.insertContent( newHeader );

// NOTE only return true if we modified the document
return true;

Should be, simply:

const insertedHeader = entry.position.nodeAfter;

if ( !insertedHeader.hasAttribute( 'id' ) ) {
    writer.setAttribute( insertedHeader, 'id', this._generateToken() );

    return true;
}

What would be the correct way to implement this code (e.g in a plugin to extend the current headers with an ID attribute) apart from editing headingediting.js?

@AroundtheGlobe I published the code here in this package: https://www.npmjs.com/package/ckeditor5-heading-with-id

I believe allowing IDs through configuration would be a better solution but I wasn't willing to wait. Hopefully the package helps.

@archonic thank you for your input. I got a reply in this topic as well which seems to do the same https://github.com/ckeditor/ckeditor5/issues/6627 . Still playing with all of this to make it fully operational.

Was this page helpful?
0 / 5 - 0 ratings