Implement plugin

This commit is contained in:
Sauli Anto
2019-08-31 20:48:37 +03:00
commit 13a10dcfdd
16 changed files with 3813 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
import Widget from '@ckeditor/ckeditor5-widget/src/widget';
import MathUI from './mathui';
import MathEditing from './mathediting';
export default class Math extends Plugin {
static get requires() {
return [ MathEditing, MathUI, Widget ];
}
static get pluginName() {
return 'Math';
}
}
+36
View File
@@ -0,0 +1,36 @@
import Command from '@ckeditor/ckeditor5-core/src/command';
import { getSelectedMathModelWidget } from './utils';
export default class MathCommand extends Command {
execute( equation ) {
const model = this.editor.model;
const selection = model.document.selection;
const selectedElement = selection.getSelectedElement();
model.change( writer => {
let mathtex;
if ( selectedElement && selectedElement.is( 'mathtex' ) ) {
// Update selected element
const mode = selectedElement.getAttribute( 'mode' );
const display = selectedElement.getAttribute( 'display' );
mathtex = writer.createElement( 'mathtex', { equation, mode, display } );
} else {
// Create new model element
mathtex = writer.createElement( 'mathtex', { equation, mode: 'script', display: true } );
}
model.insertContent( mathtex );
writer.setSelection( mathtex, 'on' );
} );
}
refresh() {
const model = this.editor.model;
const selection = model.document.selection;
const isAllowed = model.schema.checkChild( selection.focus.parent, 'mathtex' );
this.isEnabled = isAllowed;
const selectedEquation = getSelectedMathModelWidget( selection );
this.value = selectedEquation ? selectedEquation.getAttribute( 'equation' ) : null;
}
}
+160
View File
@@ -0,0 +1,160 @@
import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
import { toWidget } from '@ckeditor/ckeditor5-widget/src/utils';
import Widget from '@ckeditor/ckeditor5-widget/src/widget';
import MathCommand from './mathcommand';
import { renderEquation } from './utils';
export default class MathEditing extends Plugin {
static get requires() {
return [ Widget ];
}
static get pluginName() {
return 'MathEditing';
}
init() {
const editor = this.editor;
editor.commands.add( 'math', new MathCommand( editor ) );
this._defineSchema();
this._defineConverters();
}
_defineSchema() {
const schema = this.editor.model.schema;
schema.register( 'mathtex', {
allowWhere: '$text',
isInline: true,
isObject: true,
allowAttributes: [ 'equation', 'type', 'display' ]
} );
}
_defineConverters() {
const conversion = this.editor.conversion;
// View -> Model
conversion.for( 'upcast' )
// MathJax inline way (e.g. <script type="math/tex">\sqrt{\frac{a}{b}}</script>)
.elementToElement( {
view: {
name: 'script',
attributes: {
type: 'math/tex'
}
},
model: ( viewElement, modelWriter ) => {
const equation = viewElement.getChild( 0 ).data.trim();
return modelWriter.createElement( 'mathtex', { equation, type: 'script', display: false } );
}
} )
// MathJax display way (e.g. <script type="math/tex; mode=display">\sqrt{\frac{a}{b}}</script>)
.elementToElement( {
view: {
name: 'script',
attributes: {
type: 'math/tex; mode=display'
}
},
model: ( viewElement, modelWriter ) => {
const equation = viewElement.getChild( 0 ).data.trim();
return modelWriter.createElement( 'mathtex', { equation, type: 'script', display: true } );
}
} )
// CKEditor 4 way (e.g. <span class="math-tex">\( \sqrt{\frac{a}{b}} \)</span>)
.elementToElement( {
view: {
name: 'span',
classes: [ 'math-tex' ]
},
model: ( viewElement, modelWriter ) => {
let equation = viewElement.getChild( 0 ).data.trim();
// Remove delimiters (e.g. \( \) or \[ \])
const hasInlineDelimiters = equation.includes( '\\(' ) && equation.includes( '\\)' );
const hasDisplayDelimiters = equation.includes( '\\[' ) && equation.includes( '\\]' );
if ( hasInlineDelimiters || hasDisplayDelimiters ) {
equation = equation.substring( 2, equation.length - 2 ).trim();
}
return modelWriter.createElement( 'mathtex', { equation, type: 'span', display: hasDisplayDelimiters } );
}
} );
// Model -> View (element)
conversion.for( 'editingDowncast' ).elementToElement( {
model: 'mathtex',
view: ( modelItem, viewWriter ) => {
const widgetElement = createMathtexEditingView( modelItem, viewWriter );
return toWidget( widgetElement, viewWriter );
}
} );
// Model -> Data
conversion.for( 'dataDowncast' ).elementToElement( {
model: 'mathtex',
view: createMathtexView
} );
// Create view for editor
function createMathtexEditingView( modelItem, viewWriter ) {
const equation = modelItem.getAttribute( 'equation' );
const display = modelItem.getAttribute( 'display' );
// CKEngine render multiple times if using span instead of div
const mathtexView = viewWriter.createContainerElement( 'div', {
style: display ? 'display: block;' : 'display: inline-block;',
class: 'mathtex'
} );
// Div is formatted as parent container
const uiElement = viewWriter.createUIElement( 'div', null, function( domDocument ) {
const domElement = this.toDomElement( domDocument );
renderEquation( equation, domElement, 'mathjax', display );
return domElement;
} );
viewWriter.insert( viewWriter.createPositionAt( mathtexView, 0 ), uiElement );
return mathtexView;
}
// Create view for data
function createMathtexView( modelItem, viewWriter ) {
const equation = modelItem.getAttribute( 'equation' );
const type = modelItem.getAttribute( 'type' );
const display = modelItem.getAttribute( 'display' );
if ( type === 'span' ) {
const mathtexView = viewWriter.createContainerElement( 'span', {
class: 'math-tex'
} );
if ( display ) {
viewWriter.insert( viewWriter.createPositionAt( mathtexView, 0 ), viewWriter.createText( '\\[' + equation + '\\]' ) );
} else {
viewWriter.insert( viewWriter.createPositionAt( mathtexView, 0 ), viewWriter.createText( '\\(' + equation + '\\)' ) );
}
return mathtexView;
} else {
const mathtexView = viewWriter.createContainerElement( 'script', {
type: display ? 'math/tex; mode=display': 'math/tex'
} );
viewWriter.insert( viewWriter.createPositionAt( mathtexView, 0 ), viewWriter.createText( equation ) );
return mathtexView;
}
}
}
}
+208
View File
@@ -0,0 +1,208 @@
import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
import ClickObserver from '@ckeditor/ckeditor5-engine/src/view/observer/clickobserver';
import ContextualBalloon from '@ckeditor/ckeditor5-ui/src/panel/balloon/contextualballoon';
import clickOutsideHandler from '@ckeditor/ckeditor5-ui/src/bindings/clickoutsidehandler';
import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
import MainFormView from './ui/mainformview';
// Need math commands from there
import MathEditing from './mathediting';
import pluginIcon from '../theme/icons/icon.svg';
const mathKeystroke = 'Ctrl+M';
export default class MathUI extends Plugin {
static get requires() {
return [ ContextualBalloon, MathEditing ];
}
static get pluginName() {
return 'MathUI';
}
init() {
const editor = this.editor;
editor.editing.view.addObserver( ClickObserver );
this._form = this._createFormView();
this._balloon = editor.plugins.get( ContextualBalloon );
this._createToolbarMathButton();
this._enableUserBalloonInteractions();
}
destroy() {
super.destroy();
this._form.destroy();
}
_showUI() {
const editor = this.editor;
const mathCommand = editor.commands.get( 'math' );
if ( !mathCommand.isEnabled ) {
return;
}
this._addFormView();
this._balloon.showStack( 'main' );
}
_createFormView() {
const editor = this.editor;
const mathCommand = editor.commands.get( 'math' );
const engine = 'mathjax';
const formView = new MainFormView( editor.locale, engine );
formView.mathInputView.bind( 'value' ).to( mathCommand, 'value' );
// Listen to 'submit' button click
this.listenTo( formView, 'submit', () => {
editor.execute( 'math', formView.equation );
this._closeFormView();
} );
// Listen to cancel button click
this.listenTo( formView, 'cancel', () => {
this._closeFormView();
} );
// Close plugin ui, if esc is pressed (while ui is focused)
formView.keystrokes.set( 'esc', ( data, cancel ) => {
this._closeFormView();
cancel();
} );
return formView;
}
_addFormView() {
if ( this._isFormInPanel ) {
return;
}
const editor = this.editor;
const mathCommand = editor.commands.get( 'math' );
this._balloon.add( {
view: this._form,
position: this._getBalloonPositionData(),
} );
if ( this._balloon.visibleView === this._form ) {
this._form.mathInputView.select();
}
this._form.equation = mathCommand.value || '';
}
_hideUI() {
if ( !this._isFormInPanel ) {
return;
}
const editor = this.editor;
this.stopListening( editor.ui, 'update' );
this.stopListening( this._balloon, 'change:visibleView' );
editor.editing.view.focus();
// Remove form first because it's on top of the stack.
this._removeFormView();
}
_closeFormView() {
const mathCommand = this.editor.commands.get( 'math' );
if ( mathCommand.value !== undefined ) {
this._removeFormView();
} else {
this._hideUI();
}
}
_removeFormView() {
if ( this._isFormInPanel ) {
this._form.saveButtonView.focus();
this._balloon.remove( this._form );
this.editor.editing.view.focus();
}
}
_getBalloonPositionData() {
const view = this.editor.editing.view;
const viewDocument = view.document;
const target = view.domConverter.viewRangeToDom( viewDocument.selection.getFirstRange() );
return { target };
}
_createToolbarMathButton() {
const editor = this.editor;
const mathCommand = editor.commands.get( 'math' );
const t = editor.t;
// Handle the `Ctrl+M` keystroke and show the panel.
editor.keystrokes.set( mathKeystroke, ( keyEvtData, cancel ) => {
// Prevent focusing the search bar in FF and opening new tab in Edge. #153, #154.
cancel();
if ( mathCommand.isEnabled ) {
this._showUI();
}
} );
this.editor.ui.componentFactory.add( 'math', locale => {
const button = new ButtonView( locale );
button.isEnabled = true;
button.label = t( 'Insert math' );
button.icon = pluginIcon;
button.keystroke = mathKeystroke;
button.tooltip = true;
button.isToggleable = true;
button.bind( 'isEnabled' ).to( mathCommand, 'isEnabled' );
this.listenTo( button, 'execute', () => this._showUI() );
return button;
} );
}
_enableUserBalloonInteractions() {
// Close the panel on the Esc key press when the editable has focus and the balloon is visible.
this.editor.keystrokes.set( 'Esc', ( data, cancel ) => {
if ( this._isUIVisible ) {
this._hideUI();
cancel();
}
} );
// Close on click outside of balloon panel element.
clickOutsideHandler( {
emitter: this._form,
activator: () => this._isFormInPanel,
contextElements: [ this._balloon.view.element ],
callback: () => this._hideUI()
} );
}
get _isUIVisible() {
const visibleView = this._balloon.visibleView;
return visibleView == this._form;
}
get _isFormInPanel() {
return this._balloon.hasView( this._form );
}
}
+167
View File
@@ -0,0 +1,167 @@
import View from '@ckeditor/ckeditor5-ui/src/view';
import ViewCollection from '@ckeditor/ckeditor5-ui/src/viewcollection';
import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
import LabeledInputView from '@ckeditor/ckeditor5-ui/src/labeledinput/labeledinputview';
import InputTextView from '@ckeditor/ckeditor5-ui/src/inputtext/inputtextview';
import LabelView from '@ckeditor/ckeditor5-ui/src/label/labelview';
import KeystrokeHandler from '@ckeditor/ckeditor5-utils/src/keystrokehandler';
import FocusTracker from '@ckeditor/ckeditor5-utils/src/focustracker';
import FocusCycler from '@ckeditor/ckeditor5-ui/src/focuscycler';
import checkIcon from '@ckeditor/ckeditor5-core/theme/icons/check.svg';
import cancelIcon from '@ckeditor/ckeditor5-core/theme/icons/cancel.svg';
import submitHandler from '@ckeditor/ckeditor5-ui/src/bindings/submithandler';
import MathView from './mathview';
import '../../theme/mathform.css';
export default class MainFormView extends View {
constructor( locale, engine ) {
super( locale );
const t = locale.t;
// Create key event & focus trackers
this._createKeyAndFocusTrackers();
// Equation input
this.mathInputView = this._createMathInput();
// Preview label
this.previewLabel = new LabelView( locale );
this.previewLabel.text = t( 'Equation preview' );
// Math element
this.mathView = new MathView( engine, locale );
// Submit button
this.saveButtonView = this._createButton( t( 'Save' ), checkIcon, 'ck-button-save', null );
this.saveButtonView.type = 'submit';
// Cancel button
this.cancelButtonView = this._createButton( t( 'Cancel' ), cancelIcon, 'ck-button-cancel', 'cancel' );
// Add UI elements to template
this.setTemplate( {
tag: 'form',
attributes: {
class: [
'ck',
'ck-math-form',
],
tabindex: '-1'
},
children: [
{
tag: 'div',
attributes: {
class: [
'ck-math-view'
]
},
children: [
this.mathInputView,
this.previewLabel,
this.mathView
]
},
this.saveButtonView,
this.cancelButtonView,
],
} );
}
render() {
super.render();
// Prevent default form submit event & trigger custom 'submit'
submitHandler( {
view: this,
} );
// Register form elements to focusable elements
const childViews = [
this.mathInputView,
this.saveButtonView,
this.cancelButtonView,
];
childViews.forEach( v => {
this._focusables.add( v );
this.focusTracker.add( v.element );
} );
// Listen to keypresses inside form element
this.keystrokes.listenTo( this.element );
}
focus() {
this._focusCycler.focusFirst();
}
get equation() {
return this.mathInputView.inputView.element.value;
}
set equation( equation ) {
this.mathInputView.inputView.element.value = equation;
this.mathView.value = equation;
}
_createKeyAndFocusTrackers() {
this.focusTracker = new FocusTracker();
this.keystrokes = new KeystrokeHandler();
this._focusables = new ViewCollection();
this._focusCycler = new FocusCycler( {
focusables: this._focusables,
focusTracker: this.focusTracker,
keystrokeHandler: this.keystrokes,
actions: {
focusPrevious: 'shift + tab',
focusNext: 'tab'
}
} );
}
_createMathInput() {
const t = this.locale.t;
// Create equation input
const mathInput = new LabeledInputView( this.locale, InputTextView );
const inputView = mathInput.inputView;
mathInput.infoText = t( 'Insert equation in TeX format.' );
inputView.on( 'input', () => {
this.mathView.value = inputView.element.value;
} );
return mathInput;
}
_createButton( label, icon, className, eventName ) {
const button = new ButtonView( this.locale );
button.set( {
label,
icon,
tooltip: true
} );
button.extendTemplate( {
attributes: {
class: className
}
} );
if ( eventName ) {
button.delegate( 'execute' ).to( this, eventName );
}
return button;
}
}
+35
View File
@@ -0,0 +1,35 @@
import View from '@ckeditor/ckeditor5-ui/src/view';
import { renderEquation } from '../utils';
export default class MathView extends View {
constructor( engine, locale ) {
super( locale );
this.engine = engine;
this.set( 'value', '' );
this.on( 'change:value', () => {
this.updateMath();
} );
this.setTemplate( {
tag: 'div',
attributes: {
class: [
'ck',
'ck-math-preview'
],
}
} );
}
updateMath() {
renderEquation( this.value, this.element, this.engine );
}
render() {
super.render();
this.updateMath();
}
}
+33
View File
@@ -0,0 +1,33 @@
export function renderEquation( equation, element, engine = 'katex', display = false ) {
if ( engine === 'mathjax' && typeof katex !== 'mathjax' ) {
if (display) {
element.innerHTML = '\\[' + equation + '\\]';
} else {
element.innerHTML = '\\(' + equation + '\\)';
}
/* eslint-disable */
MathJax.Hub.Queue( [ 'Typeset', MathJax.Hub, element ] );
/* eslint-enable */
}
else if ( engine === 'katex' && typeof katex !== 'undefined' ) {
/* eslint-disable */
katex.render( equation, element, {
throwOnError: false,
displayMode: display
} );
/* eslint-enable */
} else {
element.innerHTML = equation;
console.warn( 'math-tex-typesetting-missing: Missing the mathematical typesetting engine for tex.' );
}
}
export function getSelectedMathModelWidget( selection ) {
const selectedElement = selection.getSelectedElement();
if ( selectedElement && selectedElement.is( 'mathtex' ) ) {
return selectedElement;
}
return null;
}