diff --git a/.gitignore b/.gitignore index b2c4e3c46..9ea55440e 100644 --- a/.gitignore +++ b/.gitignore @@ -48,4 +48,4 @@ upload .svelte-kit # docs -site/ +site/ \ No newline at end of file diff --git a/packages/ckeditor5-math/package.json b/packages/ckeditor5-math/package.json index 96a0b6395..ca1909d22 100644 --- a/packages/ckeditor5-math/package.json +++ b/packages/ckeditor5-math/package.json @@ -70,6 +70,7 @@ ] }, "dependencies": { - "@ckeditor/ckeditor5-icons": "47.2.0" + "@ckeditor/ckeditor5-icons": "47.2.0", + "mathlive": "0.108.2" } } diff --git a/packages/ckeditor5-math/src/index.ts b/packages/ckeditor5-math/src/index.ts index 6d6982ae2..eac4d7b22 100644 --- a/packages/ckeditor5-math/src/index.ts +++ b/packages/ckeditor5-math/src/index.ts @@ -1,6 +1,9 @@ import ckeditor from './../theme/icons/math.svg?raw'; import './augmentation.js'; import "../theme/mathform.css"; +import 'mathlive'; +import 'mathlive/fonts.css'; +import 'mathlive/static.css'; export { default as Math } from './math.js'; export { default as MathUI } from './mathui.js'; diff --git a/packages/ckeditor5-math/src/mathui.ts b/packages/ckeditor5-math/src/mathui.ts index 4c4a2794c..a27ee87fd 100644 --- a/packages/ckeditor5-math/src/mathui.ts +++ b/packages/ckeditor5-math/src/mathui.ts @@ -56,7 +56,7 @@ export default class MathUI extends Plugin { this._balloon.showStack( 'main' ); requestAnimationFrame(() => { - this.formView?.mathInputView.fieldView.element?.focus(); + this.formView?.mathLiveInputView.focus(); }); } @@ -71,31 +71,38 @@ 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.mathInputView.bind( 'value' ).to( mathCommand, 'value' ); + formView.mathLiveInputView.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 ); + formView.mathLiveInputView.bind( 'isReadOnly' ).to( mathCommand, 'isEnabled', value => !value ); + formView.saveButtonView.bind( 'isEnabled' ).to( + mathCommand, + 'isEnabled', + formView.mathLiveInputView, + 'value', + ( commandEnabled, equation ) => { + const normalizedEquation = ( equation ?? '' ).trim(); + return commandEnabled && normalizedEquation.length > 0; + } + ); + formView.displayButtonView.bind( 'isEnabled' ).to( mathCommand, 'isEnabled' ); // Listen to submit button click this.listenTo( formView, 'submit', () => { @@ -122,18 +129,6 @@ export default class MathUI extends Plugin { } }); - // Allow the textarea to be resizable - formView.mathInputView.fieldView.once('render', () => { - const textarea = formView.mathInputView.fieldView.element; - if (!textarea) return; - Object.assign(textarea.style, { - resize: 'both', - height: '100px', - width: '400px', - minWidth: '100%', - }); - }); - return formView; } @@ -162,14 +157,14 @@ export default class MathUI extends Plugin { } ); if ( this._balloon.visibleView === this.formView ) { - this.formView.mathInputView.fieldView.element?.select(); + this.formView.mathLiveInputView.focus(); } // 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 2d1c59793..d7ceb2f20 100644 --- a/packages/ckeditor5-math/src/ui/mainformview.ts +++ b/packages/ckeditor5-math/src/ui/mainformview.ts @@ -1,270 +1,227 @@ -import { ButtonView, createLabeledTextarea, FocusCycler, LabelView, LabeledFieldView, submitHandler, SwitchButtonView, View, ViewCollection, type TextareaView, 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 { + 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'; - -class MathInputView extends LabeledFieldView { - public value: null | string = null; - public isReadOnly = false; - - constructor( locale: Locale ) { - super( locale, createLabeledTextarea ); - } -} 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 displayButtonView: SwitchButtonView; + + public mathLiveInputView: MathLiveInputView; + public rawLatexInputView: RawLatexInputView; public mathView?: MathView; - public override locale: Locale = new Locale(); - public lazyLoad: undefined | ( () => Promise ); + + 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 ); - const t = locale.t; - // Submit button - this.saveButtonView = this._createButton( t( 'Save' ), IconCheck, 'ck-button-save', null ); - this.saveButtonView.type = 'submit'; + // --- 1. View Initialization --- - // Equation input - this.mathInputView = this._createMathInput(); + this.mathLiveInputView = new MathLiveInputView( locale ); + this.rawLatexInputView = new RawLatexInputView( locale ); + this.rawLatexInputView.label = t( 'LaTeX' ); - // Display button - this.displayButtonView = this._createDisplayButton(); + this.saveButtonView = this._createButton( t( 'Save' ), IconCheck, 'ck-button-save', 'submit' ); - // Cancel button - this.cancelButtonView = this._createButton( t( 'Cancel' ), IconCancel, 'ck-button-cancel', 'cancel' ); + this.cancelButtonView = this._createButton( t( 'Cancel' ), IconCancel, 'ck-button-cancel' ); + this.cancelButtonView.delegate( 'execute' ).to( this, 'cancel' ); - this.previewEnabled = previewEnabled; + this.displayButtonView = this._createDisplayButton( t ); - let children = []; - if ( this.previewEnabled ) { - // Preview label - this.previewLabel = new LabelView( locale ); - this.previewLabel.text = t( 'Equation preview' ); + // --- 2. Construct Children & Preview --- - // Math element - this.mathView = new MathView( engine, lazyLoad, locale, previewUid, previewClassName, katexRenderOptions ); + const children: View[] = [ + this.mathLiveInputView, + this.rawLatexInputView, + this.displayButtonView + ]; + + if ( previewEnabled ) { + 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 = [ - this.mathInputView, - this.displayButtonView, - this.previewLabel, - this.mathView - ]; - } else { - children = [ - this.mathInputView, - 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-view' - ] - }, - children + attributes: { class: [ 'ck-math-scroll' ] }, + children: [ { tag: 'div', attributes: { class: [ 'ck-math-view' ] }, children } ] }, - this.saveButtonView, - this.cancelButtonView + { + tag: 'div', + 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 = [ - this.mathInputView, + // 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 ); - } + if ( this.element ) this.keystrokes.listenTo( this.element ); + } + + public get equation(): string { + return this.mathLiveInputView.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; + if ( this.mathView ) this.mathView.value = norm; } public focus(): void { this._focusCycler.focusFirst(); } - public get equation(): string { - return this.mathInputView.fieldView.element?.value ?? ''; + /** + * 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)); } - 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(); - 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; + /** + * 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; } }; - fieldView.on( 'render', onInput ); - fieldView.on( 'input', onInput ); + // Handler 1: MathLive -> Raw LaTeX + Preview + this.mathLiveInputView.on('change:value', () => { + let eq = (this.mathLiveInputView.value ?? '').trim(); - return mathInput; + // Strip delimiters if present (e.g. pasted content) + if (hasDelimiters(eq)) { + const params = extractDelimiters(eq); + 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; + } + } + + // Sync to Raw LaTeX only if user isn't typing there + if (!this._isViewFocused(this.rawLatexInputView) && this.rawLatexInputView.value !== eq) { + this.rawLatexInputView.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); + }); } - 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 _createButton( label: string, icon: string, className: string, type?: 'submit' | 'button' ): ButtonView { + const btn = new ButtonView( this.locale ); + btn.set( { label, icon, tooltip: true } ); + btn.extendTemplate( { attributes: { class: className } } ); + if (type) btn.type = type; + return btn; } - private _createDisplayButton() { - const t = this.locale.t; + 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' } } ); - const switchButton = new SwitchButtonView( this.locale ); - - switchButton.set( { - label: t( 'Display mode' ), - withText: true + btn.on( 'execute', () => { + btn.isOn = !btn.isOn; + // mathView updates automatically via bind() } ); - - 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 new file mode 100644 index 000000000..a2d540228 --- /dev/null +++ b/packages/ckeditor5-math/src/ui/mathliveinputview.ts @@ -0,0 +1,110 @@ +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/mathview.ts b/packages/ckeditor5-math/src/ui/mathview.ts index fab16262e..87af15d08 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(); @@ -55,19 +55,39 @@ export default class MathView extends View { } 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 - ); + if (!this.element) { + return; } + + // Handle empty equations + if (!this.value || !this.value.trim()) { + this.element.textContent = ''; + this.element.classList.remove('ck-math-render-error'); + return; + } + + // Clear previous render + this.element.textContent = ''; + this.element.classList.remove('ck-math-render-error'); + + renderEquation( + this.value, + this.element, + this.options.engine, + this.options.lazyLoad, + this.display, + true, // isPreview + this.options.previewUid, + this.options.previewClassName, + this.options.katexRenderOptions + ).catch(error => { + console.error('Math rendering failed:', error); + + if (this.element) { + this.element.textContent = 'Error rendering equation'; + this.element.classList.add('ck-math-render-error'); + } + }); } public override render(): void { diff --git a/packages/ckeditor5-math/src/ui/rawlatexinputview.ts b/packages/ckeditor5-math/src/ui/rawlatexinputview.ts new file mode 100644 index 000000000..cc468b434 --- /dev/null +++ b/packages/ckeditor5-math/src/ui/rawlatexinputview.ts @@ -0,0 +1,54 @@ +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/lazyload.ts b/packages/ckeditor5-math/tests/lazyload.ts index 126507850..573984368 100644 --- a/packages/ckeditor5-math/tests/lazyload.ts +++ b/packages/ckeditor5-math/tests/lazyload.ts @@ -37,6 +37,7 @@ describe( 'Lazy load', () => { await buildEditor( { math: { engine: 'katex', + enablePreview: true, lazyLoad: async () => { lazyLoadInvoked = true; } @@ -44,6 +45,15 @@ describe( 'Lazy load', () => { } ); mathUIFeature._showUI(); + + // Trigger render with a non-empty value to bypass empty check optimization + if ( mathUIFeature.formView ) { + mathUIFeature.formView.equation = 'x^2'; + } + + // Wait for async rendering and lazy loading + await new Promise( resolve => setTimeout( resolve, 100 ) ); + expect( lazyLoadInvoked ).to.be.true; } ); } ); diff --git a/packages/ckeditor5-math/tests/mathui.ts b/packages/ckeditor5-math/tests/mathui.ts index 5a392c0db..6317b5e66 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!.mathInputView.isReadOnly ).to.be.false; + expect( formView!.mathLiveInputView.isReadOnly ).to.be.false; expect( formView!.saveButtonView.isEnabled ).to.be.false; expect( formView!.cancelButtonView.isEnabled ).to.be.true; command.isEnabled = false; - expect( formView!.mathInputView.isReadOnly ).to.be.true; + expect( formView!.mathLiveInputView.isReadOnly ).to.be.true; expect( formView!.saveButtonView.isEnabled ).to.be.false; expect( formView!.cancelButtonView.isEnabled ).to.be.true; } ); @@ -407,22 +407,30 @@ describe( 'MathUI', () => { setModelData( editor.model, 'f[o]o' ); } ); - it( 'should bind mainFormView.mathInputView#value to math command value', () => { + it( 'should bind mainFormView.mathLiveInputView#value to math command value', () => { const command = editor.commands.get( 'math' ); - expect( formView!.mathInputView.value ).to.null; + expect( formView!.mathLiveInputView.value ).to.be.null; command!.value = 'x^2'; - expect( formView!.mathInputView.value ).to.equal( 'x^2' ); + expect( formView!.mathLiveInputView.value ).to.equal( 'x^2' ); } ); it( 'should execute math command on mainFormView#submit event', () => { const executeSpy = vi.spyOn( editor, 'execute' ); - formView!.mathInputView.fieldView.element!.value = 'x^2'; + formView!.mathLiveInputView.value = 'x^2'; formView!.fire( 'submit' ); - expect(executeSpy.mock.lastCall?.slice(0, 2)).toMatchObject(['math', 'x^2']); + 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' ); + + formView!.rawLatexInputView.value = '\\frac{1}{2}'; + expect( formView!.mathLiveInputView.value ).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 3b7b4047f..1465b9e1b 100644 --- a/packages/ckeditor5-math/theme/mathform.css +++ b/packages/ckeditor5-math/theme/mathform.css @@ -1,35 +1,224 @@ +/** + * Math equation editor dialog styles + * Supports MathLive input, raw LaTeX textarea, and equation preview + */ + +/* ============================================================================ + Main Dialog Container + ========================================================================= */ + .ck.ck-math-form { - display: flex; - align-items: flex-start; - flex-direction: row; - flex-wrap: nowrap; - padding: var(--ck-spacing-standard); - - @media screen and (max-width: 600px) { - flex-wrap: wrap; - - & .ck-math-view { - flex-basis: 100%; - - & .ck-labeled-view { - flex-basis: 100%; - } - - & .ck-label { - flex-basis: 100%; - } - } - - & .ck-button { - flex-basis: 50%; - } - } + display: flex; + flex-direction: column; + padding: var(--ck-spacing-standard); + box-sizing: border-box; + max-width: 80vw; + max-height: 80vh; + height: 100%; + overflow-x: hidden; } -.ck-math-tex.ck-placeholder::before { - display: none !important; +/* Mobile responsiveness */ +@media screen and (max-width: 600px) { + .ck.ck-math-form { + flex-wrap: wrap; + } } -.ck.ck-toolbar-container { - z-index: calc(var(--ck-z-panel) + 2); +/* ============================================================================ + Content Layout + ========================================================================= */ + +.ck-math-view { + display: flex; + flex-direction: column; + flex: 1 1 auto; + gap: var(--ck-spacing-standard); + min-height: fit-content; + min-width: 0; + width: 100%; +} + +/* 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; + gap: var(--ck-spacing-standard); + margin-top: var(--ck-spacing-standard); + width: fit-content; + max-width: 100%; + flex-wrap: wrap; +} + +/* ============================================================================ + Shared Styles for Input Fields + ========================================================================= */ + +/* Base styling for both MathLive fields and textareas */ +.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); + border: none !important; + border-radius: var(--ck-border-radius, 6px); + outline: 3px solid transparent; + outline-offset: 6px; +} + +/* MathLive-specific configuration */ +.ck.ck-math-form math-field { + display: block !important; + width: 100%; + max-width: 100%; + overflow-x: auto !important; + + /* MathLive theme customization */ + --selection-background-color: rgba(33, 150, 243, 0.2); + --selection-color: inherit; + --contains-highlight-background-color: rgba(0, 0, 0, 0.05); +} + +/* ============================================================================ + MathLive Visual Editor (Top Input) + ========================================================================= */ + +.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), +.ck.ck-math-form math-field::part(field) { + display: flex; + flex-direction: column; + flex: 1 1 auto; + height: 100%; + align-items: flex-start; + justify-content: flex-start; +} + +/* Position MathLive UI controls */ +.ck.ck-math-form math-field::part(virtual-keyboard-toggle), +.ck.ck-math-form math-field::part(menu-toggle) { + position: absolute; + top: 8px; +} + +.ck.ck-math-form math-field::part(virtual-keyboard-toggle) { + right: 40px; +} + +.ck.ck-math-form math-field::part(menu-toggle) { + right: 8px; + display: flex !important; + visibility: visible !important; +} + +/* ============================================================================ + Raw LaTeX Textarea (Middle Input) + ========================================================================= */ + +.ck-math-view .ck-labeled-field-view { + display: flex; + flex-direction: column; + flex: 0 0 auto; + min-width: 100%; + width: 100%; + max-width: 100%; + min-height: 60px; + max-height: 65vh; + 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; + padding: 0; + border: none; + background: transparent; + box-shadow: none; +} + +/* 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; + 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; +} + +.ck-math-render-error { + color: var(--ck-color-error-text, #db1d1d); + padding: var(--ck-spacing-small); + font-style: italic; + font-size: 0.9em; + border: 1px dashed var(--ck-color-error-text, #db1d1d); + border-radius: 2px; + background: var(--ck-color-base-background, #fff); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9f51386c6..95632654f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1061,6 +1061,9 @@ importers: '@ckeditor/ckeditor5-icons': specifier: 47.2.0 version: 47.2.0 + mathlive: + specifier: 0.108.2 + version: 0.108.2 devDependencies: '@ckeditor/ckeditor5-dev-build-tools': specifier: 43.1.0 @@ -2103,6 +2106,10 @@ packages: resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} engines: {node: '>=0.1.90'} + '@cortex-js/compute-engine@0.30.2': + resolution: {integrity: sha512-Zx+iisk9WWdbxjm8EYsneIBszvjfUs7BHNwf1jBtSINIgfWGpHrTTq9vW0J59iGCFt6bOFxbmWyxNMRSmksHMA==} + engines: {node: '>=21.7.3', npm: '>=10.5.0'} + '@cspotcode/source-map-support@0.8.1': resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} @@ -6681,6 +6688,10 @@ packages: compare-versions@6.1.1: resolution: {integrity: sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==} + complex-esm@2.1.1-esm1: + resolution: {integrity: sha512-IShBEWHILB9s7MnfyevqNGxV0A1cfcSnewL/4uPFiSxkcQL4Mm3FxJ0pXMtCXuWLjYz3lRRyk6OfkeDZcjD6nw==} + engines: {node: '>=16.14.2', npm: '>=8.5.0'} + component-emitter@1.3.1: resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==} @@ -9886,6 +9897,9 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + mathlive@0.108.2: + resolution: {integrity: sha512-GIZkfprGTxrbHckOvwo92ZmOOxdD018BHDzlrEwYUU+pzR5KabhqI1s43lxe/vqXdF5RLiQKgDcuk5jxEjhkYg==} + mathml-tag-names@2.1.3: resolution: {integrity: sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg==} @@ -15004,6 +15018,8 @@ snapshots: '@ckeditor/ckeditor5-core': 47.2.0 '@ckeditor/ckeditor5-upload': 47.2.0 ckeditor5: 47.2.0(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41) + transitivePeerDependencies: + - supports-color '@ckeditor/ckeditor5-ai@47.2.0(bufferutil@4.0.9)(utf-8-validate@6.0.5)': dependencies: @@ -15150,6 +15166,8 @@ snapshots: '@ckeditor/ckeditor5-core': 47.2.0 '@ckeditor/ckeditor5-utils': 47.2.0 ckeditor5: 47.2.0(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41) + transitivePeerDependencies: + - supports-color '@ckeditor/ckeditor5-code-block@47.2.0(patch_hash=2361d8caad7d6b5bddacc3a3b4aa37dbfba260b1c1b22a450413a79c1bb1ce95)': dependencies: @@ -15214,8 +15232,6 @@ snapshots: '@ckeditor/ckeditor5-utils': 47.2.0 '@ckeditor/ckeditor5-watchdog': 47.2.0 es-toolkit: 1.39.5 - transitivePeerDependencies: - - supports-color '@ckeditor/ckeditor5-dev-build-tools@43.1.0(@swc/helpers@0.5.17)(tslib@2.8.1)(typescript@5.9.3)': dependencies: @@ -15369,6 +15385,8 @@ snapshots: '@ckeditor/ckeditor5-utils': 47.2.0 ckeditor5: 47.2.0(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41) es-toolkit: 1.39.5 + transitivePeerDependencies: + - supports-color '@ckeditor/ckeditor5-editor-multi-root@47.2.0': dependencies: @@ -16277,6 +16295,11 @@ snapshots: '@colors/colors@1.5.0': {} + '@cortex-js/compute-engine@0.30.2': + dependencies: + complex-esm: 2.1.1-esm1 + decimal.js: 10.6.0 + '@cspotcode/source-map-support@0.8.1': dependencies: '@jridgewell/trace-mapping': 0.3.9 @@ -21808,6 +21831,8 @@ snapshots: compare-versions@6.1.1: {} + complex-esm@2.1.1-esm1: {} + component-emitter@1.3.1: {} compress-commons@6.0.2: @@ -22542,8 +22567,7 @@ snapshots: decimal.js@10.5.0: {} - decimal.js@10.6.0: - optional: true + decimal.js@10.6.0: {} decko@1.2.0: {} @@ -25840,6 +25864,10 @@ snapshots: math-intrinsics@1.1.0: {} + mathlive@0.108.2: + dependencies: + '@cortex-js/compute-engine': 0.30.2 + mathml-tag-names@2.1.3: {} mdast-util-find-and-replace@3.0.2: