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 ab00a473e..ef09e139c 100644 --- a/packages/ckeditor5-math/package.json +++ b/packages/ckeditor5-math/package.json @@ -71,6 +71,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..15e3ed55d 100644 --- a/packages/ckeditor5-math/src/ui/mainformview.ts +++ b/packages/ckeditor5-math/src/ui/mainformview.ts @@ -1,270 +1,219 @@ -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 ); + // --- 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'; - // Equation input - this.mathInputView = this._createMathInput(); + this.cancelButtonView = this._createButton( t( 'Cancel' ), IconCancel, 'ck-button-cancel' ); + this.cancelButtonView.delegate( 'execute' ).to( this, 'cancel' ); - // Display button - this.displayButtonView = this._createDisplayButton(); + this.displayButtonView = this._createDisplayButton( t ); - // Cancel button - this.cancelButtonView = this._createButton( t( 'Cancel' ), IconCancel, 'ck-button-cancel', 'cancel' ); + // --- 2. Construct Children & Preview --- - this.previewEnabled = previewEnabled; + const children: View[] = [ + this.mathLiveInputView, + this.rawLatexInputView, + this.displayButtonView + ]; - let children = []; - if ( this.previewEnabled ) { - // Preview label - this.previewLabel = new LabelView( locale ); - this.previewLabel.text = t( 'Equation preview' ); + if ( previewEnabled ) { + const previewLabel = new LabelView( locale ); + previewLabel.text = t( 'Equation preview' ); - // Math element - this.mathView = new MathView( engine, lazyLoad, locale, previewUid, previewClassName, katexRenderOptions ); + // 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 ?? ''; - } + /** + * Sets up split handlers for synchronization. + */ + private _setupInputSync( previewEnabled: boolean ): void { + // Handler 1: MathLive -> Raw LaTeX + this.mathLiveInputView.on( 'change:value', () => { + let eq = ( this.mathLiveInputView.value ?? '' ).trim(); - 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; - } - } + // Delimiter Normalization + if ( hasDelimiters( eq ) ) { + const params = extractDelimiters( eq ); + eq = params.equation; + this.displayButtonView.isOn = params.display; - 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; + // 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; } - if ( this.previewEnabled && this.mathView ) { - // Update preview view - this.mathView.value = equationInput; - } - - this.saveButtonView.isEnabled = !!equationInput; } - }; - fieldView.on( 'render', onInput ); - fieldView.on( 'input', onInput ); + // Sync to Raw LaTeX + if ( this.rawLatexInputView.value !== eq ) { + this.rawLatexInputView.value = eq; + } - return mathInput; + // 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; + } + + // Sync to Preview + if ( previewEnabled && this.mathView && this.mathView.value !== eq ) { + this.mathView.value = 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 ): ButtonView { + const btn = new ButtonView( this.locale ); + btn.set( { label, icon, tooltip: true } ); + btn.extendTemplate( { attributes: { class: className } } ); + 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..f761be787 --- /dev/null +++ b/packages/ckeditor5-math/src/ui/mathliveinputview.ts @@ -0,0 +1,116 @@ +import { View, type Locale } from 'ckeditor5'; +import 'mathlive'; // Import side-effects only (registers the tag) + +/** + * Interface describing the custom element. + */ +interface MathFieldElement extends HTMLElement { + value: string; + readOnly: boolean; + mathVirtualKeyboardPolicy: string; + // Interface includes the shortcuts property + inlineShortcuts: Record; +} + +/** + * A wrapper for the MathLive component. + */ +export default class MathLiveInputView extends View { + /** + * The current LaTeX value. + * @observable + */ + public declare value: string | null; + + /** + * Read-only state. + * @observable + */ + public declare isReadOnly: boolean; + + /** + * Reference to the DOM element. + * Typed as MathFieldElement | null for proper TS support. + */ + 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(); + + // 1. Create element with the specific type + const mathfield = document.createElement( 'math-field' ) as MathFieldElement; + + // 2. Configure Options + mathfield.mathVirtualKeyboardPolicy = 'manual'; + + //Disable differential D + mathfield.addEventListener( 'mount', () => { + mathfield.inlineShortcuts = { + ...mathfield.inlineShortcuts, // Safe to read now + dx: 'dx', + dy: 'dy', + dt: 'dt' + }; + } ); + + + // Disable sounds safely + const MathfieldConstructor = customElements.get( 'math-field' ); + if ( MathfieldConstructor ) { + const proto = MathfieldConstructor as any; + if ( proto.soundsDirectory !== null ) proto.soundsDirectory = null; + if ( proto.plonkSound !== null ) proto.plonkSound = null; + } + + // 3. Set Initial State + mathfield.value = this.value ?? ''; + mathfield.readOnly = this.isReadOnly; + + // 4. Bind Events (DOM -> Observable) + mathfield.addEventListener( 'input', () => { + const val = mathfield.value; + this.value = val.length ? val : null; + } ); + + // 5. Bind Events (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; + } ); + + // 6. Mount to the wrapper view + 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..42a4c0df1 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(); @@ -56,16 +56,20 @@ export default class MathView extends View { public updateMath(): void { if ( this.element ) { + + // This prevents the new render from appending to the old one. + this.element.textContent = ''; + 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 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/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..c2a1476bb 100644 --- a/packages/ckeditor5-math/theme/mathform.css +++ b/packages/ckeditor5-math/theme/mathform.css @@ -1,35 +1,214 @@ +/** + * 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; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0dff9e3d7..72c3522f0 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 @@ -2123,6 +2126,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'} @@ -6889,6 +6896,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==} @@ -10205,6 +10216,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==} @@ -15636,6 +15650,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: @@ -15782,6 +15798,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: @@ -15846,8 +15864,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: @@ -16958,6 +16974,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 @@ -22604,6 +22625,8 @@ snapshots: compare-versions@6.1.1: {} + complex-esm@2.1.1-esm1: {} + component-emitter@1.3.1: {} compress-commons@6.0.2: @@ -23390,8 +23413,7 @@ snapshots: decimal.js@10.5.0: {} - decimal.js@10.6.0: - optional: true + decimal.js@10.6.0: {} decko@1.2.0: {} @@ -26810,6 +26832,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: