From 51db729546adee649dfde69151e48498103c6b0c Mon Sep 17 00:00:00 2001 From: meinzzzz Date: Tue, 25 Nov 2025 23:27:06 +0100 Subject: [PATCH] Improve and simplify Mathfield integration --- packages/ckeditor5-math/src/mathui.ts | 24 +- .../ckeditor5-math/src/ui/mainformview.ts | 359 ++++++------------ .../src/ui/mathliveinputview.ts | 81 ++-- packages/ckeditor5-math/src/ui/mathview.ts | 70 ++-- .../src/ui/rawlatexinputview.ts | 18 +- 5 files changed, 207 insertions(+), 345 deletions(-) diff --git a/packages/ckeditor5-math/src/mathui.ts b/packages/ckeditor5-math/src/mathui.ts index 504adf77a..a27ee87fd 100644 --- a/packages/ckeditor5-math/src/mathui.ts +++ b/packages/ckeditor5-math/src/mathui.ts @@ -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 ?? ''; diff --git a/packages/ckeditor5-math/src/ui/mainformview.ts b/packages/ckeditor5-math/src/ui/mainformview.ts index f44b020ee..15e3ed55d 100644 --- a/packages/ckeditor5-math/src/ui/mainformview.ts +++ b/packages/ckeditor5-math/src/ui/mainformview.ts @@ -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(); + private _focusCycler: FocusCycler; constructor( locale: Locale, - engine: - | 'mathjax' - | 'katex' - | ( ( - equation: string, - element: HTMLElement, - display: boolean, - ) => void ), - lazyLoad: undefined | ( () => Promise ), + mathViewOptions: MathViewOptions, previewEnabled = false, - previewUid: string, - previewClassName: Array, - popupClassName: Array, - 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(); - 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; } } diff --git a/packages/ckeditor5-math/src/ui/mathliveinputview.ts b/packages/ckeditor5-math/src/ui/mathliveinputview.ts index 6b4930805..b5f36e94e 100644 --- a/packages/ckeditor5-math/src/ui/mathliveinputview.ts +++ b/packages/ckeditor5-math/src/ui/mathliveinputview.ts @@ -1,33 +1,27 @@ import { View, type Locale } from 'ckeditor5'; -import { MathfieldElement } from 'mathlive'; +import 'mathlive'; // Import side-effects only (registers the tag) /** - * A view that wraps the MathLive `` 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 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 `` 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(); diff --git a/packages/ckeditor5-math/src/ui/mathview.ts b/packages/ckeditor5-math/src/ui/mathview.ts index fab16262e..254c306ab 100644 --- a/packages/ckeditor5-math/src/ui/mathview.ts +++ b/packages/ckeditor5-math/src/ui/mathview.ts @@ -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 ); + previewUid: string; + previewClassName: Array; + 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; - public katexRenderOptions: KatexOptions; - public engine: - | 'mathjax' - | 'katex' - | ( ( equation: string, element: HTMLElement, display: boolean ) => void ); - public lazyLoad: undefined | ( () => Promise ); - constructor( - engine: - | 'mathjax' - | 'katex' - | ( ( - equation: string, - element: HTMLElement, - display: boolean, - ) => void ), - lazyLoad: undefined | ( () => Promise ), - locale: Locale, - previewUid: string, - previewClassName: Array, - 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 ); } } diff --git a/packages/ckeditor5-math/src/ui/rawlatexinputview.ts b/packages/ckeditor5-math/src/ui/rawlatexinputview.ts index 5831fad08..81593ec76 100644 --- a/packages/ckeditor5-math/src/ui/rawlatexinputview.ts +++ b/packages/ckeditor5-math/src/ui/rawlatexinputview.ts @@ -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 { /** * 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 { 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 { } ); } - /** - * @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.) } }