Merge 162c076a145194b80f3275b4f85fbe790d0a332b into b8585594cd138783588a7ac4d6a3260de779427d

This commit is contained in:
Meinzzzz 2025-12-04 11:03:26 +00:00 committed by GitHub
commit 4e84282ada
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 685 additions and 310 deletions

View File

@ -70,6 +70,7 @@
]
},
"dependencies": {
"@ckeditor/ckeditor5-icons": "47.2.0"
"@ckeditor/ckeditor5-icons": "47.2.0",
"mathlive": "0.108.2"
}
}

View File

@ -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';

View File

@ -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 ?? '';

View File

@ -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<TextareaView> {
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<void> );
public focusTracker = new FocusTracker();
public keystrokes = new KeystrokeHandler();
private _focusables = new ViewCollection<FocusableView>();
private _focusCycler: FocusCycler;
constructor(
locale: Locale,
engine:
| 'mathjax'
| 'katex'
| ( (
equation: string,
element: HTMLElement,
display: boolean,
) => void ),
lazyLoad: undefined | ( () => Promise<void> ),
mathViewOptions: MathViewOptions,
previewEnabled = false,
previewUid: string,
previewClassName: Array<string>,
popupClassName: Array<string>,
katexRenderOptions: KatexOptions
popupClassName: string[] = []
) {
super( locale );
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 );
this.mathView.bind( 'display' ).to( this.displayButtonView, 'isOn' );
children = [
this.mathInputView,
this.displayButtonView,
this.previewLabel,
this.mathView
];
} else {
children = [
this.mathInputView,
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.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'
]
attributes: { class: [ 'ck-math-scroll' ] },
children: [ { tag: 'div', attributes: { class: [ 'ck-math-view' ] }, children } ]
},
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<FocusableView>();
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;
}
}
private _createButton(
label: string,
icon: string,
className: string,
eventName: string | null
) {
const button = new ButtonView( this.locale );
// Sync to Raw LaTeX only if user isn't typing there
if (!this._isViewFocused(this.rawLatexInputView) && this.rawLatexInputView.value !== eq) {
this.rawLatexInputView.value = eq;
}
button.set( {
label,
icon,
tooltip: true
updatePreview(eq);
});
button.extendTemplate( {
attributes: {
class: className
// 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);
});
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;
}
}

View File

@ -0,0 +1,110 @@
import { View, type Locale } from 'ckeditor5';
interface MathFieldElement extends HTMLElement {
value: string;
readOnly: boolean;
mathVirtualKeyboardPolicy: string;
inlineShortcuts?: Record<string, string>;
}
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<void> {
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();
}
}

View File

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

View File

@ -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<TextareaView> {
/**
* 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();
}
}

View File

@ -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;
} );
} );

View File

@ -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,24 +407,32 @@ describe( 'MathUI', () => {
setModelData( editor.model, '<paragraph>f[o]o</paragraph>' );
} );
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' ] );
} );
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', () => {
mathUIFeature._showUI();
formView!.fire( 'cancel' );

View File

@ -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;
flex-direction: column;
padding: var(--ck-spacing-standard);
box-sizing: border-box;
max-width: 80vw;
max-height: 80vh;
height: 100%;
overflow-x: hidden;
}
/* Mobile responsiveness */
@media screen and (max-width: 600px) {
.ck.ck-math-form {
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%;
}
}
/* ============================================================================
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%;
}
.ck-math-tex.ck-placeholder::before {
/* 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;
}
.ck.ck-toolbar-container {
z-index: calc(var(--ck-z-panel) + 2);
/* 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);
}

32
pnpm-lock.yaml generated
View File

@ -1052,6 +1052,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
@ -2101,6 +2104,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'}
@ -6679,6 +6686,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==}
@ -9884,6 +9895,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==}
@ -15002,6 +15016,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:
@ -15367,6 +15383,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: