Improve and simplify Mathfield integration

This commit is contained in:
meinzzzz 2025-11-25 23:27:06 +01:00
parent d2052ad236
commit 51db729546
5 changed files with 207 additions and 345 deletions

View File

@ -71,22 +71,20 @@ export default class MathUI extends Plugin {
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,
{
engine: mathConfig.engine!,
lazyLoad: mathConfig.lazyLoad,
previewUid: this._previewUid,
previewClassName: mathConfig.previewClassName!,
katexRenderOptions: mathConfig.katexRenderOptions!
},
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!
mathConfig.popupClassName!
);
formView.mathLiveInputView.bind( 'value' ).to( mathCommand, 'value' );
@ -164,9 +162,9 @@ export default class MathUI extends Plugin {
// Show preview element
const previewEl = document.getElementById( this._previewUid );
if ( previewEl && this.formView.previewEnabled ) {
if ( previewEl && this.formView.mathView ) {
// Force refresh preview
this.formView.mathView?.updateMath();
this.formView.mathView.updateMath();
}
this.formView.equation = mathCommand.value ?? '';

View File

@ -1,174 +1,136 @@
import { ButtonView, FocusCycler, LabelView, submitHandler, SwitchButtonView, View, ViewCollection, type FocusableView, Locale, FocusTracker, KeystrokeHandler } from 'ckeditor5';
import {
ButtonView,
FocusCycler,
LabelView,
submitHandler,
SwitchButtonView,
View,
ViewCollection,
type FocusableView,
Locale,
FocusTracker,
KeystrokeHandler
} from 'ckeditor5';
import IconCheck from '@ckeditor/ckeditor5-icons/theme/icons/check.svg?raw';
import IconCancel from '@ckeditor/ckeditor5-icons/theme/icons/cancel.svg?raw';
import { extractDelimiters, hasDelimiters } from '../utils.js';
import MathView from './mathview.js';
import MathView, { type MathViewOptions } from './mathview.js';
import MathLiveInputView from './mathliveinputview.js';
import RawLatexInputView from './rawlatexinputview.js';
import '../../theme/mathform.css';
import type { KatexOptions } from '../typings-external.js';
export default class MainFormView extends View {
public saveButtonView: ButtonView;
public cancelButtonView: ButtonView;
public displayButtonView: SwitchButtonView;
public mathLiveInputView: MathLiveInputView;
public rawLatexInputView: RawLatexInputView;
public rawLatexLabel: LabelView;
public displayButtonView: SwitchButtonView;
public cancelButtonView: ButtonView;
public previewEnabled: boolean;
public previewLabel?: LabelView;
public mathView?: MathView;
public override locale: Locale;
public focusTracker = new FocusTracker();
public keystrokes = new KeystrokeHandler();
private _focusables = new ViewCollection<FocusableView>();
private _focusCycler: FocusCycler;
constructor(
locale: Locale,
engine:
| 'mathjax'
| 'katex'
| ( (
equation: string,
element: HTMLElement,
display: boolean,
) => void ),
lazyLoad: undefined | ( () => Promise<void> ),
mathViewOptions: MathViewOptions,
previewEnabled = false,
previewUid: string,
previewClassName: Array<string>,
popupClassName: Array<string>,
katexRenderOptions: KatexOptions
popupClassName: string[] = []
) {
super( locale );
this.locale = locale;
const t = locale.t;
// Submit button
this.saveButtonView = this._createButton( t( 'Save' ), IconCheck, 'ck-button-save', null );
// --- 1. View Initialization ---
this.mathLiveInputView = new MathLiveInputView( locale );
this.rawLatexInputView = new RawLatexInputView( locale );
this.rawLatexInputView.label = t( 'LaTeX' );
this.saveButtonView = this._createButton( t( 'Save' ), IconCheck, 'ck-button-save' );
this.saveButtonView.type = 'submit';
// MathLive visual equation editor
this.mathLiveInputView = this._createMathLiveInput();
this.cancelButtonView = this._createButton( t( 'Cancel' ), IconCancel, 'ck-button-cancel' );
this.cancelButtonView.delegate( 'execute' ).to( this, 'cancel' );
// Raw LaTeX input
this.rawLatexInputView = this._createRawLatexInput();
this.displayButtonView = this._createDisplayButton( t );
// Raw LaTeX label
this.rawLatexLabel = new LabelView( locale );
this.rawLatexLabel.text = t( 'LaTeX' );
// --- 2. Construct Children & Preview ---
// Display button
this.displayButtonView = this._createDisplayButton();
const children: View[] = [
this.mathLiveInputView,
this.rawLatexInputView,
this.displayButtonView
];
// Cancel button
this.cancelButtonView = this._createButton( t( 'Cancel' ), IconCancel, 'ck-button-cancel', 'cancel' );
if ( previewEnabled ) {
const previewLabel = new LabelView( locale );
previewLabel.text = t( 'Equation preview' );
this.previewEnabled = previewEnabled;
// Clean instantiation using the options object
this.mathView = new MathView( locale, mathViewOptions );
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 );
// Bind display mode: When button flips, preview updates automatically
this.mathView.bind( 'display' ).to( this.displayButtonView, 'isOn' );
children = [
this.mathLiveInputView,
this.rawLatexLabel,
this.rawLatexInputView,
this.displayButtonView,
this.previewLabel,
this.mathView
];
} else {
children = [
this.mathLiveInputView,
this.rawLatexLabel,
this.rawLatexInputView,
this.displayButtonView
];
children.push( previewLabel, this.mathView );
}
// Add UI elements to template
// --- 3. Sync Logic ---
this._setupInputSync( previewEnabled );
// --- 4. Template Setup ---
this.setTemplate( {
tag: 'form',
attributes: {
class: [
'ck',
'ck-math-form',
...popupClassName
],
class: [ 'ck', 'ck-math-form', ...popupClassName ],
tabindex: '-1',
spellcheck: 'false'
},
children: [
{
tag: 'div',
attributes: {
class: [ 'ck-math-scroll' ]
},
children: [
{
tag: 'div',
attributes: {
class: [ 'ck-math-view' ]
},
children
}
]
attributes: { class: [ 'ck-math-scroll' ] },
children: [ { tag: 'div', attributes: { class: [ 'ck-math-view' ] }, children } ]
},
{
tag: 'div',
attributes: {
class: [ 'ck-math-button-row' ]
},
children: [
this.saveButtonView,
this.cancelButtonView
]
attributes: { class: [ 'ck-math-button-row' ] },
children: [ this.saveButtonView, this.cancelButtonView ]
}
]
} );
// --- 5. Accessibility ---
this._focusCycler = new FocusCycler( {
focusables: this._focusables,
focusTracker: this.focusTracker,
keystrokeHandler: this.keystrokes,
actions: { focusPrevious: 'shift + tab', focusNext: 'tab' }
} );
}
public override render(): void {
super.render();
// Prevent default form submit event & trigger custom 'submit'
submitHandler( {
view: this
} );
submitHandler( { view: this } );
// Register form elements to focusable elements
const childViews = [
// Register focusables
[
this.mathLiveInputView,
this.rawLatexInputView,
this.displayButtonView,
this.saveButtonView,
this.cancelButtonView
];
childViews.forEach( v => {
].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 override destroy(): void {
super.destroy();
}
public focus(): void {
this._focusCycler.focusFirst();
if ( this.element ) this.keystrokes.listenTo( this.element );
}
public get equation(): string {
@ -176,151 +138,82 @@ export default class MainFormView extends View {
}
public set equation( equation: string ) {
const normalizedEquation = equation.trim();
this.mathLiveInputView.value = normalizedEquation.length ? normalizedEquation : null;
this.rawLatexInputView.value = normalizedEquation;
if ( this.previewEnabled && this.mathView ) {
this.mathView.value = normalizedEquation;
}
const norm = equation.trim();
// Direct updates to the "source of truth"
this.mathLiveInputView.value = norm.length ? norm : null;
this.rawLatexInputView.value = norm;
if ( this.mathView ) this.mathView.value = norm;
}
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'
}
} );
public focus(): void {
this._focusCycler.focusFirst();
}
/**
* Creates the MathLive visual equation editor.
*
* Handles bidirectional synchronization with the raw LaTeX textarea and preview.
* Sets up split handlers for synchronization.
*/
private _createMathLiveInput() {
const mathLiveInput = new MathLiveInputView( this.locale );
private _setupInputSync( previewEnabled: boolean ): void {
// Handler 1: MathLive -> Raw LaTeX
this.mathLiveInputView.on( 'change:value', () => {
let eq = ( this.mathLiveInputView.value ?? '' ).trim();
const onInput = () => {
let equationInput = ( mathLiveInput.value ?? '' ).trim();
// If input has delimiters, strip them and update the display mode.
if ( hasDelimiters( equationInput ) ) {
const params = extractDelimiters( equationInput );
equationInput = params.equation;
// Delimiter Normalization
if ( hasDelimiters( eq ) ) {
const params = extractDelimiters( eq );
eq = params.equation;
this.displayButtonView.isOn = params.display;
// UX Fix: If we stripped delimiters, update the source
// so the visual editor doesn't show them.
if ( this.mathLiveInputView.value !== eq ) {
this.mathLiveInputView.value = eq;
}
}
const normalizedEquation = equationInput.length ? equationInput : null;
// Update self if needed.
if ( mathLiveInput.value !== normalizedEquation ) {
mathLiveInput.value = normalizedEquation;
// Sync to Raw LaTeX
if ( this.rawLatexInputView.value !== eq ) {
this.rawLatexInputView.value = eq;
}
// Sync to raw LaTeX textarea if its value is different.
if ( this.rawLatexInputView.value !== equationInput ) {
this.rawLatexInputView.value = equationInput;
// Sync to Preview
if ( previewEnabled && this.mathView && this.mathView.value !== eq ) {
this.mathView.value = eq;
}
} );
// Handler 2: Raw LaTeX -> MathLive
this.rawLatexInputView.on( 'change:value', () => {
const eq = ( this.rawLatexInputView.value ?? '' ).trim();
const normalized = eq.length ? eq : null;
// Sync to MathLive
if ( this.mathLiveInputView.value !== normalized ) {
this.mathLiveInputView.value = normalized;
}
// Update preview if enabled and its value is different.
if ( this.previewEnabled && this.mathView && this.mathView.value !== equationInput ) {
this.mathView.value = equationInput;
// Sync to Preview
if ( previewEnabled && this.mathView && this.mathView.value !== eq ) {
this.mathView.value = eq;
}
};
mathLiveInput.on( 'change:value', onInput );
return mathLiveInput;
} );
}
/**
* Creates the raw LaTeX textarea editor.
*
* Provides direct LaTeX code editing and synchronizes changes with the MathLive visual editor.
*/
private _createRawLatexInput() {
const t = this.locale.t;
const rawLatexInput = new RawLatexInputView( this.locale );
rawLatexInput.label = t( 'LaTeX' );
// Sync raw LaTeX textarea changes to MathLive visual editor
rawLatexInput.on( 'change:value', () => {
const rawValue = rawLatexInput.value ?? '';
const equationInput = rawValue.trim();
// Update MathLive visual editor
const normalizedEquation = equationInput.length ? equationInput : null;
if ( this.mathLiveInputView.value !== normalizedEquation ) {
this.mathLiveInputView.value = normalizedEquation;
}
// Update preview
if ( this.previewEnabled && this.mathView ) {
this.mathView.value = equationInput;
}
} );
return rawLatexInput;
private _createButton( label: string, icon: string, className: string ): ButtonView {
const btn = new ButtonView( this.locale );
btn.set( { label, icon, tooltip: true } );
btn.extendTemplate( { attributes: { class: className } } );
return btn;
}
private _createButton(
label: string,
icon: string,
className: string,
eventName: string | null
) {
const button = new ButtonView( this.locale );
private _createDisplayButton( t: ( str: string ) => string ): SwitchButtonView {
const btn = new SwitchButtonView( this.locale );
btn.set( { label: t( 'Display mode' ), withText: true } );
btn.extendTemplate( { attributes: { class: 'ck-button-display-toggle' } } );
button.set( {
label,
icon,
tooltip: true
btn.on( 'execute', () => {
btn.isOn = !btn.isOn;
// mathView updates automatically via bind()
} );
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;
return btn;
}
}

View File

@ -1,33 +1,27 @@
import { View, type Locale } from 'ckeditor5';
import { MathfieldElement } from 'mathlive';
import 'mathlive'; // Import side-effects only (registers the <math-field> tag)
/**
* A view that wraps the MathLive `<math-field>` web component for interactive LaTeX equation editing.
*
* MathLive provides a rich math input experience with live rendering, virtual keyboard support,
* and various accessibility features.
*
* @see https://cortexjs.io/mathlive/
* A wrapper for the MathLive <math-field> component.
* Uses 'any' typing to avoid TypeScript module resolution errors.
*/
export default class MathLiveInputView extends View {
/**
* The current LaTeX value of the math field.
*
* The current LaTeX value.
* @observable
*/
public declare value: string | null;
/**
* Whether the input is in read-only mode.
*
* Read-only state.
* @observable
*/
public declare isReadOnly: boolean;
/**
* Reference to the `<math-field>` DOM element.
* Reference to the DOM element (typed as any to prevent TS errors).
*/
public mathfield: HTMLElement | null = null;
public mathfield: any = null;
constructor( locale: Locale ) {
super( locale );
@ -43,68 +37,53 @@ export default class MathLiveInputView extends View {
} );
}
/**
* @inheritDoc
*/
public override render(): void {
super.render();
// Disable sounds before creating mathfield
if (typeof MathfieldElement !== 'undefined') {
MathfieldElement.soundsDirectory = null;
}
// (Removed global area click-to-focus logic; focusing now relies on direct field interaction.)
// Create the MathLive math-field custom element
// 1. Create element using DOM API instead of Class constructor
// This avoids "Module has no exported member" errors.
const mathfield = document.createElement( 'math-field' ) as any;
this.mathfield = mathfield;
// Configure the virtual keyboard to be manually controlled (shown by user interaction)
mathfield.setAttribute( 'virtual-keyboard-mode', 'manual' );
// 2. Configure Options
mathfield.mathVirtualKeyboardPolicy = 'manual';
// Set initial value
const initialValue = this.value ?? '';
if ( initialValue ) {
( mathfield as any ).value = initialValue;
// Disable sounds
const MathfieldElement = customElements.get( 'math-field' );
if ( MathfieldElement ) {
( MathfieldElement as any ).soundsDirectory = null;
( MathfieldElement as any ).plonkSound = null;
}
// Bind readonly state
if ( this.isReadOnly ) {
( mathfield as any ).readOnly = true;
}
// 3. Set Initial State
mathfield.value = this.value ?? '';
mathfield.readOnly = this.isReadOnly;
// Sync math-field changes to observable value
// 4. Bind Events (DOM -> Observable)
mathfield.addEventListener( 'input', () => {
const nextValue: string = ( mathfield as any ).value;
this.value = nextValue.length ? nextValue : null;
const val = mathfield.value;
this.value = val.length ? val : null;
} );
// Sync observable value changes back to math-field
this.on( 'change:value', () => {
const nextValue = this.value ?? '';
if ( ( mathfield as any ).value !== nextValue ) {
( mathfield as any ).value = nextValue;
// 5. Bind Events (Observable -> DOM)
this.on( 'change:value', ( _evt, _name, nextValue ) => {
if ( mathfield.value !== nextValue ) {
mathfield.value = nextValue ?? '';
}
} );
// Sync readonly state to math-field
this.on( 'change:isReadOnly', () => {
( mathfield as any ).readOnly = this.isReadOnly;
this.on( 'change:isReadOnly', ( _evt, _name, nextValue ) => {
mathfield.readOnly = nextValue;
} );
// 6. Mount
this.element?.appendChild( mathfield );
this.mathfield = mathfield;
}
/**
* Focuses the math-field element.
*/
public focus(): void {
this.mathfield?.focus();
}
/**
* @inheritDoc
*/
public override destroy(): void {
if ( this.mathfield ) {
this.mathfield.remove();

View File

@ -2,44 +2,44 @@ import { View, type Locale } from 'ckeditor5';
import type { KatexOptions } from '../typings-external.js';
import { renderEquation } from '../utils.js';
/**
* Configuration options for the MathView.
*/
export interface MathViewOptions {
engine: 'mathjax' | 'katex' | ( ( equation: string, element: HTMLElement, display: boolean ) => void );
lazyLoad: undefined | ( () => Promise<void> );
previewUid: string;
previewClassName: Array<string>;
katexRenderOptions: KatexOptions;
}
export default class MathView extends View {
/**
* The LaTeX equation value to render.
* @observable
*/
public declare value: string;
/**
* Whether to render in display mode (centered) or inline.
* @observable
*/
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
) {
/**
* Configuration options passed during initialization.
*/
private options: MathViewOptions;
constructor( locale: Locale, options: MathViewOptions ) {
super( locale );
this.engine = engine;
this.lazyLoad = lazyLoad;
this.previewUid = previewUid;
this.katexRenderOptions = katexRenderOptions;
this.previewClassName = previewClassName;
this.options = options;
this.set( 'value', '' );
this.set( 'display', false );
// Update rendering when state changes.
// Checking isRendered prevents errors during initialization.
this.on( 'change', () => {
if ( this.isRendered ) {
this.updateMath();
@ -59,13 +59,13 @@ export default class MathView extends View {
void renderEquation(
this.value,
this.element,
this.engine,
this.lazyLoad,
this.options.engine,
this.options.lazyLoad,
this.display,
true,
this.previewUid,
this.previewClassName,
this.katexRenderOptions
true, // isPreview
this.options.previewUid,
this.options.previewClassName,
this.options.katexRenderOptions
);
}
}

View File

@ -2,21 +2,16 @@ import { LabeledFieldView, createLabeledTextarea, type Locale, type TextareaView
/**
* A labeled textarea view for direct LaTeX code editing.
*
* This provides a plain text input for users who prefer to write LaTeX syntax directly
* or need to paste/edit raw LaTeX code.
*/
export default class RawLatexInputView extends LabeledFieldView<TextareaView> {
/**
* The current LaTeX value.
*
* @observable
*/
public declare value: string;
/**
* Whether the input is in read-only mode.
*
* @observable
*/
public declare isReadOnly: boolean;
@ -29,21 +24,23 @@ export default class RawLatexInputView extends LabeledFieldView<TextareaView> {
const fieldView = this.fieldView;
// Sync textarea input to observable value
// 1. Sync: DOM (Textarea) -> Observable
// We listen to the native 'input' event on the child view
fieldView.on( 'input', () => {
if ( fieldView.element ) {
this.value = fieldView.element.value;
}
} );
// Sync observable value changes back to textarea
// 2. Sync: Observable -> DOM (Textarea)
this.on( 'change:value', () => {
// Check for difference to avoid cursor jumping or unnecessary updates
if ( fieldView.element && fieldView.element.value !== this.value ) {
fieldView.element.value = this.value;
}
} );
// Sync readonly state (manual binding to avoid CKEditor observable rebind error)
// 3. Sync: ReadOnly State
this.on( 'change:isReadOnly', () => {
if ( fieldView.element ) {
fieldView.element.readOnly = this.isReadOnly;
@ -51,12 +48,7 @@ export default class RawLatexInputView extends LabeledFieldView<TextareaView> {
} );
}
/**
* @inheritDoc
*/
public override render(): void {
super.render();
// All styling is handled via CSS in mathform.css
// (Removed obsolete mousedown propagation; no longer needed after resize & gray-area click removal.)
}
}