我们正在尝试在 CKEditor5 中构建内联 React 组件小部件。我们已经让它渲染,但现在不确定如何更新模型节点。我们遵循了 React 组件教程,但将其修改为内联小部件。
我们有一个名为
BracketOption
的组件,它本质上是一个带有状态的按钮;当用户单击按钮时,我们要更新 optedState
模型元素的 bracketOption
属性。为了实现这一点,我们将回调传递到我们的组件中。 在回调中,我们如何更新模型中的节点?我们尝试在document
中搜索节点(如下)。我们尝试使用 model.change()
修改它,我们找到的节点会更新,但更改不会反映在模型检查器中。
bracketOption
以“UNDECIDED”状态开始。单击将其设置为 OPTED_IN(绿色)后,我们要相应地更新模型属性(见下文)。
// 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 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;