chore(ckeditor5-math): integrate source code

This commit is contained in:
Elian Doran
2025-05-04 22:16:32 +03:00
parent ee6e111a85
commit 5d42b942ba
19 changed files with 67 additions and 168 deletions

View File

@@ -1,7 +1,35 @@
import type { Math } from './index.js';
import type Math from './math.js';
import MathCommand from './mathcommand.js';
import MathEditing from './mathediting.js';
import MathUI from './mathui.js';
import { KatexOptions } from './typings-external.js';
declare module 'ckeditor5' {
interface PluginsMap {
[ Math.pluginName ]: Math;
[ MathEditing.pluginName ]: MathEditing;
[ MathUI.pluginName ]: MathUI;
}
interface CommandsMap {
math: MathCommand;
}
interface EditorConfig {
math?: {
engine?:
| 'mathjax'
| 'katex'
| ( ( equation: string, element: HTMLElement, display: boolean ) => void )
| undefined;
lazyLoad?: undefined | ( () => Promise<void> );
outputType?: 'script' | 'span' | undefined;
className?: string | undefined;
forceOutputType?: boolean | undefined;
enablePreview?: boolean | undefined;
previewClassName?: Array<string> | undefined;
popupClassName?: Array<string> | undefined;
katexRenderOptions?: Partial<KatexOptions> | undefined;
};
}
}

View File

@@ -0,0 +1,57 @@
import { Plugin, logWarning, blockAutoformatEditing } from 'ckeditor5';
// eslint-disable-next-line ckeditor5-rules/allow-imports-only-from-main-package-entry-point
import Math from './math.js';
import MathCommand from './mathcommand.js';
import MathUI from './mathui.js';
export default class AutoformatMath extends Plugin {
public static get requires() {
return [ Math, 'Autoformat' ] as const;
}
/**
* @inheritDoc
*/
public init(): void {
const editor = this.editor;
if ( !editor.plugins.has( 'Math' ) ) {
logWarning( 'autoformat-math-feature-missing', editor );
}
}
public afterInit(): void {
const editor = this.editor;
const command = editor.commands.get( 'math' );
if ( command instanceof MathCommand ) {
const callback = () => {
if ( !command.isEnabled ) {
return false;
}
command.display = true;
// Wait until selection is removed.
window.setTimeout(
() => {
const mathUIInstance = editor.plugins.get( 'MathUI' );
if ( mathUIInstance instanceof MathUI ) {
mathUIInstance._showUI();
}
},
50
);
};
// @ts-expect-error: blockAutoformatEditing expects an Autoformat instance even though it works with any Plugin instance
blockAutoformatEditing( editor, this, /^\$\$$/, callback );
// @ts-expect-error: blockAutoformatEditing expects an Autoformat instance even though it works with any Plugin instance
blockAutoformatEditing( editor, this, /^\\\[$/, callback );
}
}
public static get pluginName() {
return 'AutoformatMath' as const;
}
}

View File

@@ -0,0 +1,133 @@
import { Clipboard, Plugin, type Editor, LivePosition, LiveRange, Undo } from 'ckeditor5';
import { extractDelimiters, hasDelimiters, delimitersCounts } from './utils.js';
export default class AutoMath extends Plugin {
public static get requires() {
return [ Clipboard, Undo ] as const;
}
public static get pluginName() {
return 'AutoMath' as const;
}
private _timeoutId: null | number;
private _positionToInsert: null | LivePosition;
constructor( editor: Editor ) {
super( editor );
this._timeoutId = null;
this._positionToInsert = null;
}
public init(): void {
const editor = this.editor;
const modelDocument = editor.model.document;
this.listenTo( editor.plugins.get( Clipboard ), 'inputTransformation', () => {
const firstRange = modelDocument.selection.getFirstRange();
if ( !firstRange ) {
return;
}
const leftLivePosition = LivePosition.fromPosition( firstRange.start );
leftLivePosition.stickiness = 'toPrevious';
const rightLivePosition = LivePosition.fromPosition( firstRange.end );
rightLivePosition.stickiness = 'toNext';
modelDocument.once( 'change:data', () => {
this._mathBetweenPositions(
leftLivePosition,
rightLivePosition
);
leftLivePosition.detach();
rightLivePosition.detach();
},
{ priority: 'high' }
);
}
);
editor.commands.get( 'undo' )?.on( 'execute', () => {
if ( this._timeoutId ) {
window.clearTimeout( this._timeoutId );
this._positionToInsert?.detach();
this._timeoutId = null;
this._positionToInsert = null;
}
}, { priority: 'high' } );
}
private _mathBetweenPositions(
leftPosition: LivePosition,
rightPosition: LivePosition
) {
const editor = this.editor;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const mathConfig = this.editor.config.get( 'math' );
const equationRange = new LiveRange( leftPosition, rightPosition );
const walker = equationRange.getWalker( { ignoreElementEnd: true } );
let text = '';
// Get equation text
for ( const node of walker ) {
if ( node.item.is( '$textProxy' ) ) {
text += node.item.data;
}
}
text = text.trim();
// Skip if don't have delimiters
if ( !hasDelimiters( text ) || delimitersCounts( text ) !== 2 ) {
return;
}
const mathCommand = editor.commands.get( 'math' );
// Do not anything if math element cannot be inserted at the current position
if ( !mathCommand?.isEnabled ) {
return;
}
this._positionToInsert = LivePosition.fromPosition( leftPosition );
// With timeout user can undo conversation if want use plain text
this._timeoutId = window.setTimeout( () => {
editor.model.change( writer => {
this._timeoutId = null;
writer.remove( equationRange );
let insertPosition: LivePosition | null;
// Check if position where the math element should be inserted is still valid.
if ( this._positionToInsert?.root.rootName !== '$graveyard' ) {
insertPosition = this._positionToInsert;
}
editor.model.change( innerWriter => {
const params = Object.assign( extractDelimiters( text ), {
type: mathConfig?.outputType
} );
const mathElement = innerWriter.createElement( params.display ? 'mathtex-display' : 'mathtex-inline', params
);
editor.model.insertContent( mathElement, insertPosition );
innerWriter.setSelection( mathElement, 'on' );
} );
this._positionToInsert?.detach();
this._positionToInsert = null;
} );
}, 100 );
}
}

View File

@@ -1,7 +1,9 @@
import ckeditor from './../theme/icons/ckeditor.svg';
import './augmentation.js';
import "../theme/mathform.css";
export { default as Math } from './math.js';
export { default as AutoformatMath } from './autoformatmath.js';
export const icons = {
ckeditor

View File

@@ -1,39 +1,14 @@
import { Plugin, ButtonView } from 'ckeditor5';
import ckeditor5Icon from '../theme/icons/ckeditor.svg';
import { Plugin, Widget } from 'ckeditor5';
import MathEditing from './mathediting.js';
import MathUI from './mathui.js';
import AutoMath from './automath.js';
export default class Math extends Plugin {
public static get requires() {
return [ MathEditing, MathUI, AutoMath, Widget ] as const;
}
public static get pluginName() {
return 'Math' as const;
}
public init(): void {
const editor = this.editor;
const t = editor.t;
const model = editor.model;
// Register the "math" button, so it can be displayed in the toolbar.
editor.ui.componentFactory.add( 'math', locale => {
const view = new ButtonView( locale );
view.set( {
label: t( 'Math' ),
icon: ckeditor5Icon,
tooltip: true
} );
// Insert a text into the editor after clicking the button.
this.listenTo( view, 'execute', () => {
model.change( writer => {
const textNode = writer.createText( 'Hello CKEditor 5!' );
model.insertContent( textNode );
} );
editor.editing.view.focus();
} );
return view;
} );
}
}

View File

@@ -0,0 +1,64 @@
import { Command } from 'ckeditor5';
import { getSelectedMathModelWidget } from './utils.js';
export default class MathCommand extends Command {
public override value: string | null = null;
public override execute(
equation: string,
display?: boolean,
outputType: 'script' | 'span' = 'script',
forceOutputType?: boolean
): void {
const model = this.editor.model;
const selection = model.document.selection;
const selectedElement = selection.getSelectedElement();
model.change( writer => {
let mathtex;
if (
selectedElement &&
( selectedElement.is( 'element', 'mathtex-inline' ) ||
selectedElement.is( 'element', 'mathtex-display' ) )
) {
// Update selected element
const typeAttr = selectedElement.getAttribute( 'type' );
// Use already set type if found and is not forced
const type = forceOutputType ?
outputType :
typeAttr || outputType;
mathtex = writer.createElement(
display ? 'mathtex-display' : 'mathtex-inline',
{ equation, type, display }
);
} else {
// Create new model element
mathtex = writer.createElement(
display ? 'mathtex-display' : 'mathtex-inline',
{ equation, type: outputType, display }
);
}
model.insertContent( mathtex );
} );
}
public display = false;
public override refresh(): void {
const model = this.editor.model;
const selection = model.document.selection;
const selectedElement = selection.getSelectedElement();
this.isEnabled =
selectedElement === null ||
selectedElement.is( 'element', 'mathtex-inline' ) ||
selectedElement.is( 'element', 'mathtex-display' );
const selectedEquation = getSelectedMathModelWidget( selection );
const value = selectedEquation?.getAttribute( 'equation' );
this.value = typeof value === 'string' ? value : null;
const display = selectedEquation?.getAttribute( 'display' );
this.display = typeof display === 'boolean' ? display : false;
}
}

View File

@@ -0,0 +1,300 @@
import MathCommand from './mathcommand.js';
import { type Editor, Plugin, toWidget, Widget, viewToModelPositionOutsideModelElement, type DowncastWriter, type Element, CKEditorError, uid } from 'ckeditor5';
import { renderEquation, extractDelimiters } from './utils.js';
export default class MathEditing extends Plugin {
public static get requires() {
return [ Widget ] as const;
}
public static get pluginName() {
return 'MathEditing' as const;
}
constructor( editor: Editor ) {
super( editor );
editor.config.define( 'math', {
engine: 'mathjax',
outputType: 'script',
className: 'math-tex',
forceOutputType: false,
enablePreview: true,
previewClassName: [],
popupClassName: [],
katexRenderOptions: {}
} );
}
public init(): void {
const editor = this.editor;
editor.commands.add( 'math', new MathCommand( editor ) );
this._defineSchema();
this._defineConverters();
editor.editing.mapper.on(
'viewToModelPosition',
viewToModelPositionOutsideModelElement(
editor.model,
viewElement => viewElement.hasClass( 'math' )
)
);
}
private _defineSchema() {
const schema = this.editor.model.schema;
schema.register( 'mathtex-inline', {
allowWhere: '$text',
isInline: true,
isObject: true,
allowAttributes: [ 'equation', 'type', 'display' ]
} );
schema.register( 'mathtex-display', {
inheritAllFrom: '$blockObject',
allowAttributes: [ 'equation', 'type', 'display' ]
} );
}
private _defineConverters() {
const conversion = this.editor.conversion;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const mathConfig = this.editor.config.get( 'math' )!;
// 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, { writer } ) => {
const child = viewElement.getChild( 0 );
if ( child?.is( '$text' ) ) {
const equation = child.data.trim();
return writer.createElement( 'mathtex-inline', {
equation,
type: mathConfig.forceOutputType ?
mathConfig.outputType :
'script',
display: false
} );
}
return null;
}
} )
// 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, { writer } ) => {
const child = viewElement.getChild( 0 );
if ( child?.is( '$text' ) ) {
const equation = child.data.trim();
return writer.createElement( 'mathtex-display', {
equation,
type: mathConfig.forceOutputType ?
mathConfig.outputType :
'script',
display: true
} );
}
return null;
}
} )
// CKEditor 4 way (e.g. <span class="math-tex">\( \sqrt{\frac{a}{b}} \)</span>)
.elementToElement( {
view: {
name: 'span',
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
classes: [ mathConfig.className! ]
},
model: ( viewElement, { writer } ) => {
const child = viewElement.getChild( 0 );
if ( child?.is( '$text' ) ) {
const equation = child.data.trim();
const params = Object.assign( extractDelimiters( equation ), {
type: mathConfig.forceOutputType ?
mathConfig.outputType :
'span'
} );
return writer.createElement(
params.display ? 'mathtex-display' : 'mathtex-inline',
params
);
}
return null;
}
} )
// KaTeX from Quill: https://github.com/quilljs/quill/blob/develop/formats/formula.js
.elementToElement( {
view: {
name: 'span',
classes: [ 'ql-formula' ]
},
model: ( viewElement, { writer } ) => {
const equation = viewElement.getAttribute( 'data-value' );
if ( equation == null ) {
/**
* Couldn't find equation on current element
* @error missing-equation
*/
throw new CKEditorError( 'missing-equation', { pluginName: 'math' } );
}
return writer.createElement( 'mathtex-inline', {
equation: equation.trim(),
type: mathConfig.forceOutputType ?
mathConfig.outputType :
'script',
display: false
} );
}
} );
// Model -> View (element)
conversion
.for( 'editingDowncast' )
.elementToElement( {
model: 'mathtex-inline',
view: ( modelItem, { writer } ) => {
const widgetElement = createMathtexEditingView(
modelItem,
writer
);
return toWidget( widgetElement, writer );
}
} )
.elementToElement( {
model: 'mathtex-display',
view: ( modelItem, { writer } ) => {
const widgetElement = createMathtexEditingView(
modelItem,
writer
);
return toWidget( widgetElement, writer );
}
} );
// Model -> Data
conversion
.for( 'dataDowncast' )
.elementToElement( {
model: 'mathtex-inline',
view: createMathtexView
} )
.elementToElement( {
model: 'mathtex-display',
view: createMathtexView
} );
// Create view for editor
function createMathtexEditingView(
modelItem: Element,
writer: DowncastWriter
) {
const equation = String( modelItem.getAttribute( 'equation' ) );
const display = !!modelItem.getAttribute( 'display' );
const styles =
'user-select: none; ' +
( display ? '' : 'display: inline-block;' );
const classes =
'ck-math-tex ' +
( display ? 'ck-math-tex-display' : 'ck-math-tex-inline' );
const mathtexView = writer.createContainerElement(
display ? 'div' : 'span',
{
style: styles,
class: classes
}
);
const uiElement = writer.createUIElement(
'div',
null,
function( domDocument ) {
const domElement = this.toDomElement( domDocument );
void renderEquation(
equation,
domElement,
mathConfig.engine,
mathConfig.lazyLoad,
display,
false,
`math-editing-${ uid() }`,
mathConfig.previewClassName,
mathConfig.katexRenderOptions
);
return domElement;
}
);
writer.insert( writer.createPositionAt( mathtexView, 0 ), uiElement );
return mathtexView;
}
// Create view for data
function createMathtexView(
modelItem: Element,
{ writer }: { writer: DowncastWriter }
) {
const equation = modelItem.getAttribute( 'equation' );
if ( typeof equation != 'string' ) {
/**
* Couldn't find equation on current element
* @error missing-equation
*/
throw new CKEditorError( 'missing-equation', { pluginName: 'math' } );
}
const type = modelItem.getAttribute( 'type' );
const display = modelItem.getAttribute( 'display' );
if ( type === 'span' ) {
const mathtexView = writer.createContainerElement( 'span', {
class: mathConfig.className
} );
if ( display ) {
writer.insert(
writer.createPositionAt( mathtexView, 0 ),
writer.createText( '\\[' + equation + '\\]' )
);
} else {
writer.insert(
writer.createPositionAt( mathtexView, 0 ),
writer.createText( '\\(' + equation + '\\)' )
);
}
return mathtexView;
} else {
const mathtexView = writer.createContainerElement( 'script', {
type: display ? 'math/tex; mode=display' : 'math/tex'
} );
writer.insert(
writer.createPositionAt( mathtexView, 0 ),
writer.createText( equation )
);
return mathtexView;
}
}
}
}

View File

@@ -0,0 +1,281 @@
import MathEditing from './mathediting.js';
import MainFormView from './ui/mainformview.js';
import mathIcon from '../theme/icons/math.svg';
import { Plugin, ClickObserver, ButtonView, ContextualBalloon, clickOutsideHandler, CKEditorError, uid } from 'ckeditor5';
import { getBalloonPositionData } from './utils.js';
import MathCommand from './mathcommand.js';
const mathKeystroke = 'Ctrl+M';
export default class MathUI extends Plugin {
public static get requires() {
return [ ContextualBalloon, MathEditing ] as const;
}
public static get pluginName() {
return 'MathUI' as const;
}
private _previewUid = `math-preview-${ uid() }`;
private _balloon: ContextualBalloon = this.editor.plugins.get( ContextualBalloon );
public formView: MainFormView | null = null;
public init(): void {
const editor = this.editor;
editor.editing.view.addObserver( ClickObserver );
this._createToolbarMathButton();
this.formView = this._createFormView();
this._enableUserBalloonInteractions();
}
public override destroy(): void {
super.destroy();
this.formView?.destroy();
// Destroy preview element
const previewEl = document.getElementById( this._previewUid );
if ( previewEl ) {
previewEl.parentNode?.removeChild( previewEl );
}
}
public _showUI(): void {
const editor = this.editor;
const mathCommand = editor.commands.get( 'math' );
if ( !mathCommand?.isEnabled ) {
return;
}
this._addFormView();
this._balloon.showStack( 'main' );
}
private _createFormView() {
const editor = this.editor;
const mathCommand = editor.commands.get( 'math' );
if ( !( mathCommand instanceof MathCommand ) ) {
/**
* Missing Math command
* @error math-command
*/
throw new CKEditorError( 'math-command' );
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const mathConfig = editor.config.get( 'math' )!;
const formView = new MainFormView(
editor.locale,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
mathConfig.engine!,
mathConfig.lazyLoad,
mathConfig.enablePreview,
this._previewUid,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
mathConfig.previewClassName!,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
mathConfig.popupClassName!,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
mathConfig.katexRenderOptions!
);
formView.mathInputView.bind( 'value' ).to( mathCommand, 'value' );
formView.displayButtonView.bind( 'isOn' ).to( mathCommand, 'display' );
// Form elements should be read-only when corresponding commands are disabled.
formView.mathInputView.bind( 'isReadOnly' ).to( mathCommand, 'isEnabled', value => !value );
formView.saveButtonView.bind( 'isEnabled' ).to( mathCommand );
formView.displayButtonView.bind( 'isEnabled' ).to( mathCommand );
// Listen to submit button click
this.listenTo( formView, 'submit', () => {
editor.execute( 'math', formView.equation, formView.displayButtonView.isOn, mathConfig.outputType, mathConfig.forceOutputType );
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;
}
private _addFormView() {
if ( this._isFormInPanel ) {
return;
}
const editor = this.editor;
const mathCommand = editor.commands.get( 'math' );
if ( !( mathCommand instanceof MathCommand ) ) {
/**
* Math command not found
* @error plugin-load
*/
throw new CKEditorError( 'plugin-load', { pluginName: 'math' } );
}
if ( this.formView == null ) {
return;
}
this._balloon.add( {
view: this.formView,
position: getBalloonPositionData( editor )
} );
if ( this._balloon.visibleView === this.formView ) {
this.formView.mathInputView.fieldView.element?.select();
}
// Show preview element
const previewEl = document.getElementById( this._previewUid );
if ( previewEl && this.formView.previewEnabled ) {
// Force refresh preview
this.formView.mathView?.updateMath();
}
this.formView.equation = mathCommand.value ?? '';
this.formView.displayButtonView.isOn = mathCommand.display || false;
}
/**
* @private
*/
public _hideUI(): void {
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();
}
private _closeFormView() {
const mathCommand = this.editor.commands.get( 'math' );
if ( mathCommand?.value != null ) {
this._removeFormView();
} else {
this._hideUI();
}
}
private _removeFormView() {
if ( this._isFormInPanel && this.formView ) {
this.formView.saveButtonView.focus();
this._balloon.remove( this.formView );
// Hide preview element
const previewEl = document.getElementById( this._previewUid );
if ( previewEl ) {
previewEl.style.visibility = 'hidden';
}
this.editor.editing.view.focus();
}
}
private _createToolbarMathButton() {
const editor = this.editor;
const mathCommand = editor.commands.get( 'math' );
if ( !mathCommand ) {
/**
* Math command not found
* @error plugin-load
*/
throw new CKEditorError( 'plugin-load', { pluginName: '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 = mathIcon;
button.keystroke = mathKeystroke;
button.tooltip = true;
button.isToggleable = true;
button.bind( 'isEnabled' ).to( mathCommand, 'isEnabled' );
this.listenTo( button, 'execute', () => {
this._showUI();
} );
return button;
} );
}
private _enableUserBalloonInteractions() {
const editor = this.editor;
const viewDocument = this.editor.editing.view.document;
this.listenTo( viewDocument, 'click', () => {
const mathCommand = editor.commands.get( 'math' );
if ( mathCommand?.isEnabled && mathCommand.value ) {
this._showUI();
}
} );
// Close the panel on the Esc key press when the editable has focus and the balloon is visible.
editor.keystrokes.set( 'Esc', ( _data, cancel ) => {
if ( this._isUIVisible ) {
this._hideUI();
cancel();
}
} );
// Close on click outside of balloon panel element.
if ( this.formView ) {
clickOutsideHandler( {
emitter: this.formView,
activator: () => !!this._isFormInPanel,
contextElements: this._balloon.view.element ? [ this._balloon.view.element ] : [],
callback: () => { this._hideUI(); }
} );
} else {
throw new Error( 'missing form view' );
}
}
private get _isUIVisible() {
const visibleView = this._balloon.visibleView;
return visibleView == this.formView;
}
private get _isFormInPanel() {
return this.formView && this._balloon.hasView( this.formView );
}
}

View File

@@ -0,0 +1,156 @@
/**
* Basic typings for third party, external libraries (KaTeX, MathJax).
*/
export interface MathJax3 {
version: string;
tex2chtmlPromise?: ( input: string, options: { display: boolean } ) => Promise<HTMLElement>;
tex2svgPromise?: ( input: string, options: { display: boolean } ) => Promise<HTMLElement>;
}
export interface MathJax2 {
Hub: { Queue: ( callback: [string, MathJax2['Hub'], string | HTMLElement] | ( () => void ) ) => void };
}
export interface Katex {
render( equation: string, el: HTMLElement, options: KatexOptions ): void;
}
declare global {
// eslint-disable-next-line no-var
var CKEDITOR_MATH_LAZY_LOAD: undefined | Promise<void>;
// eslint-disable-next-line no-var
var MathJax: undefined | MathJax2 | MathJax3;
// eslint-disable-next-line no-var
var katex: undefined | Katex;
}
export interface KatexOptions {
/**
* If `true`, math will be rendered in display mode
* (math in display style and center math on page)
*
* If `false`, math will be rendered in inline mode
* @default false
*/
displayMode?: boolean | undefined;
/**
* Determines the markup language of the output. The valid choices are:
* - `html`: Outputs KaTeX in HTML only.
* - `mathml`: Outputs KaTeX in MathML only.
* - `htmlAndMathml`: Outputs HTML for visual rendering
* and includes MathML for accessibility.
*
* @default 'htmlAndMathml'
*/
output?: 'html' | 'mathml' | 'htmlAndMathml' | undefined;
/**
* If `true`, display math has \tags rendered on the left
* instead of the right, like \usepackage[leqno]{amsmath} in LaTeX.
*
* @default false
*/
leqno?: boolean | undefined;
/**
* If `true`, display math renders flush left with a 2em left margin,
* like \documentclass[fleqn] in LaTeX with the amsmath package.
*
* @default false
*/
fleqn?: boolean | undefined;
/**
* If `true`, KaTeX will throw a `ParseError` when
* it encounters an unsupported command or invalid LaTex
*
* If `false`, KaTeX will render unsupported commands as
* text, and render invalid LaTeX as its source code with
* hover text giving the error, in color given by errorColor
* @default true
*/
throwOnError?: boolean | undefined;
/**
* A Color string given in format `#XXX` or `#XXXXXX`
*/
errorColor?: string | undefined;
/**
* A collection of custom macros.
*
* See `src/macros.js` for its usage
*/
macros?: unknown;
/**
* Specifies a minimum thickness, in ems, for fraction lines,
* \sqrt top lines, {array} vertical lines, \hline, \hdashline,
* \underline, \overline, and the borders of \fbox, \boxed, and
* \fcolorbox.
*/
minRuleThickness?: number | undefined;
/**
* If `true`, `\color` will work like LaTeX's `\textcolor`
* and takes 2 arguments
*
* If `false`, `\color` will work like LaTeX's `\color`
* and takes 1 argument
*
* In both cases, `\textcolor` works as in LaTeX
*
* @default false
*/
colorIsTextColor?: boolean | undefined;
/**
* All user-specified sizes will be caped to `maxSize` ems
*
* If set to Infinity, users can make elements and space
* arbitrarily large
*
* @default Infinity
*/
maxSize?: number | undefined;
/**
* Limit the number of macro expansions to specified number
*
* If set to `Infinity`, marco expander will try to fully expand
* as in LaTex
*
* @default 1000
*/
maxExpand?: number | undefined;
/**
* If `false` or `"ignore"`, allow features that make
* writing in LaTex convenient but not supported by LaTex
*
* If `true` or `"error"`, throw an error for such transgressions
*
* If `"warn"`, warn about behavior via `console.warn`
*
* @default "warn"
*/
strict?: boolean | string | Function | undefined;
/**
* If `false` (do not trust input), prevent any commands that could enable adverse behavior, rendering them instead in errorColor.
*
* If `true` (trust input), allow all such commands.
*
* @default false
*/
trust?: boolean | ( ( context: object ) => boolean ) | undefined;
/**
* Place KaTeX code in the global group.
*
* @default false
*/
globalGroup?: boolean | undefined;
}

View File

@@ -0,0 +1,270 @@
import { icons, ButtonView, createLabeledInputText, FocusCycler, LabelView, LabeledFieldView, submitHandler, SwitchButtonView, View, ViewCollection, type InputTextView, type FocusableView, Locale, FocusTracker, KeystrokeHandler } from 'ckeditor5';
import { extractDelimiters, hasDelimiters } from '../utils.js';
import MathView from './mathview.js';
import '../../theme/mathform.css';
import type { KatexOptions } from '../typings-external.js';
const { check: checkIcon, cancel: cancelIcon } = icons;
class MathInputView extends LabeledFieldView<InputTextView> {
public value: null | string = null;
public isReadOnly = false;
constructor( locale: Locale ) {
super( locale, createLabeledInputText );
}
}
export default class MainFormView extends View {
public saveButtonView: ButtonView;
public mathInputView: MathInputView;
public displayButtonView: SwitchButtonView;
public cancelButtonView: ButtonView;
public previewEnabled: boolean;
public previewLabel?: LabelView;
public mathView?: MathView;
public override locale: Locale = new Locale();
public lazyLoad: undefined | ( () => Promise<void> );
constructor(
locale: Locale,
engine:
| 'mathjax'
| 'katex'
| ( (
equation: string,
element: HTMLElement,
display: boolean,
) => void ),
lazyLoad: undefined | ( () => Promise<void> ),
previewEnabled = false,
previewUid: string,
previewClassName: Array<string>,
popupClassName: Array<string>,
katexRenderOptions: KatexOptions
) {
super( locale );
const t = locale.t;
// Submit button
this.saveButtonView = this._createButton( t( 'Save' ), checkIcon, 'ck-button-save', null );
this.saveButtonView.type = 'submit';
// Equation input
this.mathInputView = this._createMathInput();
// Display button
this.displayButtonView = this._createDisplayButton();
// Cancel button
this.cancelButtonView = this._createButton( t( 'Cancel' ), cancelIcon, 'ck-button-cancel', 'cancel' );
this.previewEnabled = previewEnabled;
let children = [];
if ( this.previewEnabled ) {
// Preview label
this.previewLabel = new LabelView( locale );
this.previewLabel.text = t( 'Equation preview' );
// Math element
this.mathView = new MathView( engine, lazyLoad, locale, previewUid, previewClassName, katexRenderOptions );
this.mathView.bind( 'display' ).to( this.displayButtonView, 'isOn' );
children = [
this.mathInputView,
this.displayButtonView,
this.previewLabel,
this.mathView
];
} else {
children = [
this.mathInputView,
this.displayButtonView
];
}
// Add UI elements to template
this.setTemplate( {
tag: 'form',
attributes: {
class: [
'ck',
'ck-math-form',
...popupClassName
],
tabindex: '-1',
spellcheck: 'false'
},
children: [
{
tag: 'div',
attributes: {
class: [
'ck-math-view'
]
},
children
},
this.saveButtonView,
this.cancelButtonView
]
} );
}
public override render(): void {
super.render();
// Prevent default form submit event & trigger custom 'submit'
submitHandler( {
view: this
} );
// Register form elements to focusable elements
const childViews = [
this.mathInputView,
this.displayButtonView,
this.saveButtonView,
this.cancelButtonView
];
childViews.forEach( v => {
if ( v.element ) {
this._focusables.add( v );
this.focusTracker.add( v.element );
}
} );
// Listen to keypresses inside form element
if ( this.element ) {
this.keystrokes.listenTo( this.element );
}
}
public focus(): void {
this._focusCycler.focusFirst();
}
public get equation(): string {
return this.mathInputView.fieldView.element?.value ?? '';
}
public set equation( equation: string ) {
if ( this.mathInputView.fieldView.element ) {
this.mathInputView.fieldView.element.value = equation;
}
if ( this.previewEnabled && this.mathView ) {
this.mathView.value = equation;
}
}
public focusTracker: FocusTracker = new FocusTracker();
public keystrokes: KeystrokeHandler = new KeystrokeHandler();
private _focusables = new ViewCollection<FocusableView>();
private _focusCycler: FocusCycler = new FocusCycler( {
focusables: this._focusables,
focusTracker: this.focusTracker,
keystrokeHandler: this.keystrokes,
actions: {
focusPrevious: 'shift + tab',
focusNext: 'tab'
}
} );
private _createMathInput() {
const t = this.locale.t;
// Create equation input
const mathInput = new MathInputView( this.locale );
const fieldView = mathInput.fieldView;
mathInput.infoText = t( 'Insert equation in TeX format.' );
const onInput = () => {
if ( fieldView.element != null ) {
let equationInput = fieldView.element.value.trim();
// If input has delimiters
if ( hasDelimiters( equationInput ) ) {
// Get equation without delimiters
const params = extractDelimiters( equationInput );
// Remove delimiters from input field
fieldView.element.value = params.equation;
equationInput = params.equation;
// update display button and preview
this.displayButtonView.isOn = params.display;
}
if ( this.previewEnabled && this.mathView ) {
// Update preview view
this.mathView.value = equationInput;
}
this.saveButtonView.isEnabled = !!equationInput;
}
};
fieldView.on( 'render', onInput );
fieldView.on( 'input', onInput );
return mathInput;
}
private _createButton(
label: string,
icon: string,
className: string,
eventName: string | null
) {
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;
}
private _createDisplayButton() {
const t = this.locale.t;
const switchButton = new SwitchButtonView( this.locale );
switchButton.set( {
label: t( 'Display mode' ),
withText: true
} );
switchButton.extendTemplate( {
attributes: {
class: 'ck-button-display-toggle'
}
} );
switchButton.on( 'execute', () => {
// Toggle state
switchButton.isOn = !switchButton.isOn;
if ( this.previewEnabled && this.mathView ) {
// Update preview view
this.mathView.display = switchButton.isOn;
}
} );
return switchButton;
}
}

View File

@@ -0,0 +1,77 @@
import { View, type Locale } from 'ckeditor5';
import type { KatexOptions } from '../typings-external.js';
import { renderEquation } from '../utils.js';
export default class MathView extends View {
public declare value: string;
public declare display: boolean;
public previewUid: string;
public previewClassName: Array<string>;
public katexRenderOptions: KatexOptions;
public engine:
| 'mathjax'
| 'katex'
| ( ( equation: string, element: HTMLElement, display: boolean ) => void );
public lazyLoad: undefined | ( () => Promise<void> );
constructor(
engine:
| 'mathjax'
| 'katex'
| ( (
equation: string,
element: HTMLElement,
display: boolean,
) => void ),
lazyLoad: undefined | ( () => Promise<void> ),
locale: Locale,
previewUid: string,
previewClassName: Array<string>,
katexRenderOptions: KatexOptions
) {
super( locale );
this.engine = engine;
this.lazyLoad = lazyLoad;
this.previewUid = previewUid;
this.katexRenderOptions = katexRenderOptions;
this.previewClassName = previewClassName;
this.set( 'value', '' );
this.set( 'display', false );
this.on( 'change', () => {
if ( this.isRendered ) {
this.updateMath();
}
} );
this.setTemplate( {
tag: 'div',
attributes: {
class: [ 'ck', 'ck-math-preview' ]
}
} );
}
public updateMath(): void {
if ( this.element ) {
void renderEquation(
this.value,
this.element,
this.engine,
this.lazyLoad,
this.display,
true,
this.previewUid,
this.previewClassName,
this.katexRenderOptions
);
}
}
public override render(): void {
super.render();
this.updateMath();
}
}

View File

@@ -0,0 +1,341 @@
import type { Editor, Element as CKElement, DocumentSelection, PositioningFunction } from 'ckeditor5';
import { BalloonPanelView, CKEditorError } from 'ckeditor5';
import type { KatexOptions, MathJax2, MathJax3 } from './typings-external.js';
export function getSelectedMathModelWidget(
selection: DocumentSelection
): null | CKElement {
const selectedElement = selection.getSelectedElement();
if (
selectedElement &&
( selectedElement.is( 'element', 'mathtex-inline' ) ||
selectedElement.is( 'element', 'mathtex-display' ) )
) {
return selectedElement;
}
return null;
}
// Simple MathJax 3 version check
export function isMathJaxVersion3( MathJax: unknown ): MathJax is MathJax3 {
return (
MathJax != null && typeof MathJax == 'object' && 'version' in MathJax && typeof MathJax.version == 'string' &&
MathJax.version.split( '.' ).length === 3 &&
MathJax.version.split( '.' )[ 0 ] === '3'
);
}
// Simple MathJax 2 version check
export function isMathJaxVersion2( MathJax: unknown ): MathJax is MathJax2 {
return (
MathJax != null && typeof MathJax == 'object' && 'Hub' in MathJax );
}
// Check if equation has delimiters.
export function hasDelimiters( text: string ): RegExpMatchArray | null {
return text.match( /^(\\\[.*?\\\]|\\\(.*?\\\))$/ );
}
// Find delimiters count
export function delimitersCounts( text: string ): number | undefined {
return text.match( /(\\\[|\\\]|\\\(|\\\))/g )?.length;
}
// Extract delimiters and figure display mode for the model
export function extractDelimiters( equation: string ): {
equation: string;
display: boolean;
} {
equation = equation.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 {
equation,
display: hasDisplayDelimiters
};
}
export async function renderEquation(
equation: string,
element: HTMLElement,
engine:
| 'katex'
| 'mathjax'
| undefined
| ( (
equation: string,
element: HTMLElement,
display: boolean,
) => void ) = 'katex',
lazyLoad?: () => Promise<void>,
display = false,
preview = false,
previewUid = '',
previewClassName: Array<string> = [],
katexRenderOptions: KatexOptions = {}
): Promise<void> {
if ( engine == 'mathjax' ) {
if ( isMathJaxVersion3( MathJax ) ) {
selectRenderMode(
element,
preview,
previewUid,
previewClassName,
el => {
renderMathJax3( equation, el, display, () => {
if ( preview ) {
moveAndScaleElement( element, el );
el.style.visibility = 'visible';
}
} );
}
);
} else {
selectRenderMode(
element,
preview,
previewUid,
previewClassName,
el => {
// Fixme: MathJax typesetting cause occasionally math processing error without asynchronous call
window.setTimeout( () => {
renderMathJax2( equation, el, display );
// Move and scale after rendering
if ( preview && isMathJaxVersion2( MathJax ) ) {
// eslint-disable-next-line new-cap
MathJax.Hub.Queue( () => {
moveAndScaleElement( element, el );
el.style.visibility = 'visible';
} );
}
} );
}
);
}
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
} else if ( engine === 'katex' && window.katex !== undefined ) {
selectRenderMode(
element,
preview,
previewUid,
previewClassName,
el => {
if ( katex ) {
katex.render( equation, el, {
throwOnError: false,
displayMode: display,
...katexRenderOptions
} );
}
if ( preview ) {
moveAndScaleElement( element, el );
el.style.visibility = 'visible';
}
}
);
} else if ( typeof engine === 'function' ) {
engine( equation, element, display );
} else {
if ( lazyLoad != null ) {
try {
window.CKEDITOR_MATH_LAZY_LOAD ??= lazyLoad();
element.innerHTML = equation;
await window.CKEDITOR_MATH_LAZY_LOAD;
await renderEquation(
equation,
element,
engine,
undefined,
display,
preview,
previewUid,
previewClassName,
katexRenderOptions
);
} catch ( err ) {
element.innerHTML = equation;
console.error(
`math-tex-typesetting-lazy-load-failed: Lazy load failed: ${ String( err ) }`
);
}
} else {
element.innerHTML = equation;
console.warn(
`math-tex-typesetting-missing: Missing the mathematical typesetting engine (${ String( engine ) }) for tex.`
);
}
}
}
export function getBalloonPositionData( editor: Editor ): {
target: Range | HTMLElement;
positions: Array<PositioningFunction>;
} {
const view = editor.editing.view;
const defaultPositions = BalloonPanelView.defaultPositions;
const selectedElement = view.document.selection.getSelectedElement();
if ( selectedElement ) {
return {
target: view.domConverter.viewToDom( selectedElement ),
positions: [
defaultPositions.southArrowNorth,
defaultPositions.southArrowNorthWest,
defaultPositions.southArrowNorthEast
]
};
} else {
const viewDocument = view.document;
const firstRange = viewDocument.selection.getFirstRange();
if ( !firstRange ) {
/**
* Missing first range.
* @error math-missing-range
*/
throw new CKEditorError( 'math-missing-range' );
}
return {
target: view.domConverter.viewRangeToDom(
firstRange
),
positions: [
defaultPositions.southArrowNorth,
defaultPositions.southArrowNorthWest,
defaultPositions.southArrowNorthEast
]
};
}
}
function selectRenderMode(
element: HTMLElement,
preview: boolean,
previewUid: string,
previewClassName: Array<string>,
cb: ( previewEl: HTMLElement ) => void
) {
if ( preview ) {
createPreviewElement(
element,
previewUid,
previewClassName,
previewEl => {
cb( previewEl );
}
);
} else {
cb( element );
}
}
function renderMathJax3( equation: string, element: HTMLElement, display: boolean, cb: () => void ) {
let promiseFunction: undefined | ( ( input: string, options: { display: boolean } ) => Promise<HTMLElement> ) = undefined;
if ( !isMathJaxVersion3( MathJax ) ) {
return;
}
if ( MathJax.tex2chtmlPromise ) {
promiseFunction = MathJax.tex2chtmlPromise;
} else if ( MathJax.tex2svgPromise ) {
promiseFunction = MathJax.tex2svgPromise;
}
if ( promiseFunction != null ) {
void promiseFunction( equation, { display } ).then( ( node: Element ) => {
if ( element.firstChild ) {
element.removeChild( element.firstChild );
}
element.appendChild( node );
cb();
} );
}
}
function renderMathJax2( equation: string, element: HTMLElement, display?: boolean ) {
if ( isMathJaxVersion2( MathJax ) ) {
if ( display ) {
element.innerHTML = '\\[' + equation + '\\]';
} else {
element.innerHTML = '\\(' + equation + '\\)';
}
// eslint-disable-next-line
MathJax.Hub.Queue(['Typeset', MathJax.Hub, element]);
}
}
function createPreviewElement(
element: HTMLElement,
previewUid: string,
previewClassName: Array<string>,
render: ( previewEl: HTMLElement ) => void
): void {
const previewEl = getPreviewElement( element, previewUid, previewClassName );
render( previewEl );
}
function getPreviewElement(
element: HTMLElement,
previewUid: string,
previewClassName: Array<string>
) {
let previewEl = document.getElementById( previewUid );
// Create if not found
if ( !previewEl ) {
previewEl = document.createElement( 'div' );
previewEl.setAttribute( 'id', previewUid );
previewEl.classList.add( ...previewClassName );
previewEl.style.visibility = 'hidden';
document.body.appendChild( previewEl );
let ticking = false;
const renderTransformation = () => {
if ( !ticking ) {
window.requestAnimationFrame( () => {
if ( previewEl ) {
moveElement( element, previewEl );
ticking = false;
}
} );
ticking = true;
}
};
// Create scroll listener for following
window.addEventListener( 'resize', renderTransformation );
window.addEventListener( 'scroll', renderTransformation );
}
return previewEl;
}
function moveAndScaleElement( parent: HTMLElement, child: HTMLElement ) {
// Move to right place
moveElement( parent, child );
// Scale parent element same as preview
const domRect = child.getBoundingClientRect();
parent.style.width = domRect.width + 'px';
parent.style.height = domRect.height + 'px';
}
function moveElement( parent: HTMLElement, child: HTMLElement ) {
const domRect = parent.getBoundingClientRect();
const left = window.scrollX + domRect.left;
const top = window.scrollY + domRect.top;
child.style.position = 'absolute';
child.style.left = left + 'px';
child.style.top = top + 'px';
child.style.zIndex = 'var(--ck-z-panel)';
child.style.pointerEvents = 'none';
}