122 lines
4.4 KiB
TypeScript

import { type Editor, ModelText, ModelTextProxy, type ModelElement, type ModelRange, type Autoformat, inlineAutoformatEditing } from 'ckeditor5';
import { COMMANDS, ELEMENTS } from '../constants.js';
import { modelQueryElement, modelQueryElementsAll } from '../utils.js';
/**
* CKEditor's autoformatting feature (basically find and replace) has two opinionated default modes:
* block autoformatting, which replaces the entire line, and inline autoformatting,
* which expects a section to be formatted (but, importantly, not removed) surrounded by
* a pair of delimters which get removed.
*
* Neither of those are ideal for this case. We want to replace the matched text with a new element,
* without deleting the entire line.
*
* However, inlineAutoformatEditing allows for passing in a custom callback to handle
* regex matching, which also allows us to specify which sections to remove and
* which sections pass on to the formatting callback. This method removes the entire
* matched text, while passing the range of the numeric text on to the formatting callback.
*
* If 0 or more than 1 match is found, it returns empty ranges for both format and remove, which is a no-op.
*/
const regexMatchCallback = (
editor: Editor,
text: string
): {
remove: Array<[number, number]>;
format: Array<[number, number]>;
} => {
const selectionStart = editor.model.document.selection.anchor;
// get the text node containing the cursor's position, or the one ending at `the cursor's position
const surroundingText = selectionStart && ( selectionStart.textNode || selectionStart.getShiftedBy( -1 ).textNode );
if ( !selectionStart || !surroundingText ) {
return {
remove: [],
format: []
};
}
const results = text.matchAll( /\[\^([0-9]+)\]/g );
for ( const result of results || [] ) {
const removeStartIndex = text.indexOf( result[ 0 ] );
const removeEndIndex = removeStartIndex + result[ 0 ].length;
const textNodeOffset = selectionStart.parent.getChildStartOffset( surroundingText );
// if the cursor isn't at the end of the range to be replaced, do nothing
if ( textNodeOffset === null || selectionStart.offset !== textNodeOffset + removeEndIndex ) {
continue;
}
const formatStartIndex = removeStartIndex + 2;
const formatEndIndex = formatStartIndex + result[ 1 ].length;
return {
remove: [ [ removeStartIndex, removeEndIndex ] ],
format: [ [ formatStartIndex, formatEndIndex ] ]
};
}
return {
remove: [],
format: []
};
};
/**
* This callback takes in a range of text passed on by regexMatchCallback,
* and attempts to insert a corresponding footnote reference at the current location.
*
* Footnotes only get inserted if the matching range is an integer between 1
* and the number of existing footnotes + 1.
*/
const formatCallback = ( ranges: Array<ModelRange>, editor: Editor, rootElement: ModelElement ): boolean | undefined => {
const command = editor.commands.get( COMMANDS.insertFootnote );
if ( !command || !command.isEnabled ) {
return;
}
const text = [ ...ranges[ 0 ].getItems() ][ 0 ];
if ( !( text instanceof ModelTextProxy || text instanceof ModelText ) ) {
return false;
}
const match = text.data.match( /[0-9]+/ );
if ( !match ) {
return false;
}
const footnoteIndex = parseInt( match[ 0 ] );
const footnoteSection = modelQueryElement( editor, rootElement, element =>
element.is( 'element', ELEMENTS.footnoteSection )
);
if ( !footnoteSection ) {
if ( footnoteIndex !== 1 ) {
return false;
}
editor.execute( COMMANDS.insertFootnote );
return;
}
const footnoteCount = modelQueryElementsAll( editor, footnoteSection, element =>
element.is( 'element', ELEMENTS.footnoteItem )
).length;
if ( footnoteIndex === footnoteCount + 1 ) {
editor.execute( COMMANDS.insertFootnote );
return;
} else if ( footnoteIndex >= 1 && footnoteIndex <= footnoteCount ) {
editor.execute( COMMANDS.insertFootnote, { footnoteIndex } );
return;
}
return false;
};
/**
* Adds functionality to support creating footnotes using markdown syntax, e.g. `[^1]`.
*/
export const addFootnoteAutoformatting = ( editor: Editor, rootElement: ModelElement ): void => {
if ( editor.plugins.has( 'Autoformat' ) ) {
const autoformatPluginInstance = editor.plugins.get( 'Autoformat' ) as Autoformat;
inlineAutoformatEditing(
editor,
autoformatPluginInstance,
text => regexMatchCallback( editor, text ),
( _, ranges: Array<ModelRange> ) => formatCallback( ranges, editor, rootElement )
);
}
};