Compare commits

..

4 Commits

5 changed files with 145 additions and 107 deletions

View File

@ -48,8 +48,7 @@ export default class MainFormView extends View {
this.rawLatexInputView = new RawLatexInputView( locale ); this.rawLatexInputView = new RawLatexInputView( locale );
this.rawLatexInputView.label = t( 'LaTeX' ); this.rawLatexInputView.label = t( 'LaTeX' );
this.saveButtonView = this._createButton( t( 'Save' ), IconCheck, 'ck-button-save' ); this.saveButtonView = this._createButton( t( 'Save' ), IconCheck, 'ck-button-save', 'submit' );
this.saveButtonView.type = 'submit';
this.cancelButtonView = this._createButton( t( 'Cancel' ), IconCancel, 'ck-button-cancel' ); this.cancelButtonView = this._createButton( t( 'Cancel' ), IconCancel, 'ck-button-cancel' );
this.cancelButtonView.delegate( 'execute' ).to( this, 'cancel' ); this.cancelButtonView.delegate( 'execute' ).to( this, 'cancel' );
@ -150,58 +149,67 @@ export default class MainFormView extends View {
} }
/** /**
* Sets up split handlers for synchronization. * Checks if a view currently has focus.
*/ */
private _setupInputSync( previewEnabled: boolean ): void { private _isViewFocused(view: View): boolean {
// Handler 1: MathLive -> Raw LaTeX const el = view.element;
this.mathLiveInputView.on( 'change:value', () => { const active = document.activeElement;
let eq = ( this.mathLiveInputView.value ?? '' ).trim(); return !!(el && active && el.contains(active));
}
// Delimiter Normalization /**
if ( hasDelimiters( eq ) ) { * Sets up synchronization with Focus Gating.
const params = extractDelimiters( eq ); */
private _setupInputSync(previewEnabled: boolean): void {
const updatePreview = (eq: string) => {
if (previewEnabled && this.mathView && this.mathView.value !== eq) {
this.mathView.value = eq;
}
};
// Handler 1: MathLive -> Raw LaTeX + Preview
this.mathLiveInputView.on('change:value', () => {
let eq = (this.mathLiveInputView.value ?? '').trim();
// Strip delimiters if present (e.g. pasted content)
if (hasDelimiters(eq)) {
const params = extractDelimiters(eq);
eq = params.equation; eq = params.equation;
this.displayButtonView.isOn = params.display; this.displayButtonView.isOn = params.display;
// UX Fix: If we stripped delimiters, update the source // Only strip delimiters if not actively editing
// so the visual editor doesn't show them. if (!this._isViewFocused(this.mathLiveInputView) && this.mathLiveInputView.value !== eq) {
if ( this.mathLiveInputView.value !== eq ) {
this.mathLiveInputView.value = eq; this.mathLiveInputView.value = eq;
} }
} }
// Sync to Raw LaTeX // Sync to Raw LaTeX only if user isn't typing there
if ( this.rawLatexInputView.value !== eq ) { if (!this._isViewFocused(this.rawLatexInputView) && this.rawLatexInputView.value !== eq) {
this.rawLatexInputView.value = eq; this.rawLatexInputView.value = eq;
} }
// Sync to Preview updatePreview(eq);
if ( previewEnabled && this.mathView && this.mathView.value !== eq ) { });
this.mathView.value = eq;
}
} );
// Handler 2: Raw LaTeX -> MathLive // Handler 2: Raw LaTeX -> MathLive + Preview
this.rawLatexInputView.on( 'change:value', () => { this.rawLatexInputView.on('change:value', () => {
const eq = ( this.rawLatexInputView.value ?? '' ).trim(); const eq = (this.rawLatexInputView.value ?? '').trim();
const normalized = eq.length ? eq : null; const normalized = eq.length ? eq : null;
// Sync to MathLive // Sync to MathLive only if user isn't interacting with it
if ( this.mathLiveInputView.value !== normalized ) { if (!this._isViewFocused(this.mathLiveInputView) && this.mathLiveInputView.value !== normalized) {
this.mathLiveInputView.value = normalized; this.mathLiveInputView.value = normalized;
} }
// Sync to Preview updatePreview(eq);
if ( previewEnabled && this.mathView && this.mathView.value !== eq ) { });
this.mathView.value = eq;
}
} );
} }
private _createButton( label: string, icon: string, className: string ): ButtonView { private _createButton( label: string, icon: string, className: string, type?: 'submit' | 'button' ): ButtonView {
const btn = new ButtonView( this.locale ); const btn = new ButtonView( this.locale );
btn.set( { label, icon, tooltip: true } ); btn.set( { label, icon, tooltip: true } );
btn.extendTemplate( { attributes: { class: className } } ); btn.extendTemplate( { attributes: { class: className } } );
if (type) btn.type = type;
return btn; return btn;
} }

View File

@ -1,104 +1,98 @@
import { View, type Locale } from 'ckeditor5'; import { View, type Locale } from 'ckeditor5';
import 'mathlive'; // Import side-effects only (registers the <math-field> tag)
/**
* Interface describing the custom <math-field> element.
*/
interface MathFieldElement extends HTMLElement { interface MathFieldElement extends HTMLElement {
value: string; value: string;
readOnly: boolean; readOnly: boolean;
mathVirtualKeyboardPolicy: string; mathVirtualKeyboardPolicy: string;
// Interface includes the shortcuts property inlineShortcuts?: Record<string, string>;
inlineShortcuts: Record<string, string>;
} }
/**
* A wrapper for the MathLive <math-field> component.
*/
export default class MathLiveInputView extends View { export default class MathLiveInputView extends View {
/**
* The current LaTeX value.
* @observable
*/
public declare value: string | null; public declare value: string | null;
/**
* Read-only state.
* @observable
*/
public declare isReadOnly: boolean; public declare isReadOnly: boolean;
/**
* Reference to the DOM element.
* Typed as MathFieldElement | null for proper TS support.
*/
public mathfield: MathFieldElement | null = null; public mathfield: MathFieldElement | null = null;
constructor( locale: Locale ) { constructor(locale: Locale) {
super( locale ); super(locale);
this.set('value', null);
this.set('isReadOnly', false);
this.set( 'value', null ); this.setTemplate({
this.set( 'isReadOnly', false );
this.setTemplate( {
tag: 'div', tag: 'div',
attributes: { attributes: {
class: [ 'ck', 'ck-mathlive-input' ] class: ['ck', 'ck-mathlive-input']
} }
} ); });
} }
public override render(): void { public override render(): void {
super.render(); super.render();
this._loadMathLive();
}
// 1. Create element with the specific type private async _loadMathLive(): Promise<void> {
const mathfield = document.createElement( 'math-field' ) as MathFieldElement; try {
await import('mathlive');
await customElements.whenDefined('math-field');
// 2. Configure Options // 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'; mathfield.mathVirtualKeyboardPolicy = 'manual';
//Disable differential D // Configure shortcuts after mount
mathfield.addEventListener( 'mount', () => { mathfield.addEventListener('mount', () => {
mathfield.inlineShortcuts = { mathfield.inlineShortcuts = {
...mathfield.inlineShortcuts, // Safe to read now ...mathfield.inlineShortcuts,
dx: 'dx', dx: 'dx',
dy: 'dy', dy: 'dy',
dt: 'dt' dt: 'dt'
}; };
} ); }, { once: true });
// Initial state
// 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.value = this.value ?? '';
mathfield.readOnly = this.isReadOnly; mathfield.readOnly = this.isReadOnly;
// 4. Bind Events (DOM -> Observable) // DOM -> Observable
mathfield.addEventListener( 'input', () => { mathfield.addEventListener('input', () => {
const val = mathfield.value; const val = mathfield.value;
this.value = val.length ? val : null; this.value = val.length ? val : null;
} ); });
// 5. Bind Events (Observable -> DOM) // Observable -> DOM
this.on( 'change:value', ( _evt, _name, nextValue ) => { this.on('change:value', (_evt, _name, nextValue) => {
if ( mathfield.value !== nextValue ) { if (mathfield.value !== nextValue) {
mathfield.value = nextValue ?? ''; mathfield.value = nextValue ?? '';
} }
} ); });
this.on( 'change:isReadOnly', ( _evt, _name, nextValue ) => { this.on('change:isReadOnly', (_evt, _name, nextValue) => {
mathfield.readOnly = nextValue; mathfield.readOnly = nextValue;
} ); });
// 6. Mount to the wrapper view this.element.appendChild(mathfield);
this.element?.appendChild( mathfield );
this.mathfield = mathfield; this.mathfield = mathfield;
} }
@ -107,7 +101,7 @@ export default class MathLiveInputView extends View {
} }
public override destroy(): void { public override destroy(): void {
if ( this.mathfield ) { if (this.mathfield) {
this.mathfield.remove(); this.mathfield.remove();
this.mathfield = null; this.mathfield = null;
} }

View File

@ -55,23 +55,39 @@ export default class MathView extends View {
} }
public updateMath(): void { public updateMath(): void {
if ( this.element ) { if (!this.element) {
return;
// This prevents the new render from appending to the old one.
this.element.textContent = '';
void renderEquation(
this.value,
this.element,
this.options.engine,
this.options.lazyLoad,
this.display,
true, // isPreview
this.options.previewUid,
this.options.previewClassName,
this.options.katexRenderOptions
);
} }
// 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 { public override render(): void {

View File

@ -37,6 +37,7 @@ describe( 'Lazy load', () => {
await buildEditor( { await buildEditor( {
math: { math: {
engine: 'katex', engine: 'katex',
enablePreview: true,
lazyLoad: async () => { lazyLoad: async () => {
lazyLoadInvoked = true; lazyLoadInvoked = true;
} }
@ -44,6 +45,15 @@ describe( 'Lazy load', () => {
} ); } );
mathUIFeature._showUI(); 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; expect( lazyLoadInvoked ).to.be.true;
} ); } );
} ); } );

View File

@ -212,3 +212,13 @@
outline: none !important; outline: none !important;
box-shadow: 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);
}