From 827c8e0e7288b648a511e91becef8f818370e6fc Mon Sep 17 00:00:00 2001 From: meinzzzz Date: Sun, 7 Dec 2025 23:19:48 +0100 Subject: [PATCH] Refactor: Combine MathLive and LaTeX inputs into one single component --- packages/ckeditor5-math/src/mathui.ts | 26 +-- .../ckeditor5-math/src/ui/mainformview.ts | 92 ++------ .../ckeditor5-math/src/ui/mathinputview.ts | 198 +++++++++++++++++ .../src/ui/mathliveinputview.ts | 110 ---------- .../src/ui/rawlatexinputview.ts | 54 ----- packages/ckeditor5-math/tests/index.ts | 14 ++ packages/ckeditor5-math/tests/lazyload.ts | 21 +- packages/ckeditor5-math/tests/mathui.ts | 22 +- packages/ckeditor5-math/theme/mathform.css | 201 ++++++++++-------- packages/ckeditor5-math/vitest.config.ts | 3 + 10 files changed, 378 insertions(+), 363 deletions(-) create mode 100644 packages/ckeditor5-math/src/ui/mathinputview.ts delete mode 100644 packages/ckeditor5-math/src/ui/mathliveinputview.ts delete mode 100644 packages/ckeditor5-math/src/ui/rawlatexinputview.ts diff --git a/packages/ckeditor5-math/src/mathui.ts b/packages/ckeditor5-math/src/mathui.ts index a27ee87fd5..5f36396d14 100644 --- a/packages/ckeditor5-math/src/mathui.ts +++ b/packages/ckeditor5-math/src/mathui.ts @@ -33,10 +33,8 @@ export default class MathUI extends Plugin { public override destroy(): void { super.destroy(); - this.formView?.destroy(); - // Destroy preview element const previewEl = document.getElementById( this._previewUid ); if ( previewEl ) { previewEl.parentNode?.removeChild( previewEl ); @@ -56,7 +54,7 @@ export default class MathUI extends Plugin { this._balloon.showStack( 'main' ); requestAnimationFrame(() => { - this.formView?.mathLiveInputView.focus(); + this.formView?.mathInputView.focus(); }); } @@ -87,15 +85,15 @@ export default class MathUI extends Plugin { mathConfig.popupClassName! ); - formView.mathLiveInputView.bind( 'value' ).to( mathCommand, 'value' ); + 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.mathLiveInputView.bind( 'isReadOnly' ).to( mathCommand, 'isEnabled', value => !value ); + formView.mathInputView.bind( 'isReadOnly' ).to( mathCommand, 'isEnabled', (value: boolean) => !value ); formView.saveButtonView.bind( 'isEnabled' ).to( mathCommand, 'isEnabled', - formView.mathLiveInputView, + formView.mathInputView, 'value', ( commandEnabled, equation ) => { const normalizedEquation = ( equation ?? '' ).trim(); @@ -104,24 +102,21 @@ export default class MathUI extends Plugin { ); formView.displayButtonView.bind( 'isEnabled' ).to( mathCommand, 'isEnabled' ); - // 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(); } ); - // Allow pressing Enter to submit changes, and use Shift+Enter to insert a new line + // Enter to submit, Shift+Enter for newline formView.keystrokes.set('enter', (data, cancel) => { if (!data.shiftKey) { formView.fire('submit'); @@ -157,13 +152,11 @@ export default class MathUI extends Plugin { } ); if ( this._balloon.visibleView === this.formView ) { - this.formView.mathLiveInputView.focus(); + this.formView.mathInputView.focus(); } - // Show preview element const previewEl = document.getElementById( this._previewUid ); if ( previewEl && this.formView.mathView ) { - // Force refresh preview this.formView.mathView.updateMath(); } @@ -171,9 +164,6 @@ export default class MathUI extends Plugin { this.formView.displayButtonView.isOn = mathCommand.display || false; } - /** - * @private - */ public _hideUI(): void { if ( !this._isFormInPanel ) { return; @@ -185,8 +175,6 @@ export default class MathUI extends Plugin { this.stopListening( this._balloon, 'change:visibleView' ); editor.editing.view.focus(); - - // Remove form first because it's on top of the stack. this._removeFormView(); } @@ -202,10 +190,8 @@ export default class MathUI extends Plugin { 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'; diff --git a/packages/ckeditor5-math/src/ui/mainformview.ts b/packages/ckeditor5-math/src/ui/mainformview.ts index d7ceb2f20f..5e9d069cf7 100644 --- a/packages/ckeditor5-math/src/ui/mainformview.ts +++ b/packages/ckeditor5-math/src/ui/mainformview.ts @@ -15,8 +15,7 @@ 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, { type MathViewOptions } from './mathview.js'; -import MathLiveInputView from './mathliveinputview.js'; -import RawLatexInputView from './rawlatexinputview.js'; +import MathInputView from './mathinputview.js'; import '../../theme/mathform.css'; export default class MainFormView extends View { @@ -24,8 +23,7 @@ export default class MainFormView extends View { public cancelButtonView: ButtonView; public displayButtonView: SwitchButtonView; - public mathLiveInputView: MathLiveInputView; - public rawLatexInputView: RawLatexInputView; + public mathInputView: MathInputView; public mathView?: MathView; public focusTracker = new FocusTracker(); @@ -42,24 +40,17 @@ export default class MainFormView extends View { super( locale ); const t = locale.t; - // --- 1. View Initialization --- - - this.mathLiveInputView = new MathLiveInputView( locale ); - this.rawLatexInputView = new RawLatexInputView( locale ); - this.rawLatexInputView.label = t( 'LaTeX' ); - + // Create views + this.mathInputView = new MathInputView( locale ); this.saveButtonView = this._createButton( t( 'Save' ), IconCheck, 'ck-button-save', 'submit' ); - this.cancelButtonView = this._createButton( t( 'Cancel' ), IconCancel, 'ck-button-cancel' ); this.cancelButtonView.delegate( 'execute' ).to( this, 'cancel' ); - this.displayButtonView = this._createDisplayButton( t ); - // --- 2. Construct Children & Preview --- + // Build children const children: View[] = [ - this.mathLiveInputView, - this.rawLatexInputView, + this.mathInputView, this.displayButtonView ]; @@ -67,19 +58,14 @@ export default class MainFormView extends View { const previewLabel = new LabelView( locale ); previewLabel.text = t( 'Equation preview' ); - // Clean instantiation using the options object this.mathView = new MathView( locale, mathViewOptions ); - - // Bind display mode: When button flips, preview updates automatically this.mathView.bind( 'display' ).to( this.displayButtonView, 'isOn' ); children.push( previewLabel, this.mathView ); } - // --- 3. Sync Logic --- - this._setupInputSync( previewEnabled ); + this._setupSync( previewEnabled ); - // --- 4. Template Setup --- this.setTemplate( { tag: 'form', attributes: { @@ -101,7 +87,6 @@ export default class MainFormView extends View { ] } ); - // --- 5. Accessibility --- this._focusCycler = new FocusCycler( { focusables: this._focusables, focusTracker: this.focusTracker, @@ -117,8 +102,7 @@ export default class MainFormView extends View { // Register focusables [ - this.mathLiveInputView, - this.rawLatexInputView, + this.mathInputView, this.displayButtonView, this.saveButtonView, this.cancelButtonView @@ -133,14 +117,12 @@ export default class MainFormView extends View { } public get equation(): string { - return this.mathLiveInputView.value ?? ''; + return this.mathInputView.value ?? ''; } public set equation( equation: string ) { const norm = equation.trim(); - // Direct updates to the "source of truth" - this.mathLiveInputView.value = norm.length ? norm : null; - this.rawLatexInputView.value = norm; + this.mathInputView.value = norm.length ? norm : null; if ( this.mathView ) this.mathView.value = norm; } @@ -148,28 +130,10 @@ export default class MainFormView extends View { this._focusCycler.focusFirst(); } - /** - * Checks if a view currently has focus. - */ - private _isViewFocused(view: View): boolean { - const el = view.element; - const active = document.activeElement; - return !!(el && active && el.contains(active)); - } - - /** - * Sets up synchronization with Focus Gating. - */ - private _setupInputSync(previewEnabled: boolean): void { - const updatePreview = (eq: string) => { - if (previewEnabled && this.mathView && this.mathView.value !== eq) { - this.mathView.value = eq; - } - }; - - // Handler 1: MathLive -> Raw LaTeX + Preview - this.mathLiveInputView.on('change:value', () => { - let eq = (this.mathLiveInputView.value ?? '').trim(); + /** Handle delimiter stripping and preview updates. */ + private _setupSync(previewEnabled: boolean): void { + this.mathInputView.on('change:value', () => { + let eq = (this.mathInputView.value ?? '').trim(); // Strip delimiters if present (e.g. pasted content) if (hasDelimiters(eq)) { @@ -177,31 +141,16 @@ export default class MainFormView extends View { eq = params.equation; this.displayButtonView.isOn = params.display; - // Only strip delimiters if not actively editing - if (!this._isViewFocused(this.mathLiveInputView) && this.mathLiveInputView.value !== eq) { - this.mathLiveInputView.value = eq; + // Update the input with stripped delimiters + if (this.mathInputView.value !== eq) { + this.mathInputView.value = eq.length ? eq : null; } } - // Sync to Raw LaTeX only if user isn't typing there - if (!this._isViewFocused(this.rawLatexInputView) && this.rawLatexInputView.value !== eq) { - this.rawLatexInputView.value = eq; + // Update preview + if (previewEnabled && this.mathView && this.mathView.value !== eq) { + this.mathView.value = eq; } - - updatePreview(eq); - }); - - // Handler 2: Raw LaTeX -> MathLive + Preview - this.rawLatexInputView.on('change:value', () => { - const eq = (this.rawLatexInputView.value ?? '').trim(); - const normalized = eq.length ? eq : null; - - // Sync to MathLive only if user isn't interacting with it - if (!this._isViewFocused(this.mathLiveInputView) && this.mathLiveInputView.value !== normalized) { - this.mathLiveInputView.value = normalized; - } - - updatePreview(eq); }); } @@ -220,7 +169,6 @@ export default class MainFormView extends View { btn.on( 'execute', () => { btn.isOn = !btn.isOn; - // mathView updates automatically via bind() } ); return btn; } diff --git a/packages/ckeditor5-math/src/ui/mathinputview.ts b/packages/ckeditor5-math/src/ui/mathinputview.ts new file mode 100644 index 0000000000..72c4e08fe1 --- /dev/null +++ b/packages/ckeditor5-math/src/ui/mathinputview.ts @@ -0,0 +1,198 @@ +import { View, type Locale } from 'ckeditor5'; + +interface MathFieldElement extends HTMLElement { + value: string; + readOnly: boolean; + mathVirtualKeyboardPolicy: string; + inlineShortcuts?: Record; + setValue(value: string, options?: { silenceNotifications?: boolean }): void; +} + +/** + * Combined math input with MathLive visual editor and raw LaTeX textarea. + */ +export default class MathInputView extends View { + public declare value: string | null; + public declare isReadOnly: boolean; + + public mathfield: MathFieldElement | null = null; + private _textarea: HTMLTextAreaElement | null = null; + + constructor(locale: Locale) { + super(locale); + const t = locale.t; + + this.set('value', null); + this.set('isReadOnly', false); + + this.setTemplate({ + tag: 'div', + attributes: { + class: ['ck', 'ck-math-input'] + }, + children: [ + // MathLive container + { + tag: 'div', + attributes: { class: ['ck-mathlive-container'] } + }, + // LaTeX label (outside wrapper) + { + tag: 'label', + attributes: { class: ['ck-latex-label'] }, + children: [t('LaTeX')] + }, + // Raw LaTeX wrapper (just textarea now) + { + tag: 'div', + attributes: { class: ['ck-latex-wrapper'] }, + children: [ + { + tag: 'textarea', + attributes: { + class: ['ck', 'ck-textarea', 'ck-latex-textarea'], + autocapitalize: 'off', + autocomplete: 'off', + autocorrect: 'off', + spellcheck: 'false' + } + } + ] + } + ] + }); + } + + public override render(): void { + super.render(); + + this._textarea = this.element!.querySelector('.ck-latex-textarea') as HTMLTextAreaElement; + this._textarea.value = this.value ?? ''; + this._textarea.readOnly = this.isReadOnly; + + this._loadMathLive(); + + // Textarea -> observable (and sync to mathfield) + this._textarea.addEventListener('input', () => { + const val = this._textarea!.value; + if (this.mathfield) { + this.mathfield.setValue(val, { silenceNotifications: true }); + } + this.value = val.length ? val : null; + }); + + // Observable -> textarea and mathfield + this.on('change:value', (_evt, _name, newValue) => { + const val = newValue ?? ''; + if (this._textarea && this._textarea.value !== val) { + this._textarea.value = val; + } + if (this.mathfield && this.mathfield.value !== val) { + this.mathfield.setValue(val, { silenceNotifications: true }); + } + }); + + this.on('change:isReadOnly', (_evt, _name, newValue) => { + if (this._textarea) this._textarea.readOnly = newValue; + if (this.mathfield) this.mathfield.readOnly = newValue; + }); + } + + private async _loadMathLive(): Promise { + try { + await import('mathlive'); + await customElements.whenDefined('math-field'); + + // Disable MathLive sounds + const MathfieldClass = customElements.get('math-field') as any; + if (MathfieldClass) { + MathfieldClass.soundsDirectory = null; + MathfieldClass.plonkSound = null; + } + + if (!this.element) return; + this._createMathField(); + } catch (error) { + console.error('MathLive load failed:', error); + const container = this.element?.querySelector('.ck-mathlive-container'); + if (container) { + container.textContent = 'Math editor unavailable'; + } + } + } + + private _createMathField(): void { + const container = this.element?.querySelector('.ck-mathlive-container'); + if (!container) return; + + const mathfield = document.createElement('math-field') as MathFieldElement; + mathfield.mathVirtualKeyboardPolicy = 'manual'; + + // Add common shortcuts + mathfield.addEventListener('mount', () => { + mathfield.inlineShortcuts = { + ...mathfield.inlineShortcuts, + dx: 'dx', + dy: 'dy', + dt: 'dt' + }; + }, { once: true }); + + // Set initial value (may have been set before MathLive loaded) + try { + mathfield.value = this.value ?? ''; + } catch { /* MathLive may not be ready */ } + mathfield.readOnly = this.isReadOnly; + + if (this._textarea && this.value) { + this._textarea.value = this.value; + } + + // MathLive -> textarea and observable + mathfield.addEventListener('input', () => { + try { + const val = mathfield.value; + if (this._textarea) this._textarea.value = val; + this.value = val.length ? val : null; + } catch { /* MathLive may not be ready */ } + }); + + // Observable -> MathLive + this.on('change:value', (_evt, _name, newValue) => { + try { + const val = newValue ?? ''; + if (mathfield.value !== val) { + mathfield.setValue(val, { silenceNotifications: true }); + } + } catch { /* MathLive may not be ready */ } + }); + + container.appendChild(mathfield); + this.mathfield = mathfield; + } + + public focus(): void { + this.mathfield?.focus(); + } + + public hideKeyboard(): void { + if (this.mathfield) { + try { + this.mathfield.blur(); + (this.mathfield as any).executeCommand?.('hideVirtualKeyboard'); + } catch { /* MathLive may already be disposed */ } + } + } + + public override destroy(): void { + if (this.mathfield) { + try { + this.mathfield.blur(); + this.mathfield.remove(); + } catch { /* MathLive cleanup error */ } + this.mathfield = null; + } + this._textarea = null; + super.destroy(); + } +} diff --git a/packages/ckeditor5-math/src/ui/mathliveinputview.ts b/packages/ckeditor5-math/src/ui/mathliveinputview.ts deleted file mode 100644 index a2d540228e..0000000000 --- a/packages/ckeditor5-math/src/ui/mathliveinputview.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { View, type Locale } from 'ckeditor5'; - -interface MathFieldElement extends HTMLElement { - value: string; - readOnly: boolean; - mathVirtualKeyboardPolicy: string; - inlineShortcuts?: Record; -} - -export default class MathLiveInputView extends View { - public declare value: string | null; - public declare isReadOnly: boolean; - public mathfield: MathFieldElement | null = null; - - constructor(locale: Locale) { - super(locale); - this.set('value', null); - this.set('isReadOnly', false); - - this.setTemplate({ - tag: 'div', - attributes: { - class: ['ck', 'ck-mathlive-input'] - } - }); - } - - public override render(): void { - super.render(); - this._loadMathLive(); - } - - private async _loadMathLive(): Promise { - try { - await import('mathlive'); - await customElements.whenDefined('math-field'); - - // Configure global MathLive settings - const MathfieldClass = customElements.get( 'math-field' ) as any; - if ( MathfieldClass ) { - MathfieldClass.soundsDirectory = null; - MathfieldClass.plonkSound = null; - } - - if (!this.element) return; - - this._createMathField(); - } catch (error) { - console.error('MathLive load failed:', error); - if (this.element) { - this.element.textContent = 'Math editor unavailable'; - } - } - } - - private _createMathField(): void { - if (!this.element) return; - - const mathfield = document.createElement('math-field') as MathFieldElement; - - // Instance-level config (no prototype pollution) - mathfield.mathVirtualKeyboardPolicy = 'manual'; - - // Configure shortcuts after mount - mathfield.addEventListener('mount', () => { - mathfield.inlineShortcuts = { - ...mathfield.inlineShortcuts, - dx: 'dx', - dy: 'dy', - dt: 'dt' - }; - }, { once: true }); - - // Initial state - mathfield.value = this.value ?? ''; - mathfield.readOnly = this.isReadOnly; - - // DOM -> Observable - mathfield.addEventListener('input', () => { - const val = mathfield.value; - this.value = val.length ? val : null; - }); - - // Observable -> DOM - this.on('change:value', (_evt, _name, nextValue) => { - if (mathfield.value !== nextValue) { - mathfield.value = nextValue ?? ''; - } - }); - - this.on('change:isReadOnly', (_evt, _name, nextValue) => { - mathfield.readOnly = nextValue; - }); - - this.element.appendChild(mathfield); - this.mathfield = mathfield; - } - - public focus(): void { - this.mathfield?.focus(); - } - - public override destroy(): void { - if (this.mathfield) { - this.mathfield.remove(); - this.mathfield = null; - } - super.destroy(); - } -} diff --git a/packages/ckeditor5-math/src/ui/rawlatexinputview.ts b/packages/ckeditor5-math/src/ui/rawlatexinputview.ts deleted file mode 100644 index cc468b4343..0000000000 --- a/packages/ckeditor5-math/src/ui/rawlatexinputview.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { LabeledFieldView, createLabeledTextarea, type Locale, type TextareaView } from 'ckeditor5'; - -/** - * A labeled textarea view for direct LaTeX code editing. - */ -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; - - constructor( locale: Locale ) { - super( locale, createLabeledTextarea ); - - this.set( 'value', '' ); - this.set( 'isReadOnly', false ); - - const fieldView = this.fieldView; - - // 1. Sync: DOM (Textarea) -> Observable - fieldView.on( 'input', () => { - // We cast strictly to HTMLTextAreaElement to access '.value' safely - const textarea = fieldView.element as HTMLTextAreaElement; - if ( textarea ) { - this.value = textarea.value; - } - } ); - - // 2. Sync: Observable -> DOM (Textarea) - this.on( 'change:value', () => { - const textarea = fieldView.element as HTMLTextAreaElement; - // Check for difference to avoid cursor jumping - if ( textarea && textarea.value !== this.value ) { - textarea.value = this.value; - } - } ); - - // 3. Sync: ReadOnly State - this.on( 'change:isReadOnly', ( _evt, _name, nextValue ) => { - fieldView.isReadOnly = nextValue; - } ); - } - - public override render(): void { - super.render(); - } -} diff --git a/packages/ckeditor5-math/tests/index.ts b/packages/ckeditor5-math/tests/index.ts index 4493420e1d..b379ad8c68 100644 --- a/packages/ckeditor5-math/tests/index.ts +++ b/packages/ckeditor5-math/tests/index.ts @@ -3,6 +3,20 @@ import Math from '../src/math'; import AutoformatMath from '../src/autoformatmath'; import { describe, it, expect } from 'vitest'; +// Suppress MathLive errors during async cleanup in tests +if (typeof window !== 'undefined') { + window.addEventListener('unhandledrejection', event => { + if (event.reason?.message?.includes('options') || event.reason?.message?.includes('mathlive')) { + event.preventDefault(); + } + }); + window.addEventListener('error', event => { + if (event.message?.includes('options') || event.message?.includes('mathlive')) { + event.preventDefault(); + } + }); +} + describe( 'CKEditor5 Math DLL', () => { it( 'exports Math', () => { expect( MathDll ).to.equal( Math ); diff --git a/packages/ckeditor5-math/tests/lazyload.ts b/packages/ckeditor5-math/tests/lazyload.ts index 5739843684..173fae184d 100644 --- a/packages/ckeditor5-math/tests/lazyload.ts +++ b/packages/ckeditor5-math/tests/lazyload.ts @@ -2,6 +2,20 @@ import { ClassicEditor, type EditorConfig } from 'ckeditor5'; import MathUI from '../src/mathui'; import { describe, beforeEach, it, afterEach, expect } from "vitest"; +// Suppress MathLive errors during async cleanup +if (typeof window !== 'undefined') { + window.addEventListener('unhandledrejection', event => { + if (event.reason?.message?.includes('options') || event.reason?.message?.includes('mathlive')) { + event.preventDefault(); + } + }); + window.addEventListener('error', event => { + if (event.message?.includes('options') || event.message?.includes('mathlive')) { + event.preventDefault(); + } + }); +} + describe( 'Lazy load', () => { let editorElement: HTMLDivElement; let editor: ClassicEditor; @@ -24,11 +38,14 @@ describe( 'Lazy load', () => { beforeEach( () => { editorElement = document.createElement( 'div' ); document.body.appendChild( editorElement ); - lazyLoadInvoked = false; } ); - afterEach( () => { + afterEach( async () => { + if ( mathUIFeature?.formView ) { + mathUIFeature._hideUI(); + } + await new Promise( resolve => setTimeout( resolve, 50 ) ); editorElement.remove(); return editor.destroy(); } ); diff --git a/packages/ckeditor5-math/tests/mathui.ts b/packages/ckeditor5-math/tests/mathui.ts index 6317b5e66c..94becd000e 100644 --- a/packages/ckeditor5-math/tests/mathui.ts +++ b/packages/ckeditor5-math/tests/mathui.ts @@ -168,13 +168,13 @@ describe( 'MathUI', () => { command.isEnabled = true; - expect( formView!.mathLiveInputView.isReadOnly ).to.be.false; + expect( formView!.mathInputView.isReadOnly ).to.be.false; expect( formView!.saveButtonView.isEnabled ).to.be.false; expect( formView!.cancelButtonView.isEnabled ).to.be.true; command.isEnabled = false; - expect( formView!.mathLiveInputView.isReadOnly ).to.be.true; + expect( formView!.mathInputView.isReadOnly ).to.be.true; expect( formView!.saveButtonView.isEnabled ).to.be.false; expect( formView!.cancelButtonView.isEnabled ).to.be.true; } ); @@ -407,30 +407,30 @@ describe( 'MathUI', () => { setModelData( editor.model, 'f[o]o' ); } ); - it( 'should bind mainFormView.mathLiveInputView#value to math command value', () => { + it( 'should bind mainFormView.mathInputView#value to math command value', () => { const command = editor.commands.get( 'math' ); - expect( formView!.mathLiveInputView.value ).to.be.null; + expect( formView!.mathInputView.value ).to.be.null; command!.value = 'x^2'; - expect( formView!.mathLiveInputView.value ).to.equal( 'x^2' ); + expect( formView!.mathInputView.value ).to.equal( 'x^2' ); } ); it( 'should execute math command on mainFormView#submit event', () => { const executeSpy = vi.spyOn( editor, 'execute' ); - formView!.mathLiveInputView.value = 'x^2'; + formView!.mathInputView.value = 'x^2'; formView!.fire( 'submit' ); expect( executeSpy.mock.lastCall?.slice( 0, 2 ) ).toMatchObject( [ 'math', 'x^2' ] ); } ); - it( 'should sync mathLiveInputView and rawLatexInputView', () => { - formView!.mathLiveInputView.value = 'x^2'; - expect( formView!.rawLatexInputView.value ).to.equal( 'x^2' ); + it( 'should update equation value when mathInputView changes', () => { + formView!.mathInputView.value = 'x^2'; + expect( formView!.equation ).to.equal( 'x^2' ); - formView!.rawLatexInputView.value = '\\frac{1}{2}'; - expect( formView!.mathLiveInputView.value ).to.equal( '\\frac{1}{2}' ); + formView!.mathInputView.value = '\\frac{1}{2}'; + expect( formView!.equation ).to.equal( '\\frac{1}{2}' ); } ); it( 'should hide the balloon on mainFormView#cancel if math command does not have a value', () => { diff --git a/packages/ckeditor5-math/theme/mathform.css b/packages/ckeditor5-math/theme/mathform.css index 1465b9e1b7..0d401fa5e8 100644 --- a/packages/ckeditor5-math/theme/mathform.css +++ b/packages/ckeditor5-math/theme/mathform.css @@ -1,6 +1,5 @@ /** * Math equation editor dialog styles - * Supports MathLive input, raw LaTeX textarea, and equation preview */ /* ============================================================================ @@ -14,8 +13,7 @@ box-sizing: border-box; max-width: 80vw; max-height: 80vh; - height: 100%; - overflow-x: hidden; + overflow: hidden; } /* Mobile responsiveness */ @@ -34,41 +32,12 @@ flex-direction: column; flex: 1 1 auto; gap: var(--ck-spacing-standard); - min-height: fit-content; + min-height: 0; min-width: 0; width: 100%; + overflow: hidden; } -/* LaTeX section heading */ -.ck-math-view > .ck-labeled-field-view::before { - content: "LaTeX"; - display: block; - font-size: 12px; - font-weight: 600; - color: var(--ck-color-text, #333); - margin-bottom: 4px; - padding-left: 2px; - opacity: 0.8; -} - -/* Equation preview section heading */ -.ck-math-view > math-field::before { - content: "Equation preview"; - display: block; - font-size: 12px; - font-weight: 600; - color: var(--ck-color-text, #333); - margin-bottom: 4px; - padding-left: 2px; - opacity: 0.8; -} - -/* Add spacing between preview and action buttons */ -.ck-math-view > math-field { - margin-bottom: var(--ck-spacing-large, 16px); -} - -/* Action buttons row (Save/Cancel) */ .ck-math-button-row { display: flex; flex-shrink: 0; @@ -87,14 +56,34 @@ .ck.ck-math-form math-field, .ck.ck-math-form textarea { box-sizing: border-box; - padding: var(--ck-spacing-small); - background: var(--ck-color-input-background) !important; - color: var(--ck-color-input-text, inherit); font-size: var(--ck-font-size-base); + color: var(--ck-color-input-text, inherit); + background: transparent !important; border: none !important; - border-radius: var(--ck-border-radius, 6px); - outline: 3px solid transparent; - outline-offset: 6px; + border-radius: 0; + padding: 0; + margin: 0; + box-shadow: none !important; + outline: none !important; + outline-offset: 0 !important; + transition: none !important; + animation: none !important; +} + +.ck.ck-math-input .ck-textarea, +.ck.ck-math-input .ck-textarea:focus, +.ck.ck-math-input .ck-textarea:hover { + transition: none !important; + box-shadow: none !important; + animation: none !important; +} + +.ck.ck-math-form math-field:focus-within, +.ck.ck-math-form textarea:focus { + outline: none !important; + outline-offset: 0 !important; + box-shadow: none !important; + transition: none !important; } /* MathLive-specific configuration */ @@ -111,21 +100,9 @@ } /* ============================================================================ - MathLive Visual Editor (Top Input) + MathLive Configuration ========================================================================= */ -.ck.ck-mathlive-input { - display: inline-block; - flex: 0 0 auto; - width: 100%; - max-width: 100%; - min-height: fit-content; - max-height: 80vh; - overflow: auto; - padding-bottom: var(--ck-spacing-small); - resize: none; -} - /* Configure MathLive shadow DOM layout */ .ck.ck-math-form math-field::part(container), .ck.ck-math-form math-field::part(content), @@ -156,62 +133,88 @@ } /* ============================================================================ - Raw LaTeX Textarea (Middle Input) + Combined Math Input (MathLive + LaTeX Textarea) ========================================================================= */ -.ck-math-view .ck-labeled-field-view { +.ck.ck-math-input { + display: flex; + flex-direction: column; + gap: var(--ck-spacing-standard); + width: 100%; + flex: 1 1 auto; + min-height: 0; + overflow: hidden; +} + +.ck.ck-math-input .ck-mathlive-container { + display: flex; + width: 100%; + min-height: 50px; + flex-shrink: 0; + padding: var(--ck-spacing-small); + border: 1px solid var(--ck-color-input-border, #ccc); + border-radius: var(--ck-border-radius, 6px); + background: var(--ck-color-input-background) !important; + transition: border-color 120ms ease; +} + +.ck.ck-math-input .ck-mathlive-container:focus-within { + border-color: var(--ck-color-focus-border, #1a73e8); +} + +.ck.ck-math-input .ck-latex-wrapper { display: flex; flex-direction: column; - flex: 0 0 auto; - min-width: 100%; width: 100%; - max-width: 100%; min-height: 60px; - max-height: 65vh; + flex: 1 1 auto; + padding: var(--ck-spacing-small); + border: 1px solid var(--ck-color-input-border, #ccc); + border-radius: var(--ck-border-radius, 6px); + background: var(--ck-color-input-background) !important; + transition: border-color 120ms ease; +} + +.ck.ck-math-input .ck-latex-wrapper:focus-within { + border-color: var(--ck-color-focus-border, #1a73e8); +} + +.ck.ck-math-input .ck-latex-label { + display: block; + font-size: 12px; + font-weight: 600; + color: var(--ck-color-text, #333); + opacity: 0.8; + margin: 0 0 var(--ck-spacing-small) 0; +} + +.ck.ck-math-input .ck-latex-textarea { + display: block; + width: 100%; + min-width: 100%; + min-height: 60px; + max-height: 100%; + flex: 1 1 auto; resize: both; overflow: auto; - background: transparent; -} - -/* Hide the default label (we use ::before for custom heading) */ -.ck-math-view .ck-labeled-field-view .ck-label { - display: none !important; -} - -/* Textarea wrapper */ -.ck-math-view .ck-labeled-field-view .ck-labeled-field-view__input-wrapper { - display: flex; - flex-direction: column; - flex: 1 1 auto; - width: 100%; - min-height: 100px; - height: auto; + font-family: monospace; + background: transparent !important; + border: none !important; padding: 0; - border: none; - background: transparent; - box-shadow: none; + box-shadow: none !important; + transition: none !important; + animation: none !important; } -/* Raw LaTeX textarea styling */ -.ck-math-view .ck-labeled-field-view textarea { - display: block; - flex: 1 1 auto; - width: 100% !important; - height: 100%; - min-height: 140px; - resize: none !important; - border-radius: 0 !important; +.ck.ck-math-input .ck-latex-textarea:focus { + outline: none; box-shadow: none !important; transition: none !important; } -/* Textarea hover and focus states */ -.ck-math-view .ck-labeled-field-view textarea:hover, -.ck-math-view .ck-labeled-field-view textarea:focus { - background: var(--ck-color-input-background) !important; - outline: none !important; - box-shadow: none !important; -} +/* ============================================================================ + Error State + ========================================================================= */ .ck-math-render-error { color: var(--ck-color-error-text, #db1d1d); @@ -222,3 +225,13 @@ border-radius: 2px; background: var(--ck-color-base-background, #fff); } + +/* ============================================================================ + Preview Section (always at bottom) + ========================================================================= */ + +.ck-math-preview { + flex-shrink: 0; + overflow-x: auto; + overflow-y: hidden; +} diff --git a/packages/ckeditor5-math/vitest.config.ts b/packages/ckeditor5-math/vitest.config.ts index fb7ccfff03..47a35ced82 100644 --- a/packages/ckeditor5-math/vitest.config.ts +++ b/packages/ckeditor5-math/vitest.config.ts @@ -22,6 +22,9 @@ export default defineConfig( { include: [ 'tests/**/*.[jt]s' ], + exclude: [ + 'tests/setup.ts' + ], globals: true, watch: false, coverage: {