reactjsckeditor5

CKEditor5+React: How to update the node represented by an inline widget?


We are attempting an inline React component widget inside CKEditor5. We've gotten it to render, but now aren't sure how to update the model node. We followed the React component tutorial, but modified it for a inline widget.

We have a component called BracketOption that's a essentially a button with a state; when user clicks the button, we want to update the optedState attribute of the bracketOption model element. To achieve this, we pass a callback into our component. Inside the callback, how do we update the node in the model? We've tried searching the document for the node (below). We attempt to modify it with model.change(), and the node we found gets updated, but the changes don't reflect in the Model inspector.

The bracketOption starts with state UNDECIDED. After clicking it to set it to OPTED_IN (green), we want to update the model attribute accordingly (see below). enter image description here App screenshot

// utils.ts
export function getChildNodeByAttribute(node: Element, attributeName: string, attributeValue: string) : Element | null{
    for (let i = 0; i < node.childCount; i++) {
        const child = node.getChild(i)
        if (child instanceof Element && child.getAttribute(attributeName) === attributeValue) {
            return child
        }

        const result = getChildNodeByAttribute(child as Element, attributeName, attributeValue)
        if (result) {
            return result
        }
    }
    
    return null
}
// bracketOptionEditing.js
import { Plugin } from '@ckeditor/ckeditor5-core'

import { getChildNodeByAttribute } from './utils';
import { Widget, toWidget, toWidgetEditable, viewToModelPositionOutsideModelElement } from '@ckeditor/ckeditor5-widget';

export default class BracketOptionEditing extends Plugin {
    static get requires() {
        return [Widget];
    }

    init() {
        console.log('BracketOptionEditing was initialized')

        this._defineSchema()
        this._defineConverters()

        this.editor.editing.mapper.on(
            'viewToModelPosition',
            viewToModelPositionOutsideModelElement(this.editor.model, viewElement => viewElement.hasClass('bracket-option'))
        );
    }

    _defineSchema() {
        const schema = this.editor.model.schema

        schema.register('bracketOption', {
            // Behaves like a self-contained inline object (e.g. an inline image)
            // allowed in places where $text is allowed (e.g. in paragraphs).
            inheritAllFrom: '$inlineObject',

            allowAttributes: [
                'id',
                'value', // content of bracket option (text for now; TODO allow composite content including units-of-measure)
                'optedState' // 'undecided', 'optedIn', or 'optedOut'
            ]
        })
    }

    _defineConverters() {
        const editor = this.editor
        const model = editor.model
        const conversion = editor.conversion
        const renderBracketOption = editor.config.get('bracketOption').bracketOptionRenderer

        // **NOTE: Other converters omitted for brevity**

        // <bracketOption> convert model to editing view
        conversion.for('editingDowncast').elementToElement({
            model: 'bracketOption',
            view: (modelElement, { writer: viewWriter }) => {
                // In the editing view, the model <bracketOption> corresponds to:
                //
                // <span class="bracket-option" data-id="...">
                //     <span class="bracket-option__react-wrapper">
                //         <BracketOption /> (React component)
                //     </span>
                // </span>
                const id = modelElement.getAttribute('id')
                const value = modelElement.getAttribute('value')
                const optedState = modelElement.getAttribute('optedState')

                // The outermost <span class="bracket-option" data-id="..."></span> element.
                const span = viewWriter.createContainerElement('span', {
                    class: 'bracket-option',
                    'data-id': id
                })

                // The inner <span class="bracket-option__react-wrapper"></span> element.
                // This element will host a React <BracketOption /> component.
                const reactWrapper = viewWriter.createRawElement('span', {
                    class: 'bracket-option__react-wrapper'
                }, function (domElement) {
                    // This is the place where React renders the actual bracket-option preview hosted
                    // by a UIElement in the view. You are using a function (renderer) passed as
                    // editor.config.bracket-options#bracketOptionRenderer.
                    renderBracketOption(id, value, optedState, (newState) => {
                        var root = model.document.getRoot()
                        var node = getChildNodeByAttribute(root, 'id', id)
                        if (node) {
                            // NOTE: This finds a match and updates its attributes, but the inspector's Model state does not reflect the change.
                            var root = model.document.getRoot()
                            var node = getChildNodeByAttribute(root, 'id', id)
                            if (node) {
                                writer.setAttribute('optedState', newState, node)
                            }
                        }
                        console.log(newState)
                    }, domElement);
                })

                viewWriter.insert(viewWriter.createPositionAt(span, 0), reactWrapper)

                return toWidget(span, viewWriter, { label: 'bracket option widget' })
            }
        });
    }
}
// BracketOption.tsx
import React from 'react';
import { OptedState } from '../model/optionItemState';

interface BracketOptionProps {
    id: string;
    value: string;
    initialOptedState: OptedState;
    onOptedStateChanged: (newState: OptedState) => void;
}

const BracketOption: React.FC<BracketOptionProps> = ({ id, value, initialOptedState, onOptedStateChanged }) => {
    const [optedState, setOptedState] = React.useState<OptedState>(initialOptedState);
    const handleClick = React.useCallback(() => {
        const newState = optedState === OptedState.OptedIn ? OptedState.OptedOut : OptedState.OptedIn;
        setOptedState(newState);
        onOptedStateChanged?.(newState);

    }, [onOptedStateChanged, optedState]);

    let buttonStyle = {};
    if (optedState === OptedState.OptedIn) {
        buttonStyle = { backgroundColor: 'green' };
    } else if (optedState === OptedState.OptedOut) {
        buttonStyle = { backgroundColor: 'white' };
    } else {
        buttonStyle = { backgroundColor: 'lightgray' };
    }

    return (
        <span data-id={id} data-opted-state={optedState}>
            <button id={id} onClick={handleClick} style={buttonStyle}>{value}</button>
        </span>
    );
};

export default BracketOption;
// App.tsx

import React, { Component } from 'react';

import { CKEditor } from '@ckeditor/ckeditor5-react';
import CKEditorInspector from '@ckeditor/ckeditor5-inspector';

// NOTE: Use the editor from source (not a build)!
import { ClassicEditor } from '@ckeditor/ckeditor5-editor-classic';

import { Essentials } from '@ckeditor/ckeditor5-essentials';
import { Bold, Italic } from '@ckeditor/ckeditor5-basic-styles';
import { Paragraph } from '@ckeditor/ckeditor5-paragraph';
import UnitsOfMeasure from './ckeditor/unitsOfMeasure';
import { default as BracketOptionPlugin } from './ckeditor/bracketOption';
import { OptedState } from './model/optionItemState';
import { createRoot } from 'react-dom/client';
import BracketOption from './react/BracketOption';

const editorConfiguration = {
    plugins: [Essentials, Bold, Italic, Paragraph, UnitsOfMeasure, BracketOptionPlugin],
    bracketOption: {
        bracketOptionRenderer: (
            id: string,
            value: string,
            optedState: OptedState,
            onOptedStateChanged: (newState: OptedState) => void,
            domElement: HTMLElement,
        ) => {
            const root = createRoot(domElement);

            root.render(
                <BracketOption id={id} value={value} initialOptedState={optedState} onOptedStateChanged={onOptedStateChanged} />
            );
        }
    }
};

const intialData = "<p>After construction ends, prior to occupancy and with all interior finishes <span class='bracket-option' data-id='123' data-opted-state='UNDECIDED'>installed</span>, perform a building flush-out by supplying a total volume of <span class='units-of-measure'>{14,000 cu. ft. (4 300 000 L)}</span> of outdoor air per <span class='units-of-measure'>{sq. ft. (sq. m)}</span> of floor area while maintaining an internal temperature of at least <span class='units-of-measure'>{60 deg F (16 deg C)}</span> and a relative humidity no higher than 60 percent.</p>";

class App extends Component {
    render() {
        return (
            <div className="App">
                <h2>Using CKEditor&nbsp;5 from source in React</h2>
                <CKEditor
                    editor={ClassicEditor}
                    config={editorConfiguration}
                    data={intialData}
                    onReady={editor => {
                        // You can store the "editor" and use when it is needed.
                        console.log('Editor is ready to use!', editor);
                        CKEditorInspector.attach(editor);
                    }}
                    onChange={(event) => {
                        console.log(event);
                    }}
                    onBlur={(event, editor) => {
                        console.log('Blur.', editor);
                    }}
                    onFocus={(event, editor) => {
                        console.log('Focus.', editor);
                    }}
                />
            </div>
        );
    }
}

export default App;

Solution

  • It turned out I was inspecting the wrong editor instance! For some reason <App> called the editor's onReady() twice, resulting in a "second editor" from the point of view of the CKEditor5 Inspector. Once I selected the second instance, the changes reflected.

    enter image description here

    I was also able to simplify the callback to use the modelElement param from the lambda, removing the need to search the tree for the node.

                conversion.for('editingDowncast').elementToElement({
                model: 'bracketOption',
                view: (modelElement, { writer: viewWriter }) => {
                    // In the editing view, the model <bracketOption> corresponds to:
                    //
                    // <span class="bracket-option" data-id="...">
                    //     <span class="bracket-option__react-wrapper">
                    //         <BracketOption /> (React component)
                    //     </span>
                    // </span>
                    const id = modelElement.getAttribute('id')
                    const value = modelElement.getAttribute('value')
                    const optedState = modelElement.getAttribute('optedState')
    
                    // The outermost <span class="bracket-option" data-id="..."></span> element.
                    const span = viewWriter.createContainerElement('span', {
                        class: 'bracket-option',
                        'data-id': id
                    })
    
                    // The inner <span class="bracket-option__react-wrapper"></span> element.
                    // This element will host a React <BracketOption /> component.
                    const reactWrapper = viewWriter.createRawElement('span', {
                        class: 'bracket-option__react-wrapper'
                    }, function (domElement) {
                        // This is the place where React renders the actual bracket-option preview hosted
                        // by a UIElement in the view. You are using a function (renderer) passed as
                        // editor.config.bracket-options#bracketOptionRenderer.
                        renderBracketOption(id, value, optedState, (newState) => {
                            model.change(writer => {
                                writer.setAttribute('optedState', newState, modelElement)
                            })
                            console.log(newState)
                        }, domElement);
                    })
    
                    viewWriter.insert(viewWriter.createPositionAt(span, 0), reactWrapper)
    
                    return toWidget(span, viewWriter, { label: 'bracket option widget' })
                }
            })