mirror of
https://github.com/zadam/trilium.git
synced 2026-01-15 02:54:24 +01:00
270 lines
7.7 KiB
TypeScript
270 lines
7.7 KiB
TypeScript
// Math input widget: wraps a MathLive <math-field> and a LaTeX textarea
|
|
// and keeps them in sync for the CKEditor 5 math dialog.
|
|
import { View, type Locale, type FocusableView } from 'ckeditor5';
|
|
import 'mathlive/fonts.css'; // Auto-bundles offline fonts
|
|
import 'mathlive/static.css'; // Static styles for mathlive
|
|
|
|
declare global {
|
|
interface Window {
|
|
mathVirtualKeyboard?: {
|
|
visible: boolean;
|
|
show: () => void;
|
|
hide: () => void;
|
|
addEventListener: ( event: string, cb: () => void ) => void;
|
|
removeEventListener: ( event: string, cb: () => void ) => void;
|
|
};
|
|
}
|
|
}
|
|
|
|
interface MathFieldElement extends HTMLElement {
|
|
value: string;
|
|
readOnly: boolean;
|
|
mathVirtualKeyboardPolicy: string;
|
|
inlineShortcuts?: Record<string, string>;
|
|
setValue?: ( value: string, options?: { silenceNotifications?: boolean } ) => void;
|
|
}
|
|
|
|
// Wrapper for the MathLive element to make it focusable in CKEditor's UI system
|
|
export class MathFieldFocusableView extends View implements FocusableView {
|
|
public declare element: HTMLElement | null;
|
|
private _view: MathInputView;
|
|
constructor( locale: Locale, view: MathInputView ) {
|
|
super( locale );
|
|
this._view = view;
|
|
}
|
|
public focus(): void {
|
|
this._view.mathfield?.focus();
|
|
}
|
|
public setElement( el: HTMLElement ): void {
|
|
this.element = el;
|
|
}
|
|
}
|
|
|
|
// Wrapper for the LaTeX textarea to make it focusable in CKEditor's UI system
|
|
export class LatexTextAreaView extends View implements FocusableView {
|
|
declare public element: HTMLTextAreaElement;
|
|
constructor( locale: Locale ) {
|
|
super( locale );
|
|
this.setTemplate( { tag: 'textarea', attributes: {
|
|
class: [ 'ck', 'ck-textarea', 'ck-latex-textarea' ], spellcheck: 'false', tabindex: 0
|
|
} } );
|
|
}
|
|
public focus(): void {
|
|
this.element?.focus();
|
|
}
|
|
}
|
|
|
|
// Main view class for the math input
|
|
export default class MathInputView extends View {
|
|
public declare value: string | null;
|
|
public declare isReadOnly: boolean;
|
|
public mathfield: MathFieldElement | null = null;
|
|
public readonly latexTextAreaView: LatexTextAreaView;
|
|
public readonly mathFieldFocusableView: MathFieldFocusableView;
|
|
private _destroyed = false;
|
|
private _vkGeometryHandler?: () => void;
|
|
private _updating = false;
|
|
private static _configured = false;
|
|
|
|
constructor( locale: Locale ) {
|
|
super( locale );
|
|
this.latexTextAreaView = new LatexTextAreaView( locale );
|
|
this.mathFieldFocusableView = new MathFieldFocusableView( locale, this );
|
|
this.set( 'value', null );
|
|
this.set( 'isReadOnly', false );
|
|
this.setTemplate( {
|
|
tag: 'div', attributes: { class: [ 'ck', 'ck-math-input' ] },
|
|
children: [
|
|
{ tag: 'div', attributes: { class: [ 'ck-mathlive-container' ] } },
|
|
{ tag: 'label', attributes: { class: [ 'ck-latex-label' ] }, children: [ locale.t( 'LaTeX' ) ] },
|
|
{ tag: 'div', attributes: { class: [ 'ck-latex-wrapper' ] }, children: [ this.latexTextAreaView ] }
|
|
]
|
|
} );
|
|
}
|
|
|
|
public override render(): void {
|
|
super.render();
|
|
const textarea = this.latexTextAreaView.element;
|
|
|
|
// Sync changes from the LaTeX textarea to the mathfield and model
|
|
this.listenTo( textarea, 'input', () => {
|
|
if ( this._updating ) {
|
|
return;
|
|
}
|
|
this._updating = true;
|
|
const val = textarea.value;
|
|
this.value = val || null;
|
|
if ( this.mathfield ) {
|
|
if ( val === '' ) {
|
|
this.mathfield.remove();
|
|
this.mathfield = null;
|
|
this._initMathField( false );
|
|
} else if ( this.mathfield.value.trim() !== val.trim() ) {
|
|
this._setMathfieldValue( val );
|
|
}
|
|
}
|
|
this._updating = false;
|
|
} );
|
|
|
|
// Sync changes from the model (this.value) to the UI elements
|
|
this.on( 'change:value', ( _e, _n, val ) => {
|
|
if ( this._updating ) {
|
|
return;
|
|
}
|
|
this._updating = true;
|
|
const newVal = val ?? '';
|
|
if ( textarea.value !== newVal ) {
|
|
textarea.value = newVal;
|
|
}
|
|
if ( this.mathfield ) {
|
|
if ( this.mathfield.value.trim() !== newVal.trim() ) {
|
|
this._setMathfieldValue( newVal );
|
|
}
|
|
} else if ( newVal !== '' ) {
|
|
this._initMathField( false );
|
|
}
|
|
this._updating = false;
|
|
} );
|
|
|
|
// Handle read-only state changes
|
|
this.on( 'change:isReadOnly', ( _e, _n, val ) => {
|
|
textarea.readOnly = val;
|
|
if ( this.mathfield ) {
|
|
this.mathfield.readOnly = val;
|
|
}
|
|
} );
|
|
|
|
// Handle virtual keyboard geometry changes
|
|
const vk = window.mathVirtualKeyboard;
|
|
if ( vk && !this._vkGeometryHandler ) {
|
|
this._vkGeometryHandler = () => {
|
|
if ( vk.visible && this.mathfield ) {
|
|
this.mathfield.focus();
|
|
}
|
|
};
|
|
vk.addEventListener( 'geometrychange', this._vkGeometryHandler );
|
|
}
|
|
|
|
const initial = this.value ?? '';
|
|
if ( textarea.value !== initial ) {
|
|
textarea.value = initial;
|
|
}
|
|
this._loadMathLive();
|
|
}
|
|
|
|
// Loads the MathLive library dynamically
|
|
private async _loadMathLive(): Promise<void> {
|
|
try {
|
|
await import( 'mathlive' );
|
|
await customElements.whenDefined( 'math-field' );
|
|
if ( this._destroyed ) {
|
|
return;
|
|
}
|
|
if ( !MathInputView._configured ) {
|
|
const MathfieldClass = customElements.get( 'math-field' ) as any;
|
|
if ( MathfieldClass ) {
|
|
MathfieldClass.soundsDirectory = null;
|
|
MathfieldClass.plonkSound = null;
|
|
MathInputView._configured = true;
|
|
}
|
|
}
|
|
if ( this.element && !this._destroyed ) {
|
|
this._initMathField( true );
|
|
}
|
|
} catch {
|
|
const c = this.element?.querySelector( '.ck-mathlive-container' );
|
|
if ( c ) {
|
|
c.textContent = 'Math editor unavailable';
|
|
}
|
|
}
|
|
}
|
|
|
|
// Initializes the <math-field> element
|
|
private _initMathField( shouldFocus: boolean ): void {
|
|
const container = this.element?.querySelector( '.ck-mathlive-container' );
|
|
if ( !container ) {
|
|
return;
|
|
}
|
|
if ( this.mathfield ) {
|
|
this._setMathfieldValue( this.value ?? '' );
|
|
return;
|
|
}
|
|
const mf = document.createElement( 'math-field' ) as MathFieldElement;
|
|
mf.mathVirtualKeyboardPolicy = 'auto';
|
|
mf.setAttribute( 'tabindex', '0' );
|
|
mf.value = this.value ?? '';
|
|
mf.readOnly = this.isReadOnly;
|
|
container.appendChild( mf );
|
|
// Set shortcuts after mounting (accessing inlineShortcuts requires mounted element)
|
|
try {
|
|
if ( mf.inlineShortcuts ) {
|
|
mf.inlineShortcuts = { ...mf.inlineShortcuts, dx: 'dx', dy: 'dy', dt: 'dt' };
|
|
}
|
|
} catch {
|
|
// Inline shortcut configuration is optional; ignore failures to avoid breaking the math field.
|
|
}
|
|
mf.addEventListener( 'keydown', ev => {
|
|
if ( ev.key === 'Tab' ) {
|
|
if ( ev.shiftKey ) {
|
|
ev.preventDefault();
|
|
} else {
|
|
ev.preventDefault();
|
|
ev.stopImmediatePropagation();
|
|
this.latexTextAreaView.focus();
|
|
}
|
|
}
|
|
}, { capture: true } );
|
|
mf.addEventListener( 'input', () => {
|
|
if ( this._updating ) {
|
|
return;
|
|
}
|
|
this._updating = true;
|
|
const textarea = this.latexTextAreaView.element;
|
|
if ( textarea.value.trim() !== mf.value.trim() ) {
|
|
textarea.value = mf.value;
|
|
}
|
|
this.value = mf.value || null;
|
|
this._updating = false;
|
|
} );
|
|
this.mathfield = mf;
|
|
this.mathFieldFocusableView.setElement( mf );
|
|
this.fire( 'mathfieldReady' );
|
|
if ( shouldFocus ) {
|
|
requestAnimationFrame( () => mf.focus() );
|
|
}
|
|
}
|
|
|
|
// Updates the mathfield value without triggering loops
|
|
private _setMathfieldValue( value: string ): void {
|
|
if ( !this.mathfield ) {
|
|
return;
|
|
}
|
|
if ( this.mathfield.setValue ) {
|
|
this.mathfield.setValue( value, { silenceNotifications: true } );
|
|
} else {
|
|
this.mathfield.value = value;
|
|
}
|
|
}
|
|
|
|
public hideKeyboard(): void {
|
|
window.mathVirtualKeyboard?.hide();
|
|
}
|
|
|
|
public focus(): void {
|
|
this.mathfield?.focus();
|
|
}
|
|
|
|
public override destroy(): void {
|
|
this._destroyed = true;
|
|
const vk = window.mathVirtualKeyboard;
|
|
if ( vk && this._vkGeometryHandler ) {
|
|
vk.removeEventListener( 'geometrychange', this._vkGeometryHandler );
|
|
this._vkGeometryHandler = undefined;
|
|
}
|
|
this.hideKeyboard();
|
|
this.mathfield?.remove();
|
|
this.mathfield = null;
|
|
super.destroy();
|
|
}
|
|
}
|