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.51k stars 3.7k forks source link

view-writer-invalid-range-container with first & last child, when a parent uses `elementToStructure` editing downcast #16055

Open jake-netflix opened 7 months ago

jake-netflix commented 7 months ago

📝 Provide detailed reproduction steps (if any)

Have a parent that uses elementToStructure downcast. Have multiple children. They can use elementToElement.

Using model.change, call setAttribute on the first or last child, and it will throw this exception:

Uncaught CKEditorError: view-writer-invalid-range-container
Read more: https://ckeditor.com/docs/ckeditor5/latest/support/error-codes.html#error-view-writer-invalid-range-container
    at validateRangeContainer (downcastwriter.js:1687:15)
    at DowncastWriter.remove (downcastwriter.js:617:9)
    at DowncastDispatcher.<anonymous> (downcasthelpers.js:807:46)
    at DowncastDispatcher.fire (emittermixin.js:146:47)
    at DowncastDispatcher._convertRemove (downcastdispatcher.js:277:14)
    at DowncastDispatcher.convertChanges (downcastdispatcher.js:147:22)
    at editingcontroller.js:57:41
    at View.change (view.js:414:36)
    at EditingController.listenTo.priority (editingcontroller.js:56:23)
    at Document.fire (emittermixin.js:146:47)

More detailed example:

Here's a full code example to reproduce using the bare minimum CKEditor features:

import React from 'react';
import { CKEditor } from '@ckeditor/ckeditor5-react';
import { BalloonEditor } from '@ckeditor/ckeditor5-editor-balloon';
import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
import { EditorConfig } from '@ckeditor/ckeditor5-core';
import Essentials from '@ckeditor/ckeditor5-essentials/src/essentials';
import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
import CKEditorInspector from '@ckeditor/ckeditor5-inspector';
import ModelElement from '@ckeditor/ckeditor5-engine/src/model/element';
import { toWidget } from '@ckeditor/ckeditor5-widget';

class MyParent extends Plugin {
  init() {
    const editor = this.editor;

    editor.model.schema.register('myParent', {
      isObject: true,
      allowIn: ['$root'],
    });

    editor.conversion.for('upcast').elementToElement({
      model: 'myParent',
      view: {
        name: 'div',
        classes: 'my-parent',
      },
    });

    editor.conversion.for('dataDowncast').elementToElement({
      model: 'myParent',
      view: {
        name: 'div',
        classes: 'my-parent',
      },
    });

    // Key thing: The parent uses elementToStructure downcast with a UI element:
    editor.conversion.for('editingDowncast').elementToStructure({
      model: 'myParent',
      view: (modelElement, { writer }) => {
        // The main container for our view
        const container = writer.createContainerElement('div', { class: 'my-parent' });

        // A UI element in our view container
        const uiElement = writer.createUIElement('div', { class: 'my-parent-ui' }, function (domDocument) {
          const domElement = this.toDomElement(domDocument);
          domElement.textContent = 'This is a parent element with some UI!';
          return domElement;
        });
        writer.insert(writer.createPositionAt(container, 'end'), uiElement);

        // Container for the child views to render
        writer.insert(
          writer.createPositionAt(container, 'end'),
          writer.createContainerElement('div', { class: 'my-children' }, [writer.createSlot()])
        );

        return toWidget(container, writer);
      },
    });
  }
}

class MyChild extends Plugin {
  init() {
    const editor = this.editor;

    editor.model.schema.register('myChild', {
      isObject: true,
      allowIn: ['myParent'],
    });

    editor.conversion.for('upcast').elementToElement({
      model: 'myChild',
      view: {
        name: 'div',
        classes: 'my-child',
      },
    });

    editor.conversion.for('dataDowncast').elementToElement({
      model: 'myChild',
      view: {
        name: 'div',
        classes: 'my-child',
      },
    });

    editor.conversion.for('editingDowncast').elementToElement({
      model: {
        name: 'myChild',
        attributes: ['myValue'],
      },
      view: (modelElement, { writer }) => {
        // The main container for our view
        const container = writer.createContainerElement('div', { class: 'my-child' });
        console.log('myChild downcast (should update every setAttribute)');

        const _this = this;
        // A UI element in our view, which will be used to change attribute values
        const uiElement = writer.createUIElement(
          'div',
          { class: 'my-child-ui', 'data-cke-ignore-events': true },
          function (domDocument) {
            const domElement = this.toDomElement(domDocument);
            // Give some options to change the attribute value.
            // In a real world use case, this is a React component.
            domElement.textContent = 'Current option is: ' + modelElement.getAttribute('myValue') + ' ';
            _this.appendButton('A', modelElement, domElement);
            _this.appendButton('B', modelElement, domElement);
            _this.appendButton('C', modelElement, domElement);
            return domElement;
          }
        );
        writer.insert(writer.createPositionAt(container, 'end'), uiElement);

        return container;
      },
    });
  }

  private appendButton(value: string, modelElement: ModelElement, domElement: HTMLElement) {
    const button = document.createElement('button');
    button.textContent = 'Option ' + value;
    button.onclick = () => {
      this.editor.model.change(writer => {
        writer.setAttribute('myValue', value, modelElement);
      });
    };
    domElement.appendChild(button);
  }
}

const editorConfig: EditorConfig = {
  plugins: [Essentials, Paragraph, MyParent, MyChild],
  initialData: `
      <div class="my-parent">
          <div class="my-child">&nbsp;</div>
          <div class="my-child">&nbsp;</div>
          <div class="my-child">&nbsp;</div>
          <div class="my-child">&nbsp;</div>
          <div class="my-child">&nbsp;</div>
          <div class="my-child">&nbsp;</div>
          <div class="my-child">&nbsp;</div>
      </div>`,
};

function CKEditorPlayground() {
  return (
    <div style={{ width: 900, margin: '40px auto' }}>
      <div style={{ margin: 40 }}>
        This is a vanilla CKEditor playground for the purpose of reproducing CKEditor conditions to share with their
        team.
      </div>
      <CKEditor
        editor={BalloonEditor}
        config={editorConfig}
        onReady={editor => {
          console.log('CKEditor is ready. Editor instance: ', editor);
          CKEditorInspector.attach(editor);
        }}
      />
    </div>
  );
}

Given the above, it's basically a parent with some children. The parent has a UiElement which represents some controls you might use to manage that widget.

Each child also has its own UiElement which represents some custom UI that is used to change an attribute value.

In this example, each child just has 3 buttons (A, B, C) which set its attribute to A, B, or C.

Example of how it looks in the initial state:

Screenshot 2024-03-18 at 9 42 45 PM

Example of the model in the initial state

Screenshot 2024-03-18 at 9 42 54 PM

Example if you click the buttons (not first or last child):

As we can see, it updates the attribute, reconverts the model, allowing the editingDowncast to re-render the UI. Screenshot 2024-03-18 at 9 43 13 PM

Example of the model after the above state:

Screenshot 2024-03-18 at 9 43 18 PM

And then, as soon as you click the buttons on the first or last child:

Screenshot 2024-03-18 at 9 43 28 PM

✔ Expected result

You should be able to call setAttribute on your models, regardless of their position in their parent, and expect a qualifying downcast dispatcher to fire, without any errors.

❌ Actual result

Throws the above exception.

❓ Possible solution

Not sure. I've tried custom implementations of modelToViewPosition but even with a correct view position, the error occurs.

The setAttribute ultimately turns into a reinsertion (I believe via reduceChanges). So if instead override modelDispatcher.on('remove:myModel') and use some custom implementation, it fixes the removal, but then the insertion is more complicated. I don't think it's sustainable to override CKEditor default behavior for a custom removal & insertion, as it would cause even more issues in the future when CKEditor core logic gets updated.

📃 Other details


If you'd like to see this fixed sooner, add a 👍 reaction to this post.

jake-netflix commented 7 months ago

Additional update here. The same issue occurs when using raw elements (not just ui elements).