chore(ckeditor5-footnotes): integrate source code changes

This commit is contained in:
Elian Doran
2025-05-04 20:31:25 +03:00
parent 9643bf310e
commit d977866c44
51 changed files with 14 additions and 1323 deletions

View File

@@ -0,0 +1,35 @@
export const TOOLBAR_COMPONENT_NAME = 'footnote';
export const DATA_FOOTNOTE_ID = 'data-footnote-id';
export const ELEMENTS = {
footnoteItem: 'footnoteItem',
footnoteReference: 'footnoteReference',
footnoteSection: 'footnoteSection',
footnoteContent: 'footnoteContent',
footnoteBackLink: 'footnoteBackLink'
};
export const CLASSES = {
footnoteContent: 'footnote-content',
footnoteItem: 'footnote-item',
footnoteReference: 'footnote-reference',
footnoteSection: 'footnote-section',
footnoteBackLink: 'footnote-back-link',
footnotes: 'footnotes', // a class already used on our sites for the footnote section
hidden: 'hidden'
};
export const COMMANDS = {
insertFootnote: 'InsertFootnote'
};
export const ATTRIBUTES = {
footnoteContent: 'data-footnote-content',
footnoteId: 'data-footnote-id',
footnoteIndex: 'data-footnote-index',
footnoteItem: 'data-footnote-item',
footnoteReference: 'data-footnote-reference',
footnoteSection: 'data-footnote-section',
footnoteBackLink: 'data-footnote-back-link',
footnoteBackLinkHref: 'data-footnote-back-link-href'
};

View File

@@ -0,0 +1,121 @@
import { type Editor, Text, TextProxy, type Element, type Range, 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<Range>, editor: Editor, rootElement: Element ): boolean | undefined => {
const command = editor.commands.get( COMMANDS.insertFootnote );
if ( !command || !command.isEnabled ) {
return;
}
const text = [ ...ranges[ 0 ].getItems() ][ 0 ];
if ( !( text instanceof TextProxy || text instanceof Text ) ) {
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: Element ): void => {
if ( editor.plugins.has( 'Autoformat' ) ) {
const autoformatPluginInstance = editor.plugins.get( 'Autoformat' ) as Autoformat;
inlineAutoformatEditing(
editor,
autoformatPluginInstance,
text => regexMatchCallback( editor, text ),
( _, ranges: Array<Range> ) => formatCallback( ranges, editor, rootElement )
);
}
};

View File

@@ -0,0 +1,370 @@
import { type Editor, type DowncastConversionApi, type ViewContainerElement, Element, toWidget, toWidgetEditable } from 'ckeditor5';
import { ATTRIBUTES, CLASSES, ELEMENTS } from '../constants.js';
import { viewQueryElement } from '../utils.js';
/**
* Defines methods for converting between model, data view, and editing view representations of each element type.
*/
export const defineConverters = ( editor: Editor ): void => {
const conversion = editor.conversion;
/** *********************************Attribute Conversion************************************/
conversion.for( 'downcast' ).attributeToAttribute( {
model: ATTRIBUTES.footnoteId,
view: ATTRIBUTES.footnoteId
} );
conversion.for( 'downcast' ).attributeToAttribute( {
model: ATTRIBUTES.footnoteIndex,
view: ATTRIBUTES.footnoteIndex
} );
/** *********************************Footnote Section Conversion************************************/
// ((data) view → model)
conversion.for( 'upcast' ).elementToElement( {
view: {
attributes: {
[ ATTRIBUTES.footnoteSection ]: true
}
},
model: ELEMENTS.footnoteSection,
converterPriority: 'high'
} );
// (model → data view)
conversion.for( 'dataDowncast' ).elementToElement( {
model: ELEMENTS.footnoteSection,
view: {
name: 'ol',
attributes: {
[ ATTRIBUTES.footnoteSection ]: '',
role: 'doc-endnotes'
},
classes: [ CLASSES.footnoteSection, CLASSES.footnotes ]
}
} );
// (model → editing view)
conversion.for( 'editingDowncast' ).elementToElement( {
model: ELEMENTS.footnoteSection,
view: ( _, conversionApi ) => {
const viewWriter = conversionApi.writer;
// eslint-disable-next-line max-len
/** The below is a div rather than an ol because using an ol here caused weird behavior, including randomly duplicating the footnotes section.
* This is techincally invalid HTML, but it's valid in the data view (that is, the version shown in the post). I've added role='list'
* as a next-best option, in accordance with ARIA recommendations.
*/
const section = viewWriter.createContainerElement( 'div', {
[ ATTRIBUTES.footnoteSection ]: '',
role: 'doc-endnotes list',
class: CLASSES.footnoteSection
} );
return toWidget( section, viewWriter, { label: 'footnote widget' } );
}
} );
/** *********************************Footnote Content Conversion************************************/
conversion.for( 'upcast' ).elementToElement( {
view: {
attributes: {
[ ATTRIBUTES.footnoteContent ]: true
}
},
model: ( viewElement, conversionApi ) => {
const modelWriter = conversionApi.writer;
return modelWriter.createElement( ELEMENTS.footnoteContent );
}
} );
conversion.for( 'dataDowncast' ).elementToElement( {
model: ELEMENTS.footnoteContent,
view: {
name: 'div',
attributes: { [ ATTRIBUTES.footnoteContent ]: '' },
classes: [ CLASSES.footnoteContent ]
}
} );
conversion.for( 'editingDowncast' ).elementToElement( {
model: ELEMENTS.footnoteContent,
view: ( _, conversionApi ) => {
const viewWriter = conversionApi.writer;
// Note: You use a more specialized createEditableElement() method here.
const section = viewWriter.createEditableElement( 'div', {
[ ATTRIBUTES.footnoteContent ]: '',
class: CLASSES.footnoteContent
} );
return toWidgetEditable( section, viewWriter );
}
} );
/** *********************************Footnote Item Conversion************************************/
conversion.for( 'upcast' ).elementToElement( {
view: {
attributes: {
[ ATTRIBUTES.footnoteItem ]: true
}
},
model: ( viewElement, conversionApi ) => {
const modelWriter = conversionApi.writer;
const id = viewElement.getAttribute( ATTRIBUTES.footnoteId );
const index = viewElement.getAttribute( ATTRIBUTES.footnoteIndex );
if ( id === undefined || index === undefined ) {
return null;
}
return modelWriter.createElement( ELEMENTS.footnoteItem, {
[ ATTRIBUTES.footnoteIndex ]: index,
[ ATTRIBUTES.footnoteId ]: id
} );
},
/** converterPriority is needed to supersede the builtin upcastListItemStyle
* which for unknown reasons causes a null reference error.
*/
converterPriority: 'high'
} );
conversion.for( 'dataDowncast' ).elementToElement( {
model: ELEMENTS.footnoteItem,
view: createFootnoteItemViewElement
} );
conversion.for( 'editingDowncast' ).elementToElement( {
model: ELEMENTS.footnoteItem,
view: createFootnoteItemViewElement
} );
/** *********************************Footnote Reference Conversion************************************/
conversion.for( 'upcast' ).elementToElement( {
view: {
attributes: {
[ ATTRIBUTES.footnoteReference ]: true
}
},
model: ( viewElement, conversionApi ) => {
const modelWriter = conversionApi.writer;
const index = viewElement.getAttribute( ATTRIBUTES.footnoteIndex );
const id = viewElement.getAttribute( ATTRIBUTES.footnoteId );
if ( index === undefined || id === undefined ) {
return null;
}
return modelWriter.createElement( ELEMENTS.footnoteReference, {
[ ATTRIBUTES.footnoteIndex ]: index,
[ ATTRIBUTES.footnoteId ]: id
} );
}
} );
conversion.for( 'editingDowncast' ).elementToElement( {
model: ELEMENTS.footnoteReference,
view: ( modelElement, conversionApi ) => {
const viewWriter = conversionApi.writer;
const footnoteReferenceViewElement = createFootnoteReferenceViewElement( modelElement, conversionApi );
return toWidget( footnoteReferenceViewElement, viewWriter );
}
} );
conversion.for( 'dataDowncast' ).elementToElement( {
model: ELEMENTS.footnoteReference,
view: createFootnoteReferenceViewElement
} );
/** This is an event listener for changes to the `data-footnote-index` attribute on `footnoteReference` elements.
* When that event fires, the callback function below updates the displayed view of the footnote reference in the
* editor to match the new index.
*/
conversion.for( 'editingDowncast' ).add( dispatcher => {
dispatcher.on(
`attribute:${ ATTRIBUTES.footnoteIndex }:${ ELEMENTS.footnoteReference }`,
( _, data, conversionApi ) => updateFootnoteReferenceView( data, conversionApi, editor ),
{ priority: 'high' }
);
} );
/** *********************************Footnote Back Link Conversion************************************/
conversion.for( 'upcast' ).elementToElement( {
view: {
attributes: {
[ ATTRIBUTES.footnoteBackLink ]: true
}
},
model: ( viewElement, conversionApi ) => {
const modelWriter = conversionApi.writer;
const id = viewElement.getAttribute( ATTRIBUTES.footnoteId );
if ( id === undefined ) {
return null;
}
return modelWriter.createElement( ELEMENTS.footnoteBackLink, {
[ ATTRIBUTES.footnoteId ]: id
} );
}
} );
conversion.for( 'dataDowncast' ).elementToElement( {
model: ELEMENTS.footnoteBackLink,
view: createFootnoteBackLinkViewElement
} );
conversion.for( 'editingDowncast' ).elementToElement( {
model: ELEMENTS.footnoteBackLink,
view: createFootnoteBackLinkViewElement
} );
};
/**
* Creates and returns a view element for a footnote backlink,
* which navigates back to the inline reference in the text. Used
* for both data and editing downcasts.
*/
function createFootnoteBackLinkViewElement(
modelElement: Element,
conversionApi: DowncastConversionApi
): ViewContainerElement {
const viewWriter = conversionApi.writer;
const id = `${ modelElement.getAttribute( ATTRIBUTES.footnoteId ) }`;
if ( id === undefined ) {
throw new Error( 'Footnote return link has no provided Id.' );
}
const footnoteBackLinkView = viewWriter.createContainerElement( 'span', {
class: CLASSES.footnoteBackLink,
[ ATTRIBUTES.footnoteBackLink ]: '',
[ ATTRIBUTES.footnoteId ]: id
} );
const sup = viewWriter.createContainerElement( 'sup' );
const strong = viewWriter.createContainerElement( 'strong' );
const anchor = viewWriter.createContainerElement( 'a', { href: `#fnref${ id }` } );
const innerText = viewWriter.createText( '^' );
viewWriter.insert( viewWriter.createPositionAt( anchor, 0 ), innerText );
viewWriter.insert( viewWriter.createPositionAt( strong, 0 ), anchor );
viewWriter.insert( viewWriter.createPositionAt( sup, 0 ), strong );
viewWriter.insert( viewWriter.createPositionAt( footnoteBackLinkView, 0 ), sup );
return footnoteBackLinkView;
}
/**
* Creates and returns a view element for an inline footnote reference. Used for both
* data downcast and editing downcast conversions.
*/
function createFootnoteReferenceViewElement(
modelElement: Element,
conversionApi: DowncastConversionApi
): ViewContainerElement {
const viewWriter = conversionApi.writer;
const index = `${ modelElement.getAttribute( ATTRIBUTES.footnoteIndex ) }`;
const id = `${ modelElement.getAttribute( ATTRIBUTES.footnoteId ) }`;
if ( index === 'undefined' ) {
throw new Error( 'Footnote reference has no provided index.' );
}
if ( id === 'undefined' ) {
throw new Error( 'Footnote reference has no provided id.' );
}
const footnoteReferenceView = viewWriter.createContainerElement( 'span', {
class: CLASSES.footnoteReference,
[ ATTRIBUTES.footnoteReference ]: '',
[ ATTRIBUTES.footnoteIndex ]: index,
[ ATTRIBUTES.footnoteId ]: id,
role: 'doc-noteref',
id: `fnref${ id }`
} );
const innerText = viewWriter.createText( `[${ index }]` );
const link = viewWriter.createContainerElement( 'a', { href: `#fn${ id }` } );
const superscript = viewWriter.createContainerElement( 'sup' );
viewWriter.insert( viewWriter.createPositionAt( link, 0 ), innerText );
viewWriter.insert( viewWriter.createPositionAt( superscript, 0 ), link );
viewWriter.insert( viewWriter.createPositionAt( footnoteReferenceView, 0 ), superscript );
return footnoteReferenceView;
}
/**
* Creates and returns a view element for an inline footnote reference. Used for both
* data downcast and editing downcast conversions.
*/
function createFootnoteItemViewElement(
modelElement: Element,
conversionApi: DowncastConversionApi
): ViewContainerElement {
const viewWriter = conversionApi.writer;
const index = modelElement.getAttribute( ATTRIBUTES.footnoteIndex );
const id = modelElement.getAttribute( ATTRIBUTES.footnoteId );
if ( !index ) {
throw new Error( 'Footnote item has no provided index.' );
}
if ( !id ) {
throw new Error( 'Footnote item has no provided id.' );
}
return viewWriter.createContainerElement( 'li', {
class: CLASSES.footnoteItem,
[ ATTRIBUTES.footnoteItem ]: '',
[ ATTRIBUTES.footnoteIndex ]: `${ index }`,
[ ATTRIBUTES.footnoteId ]: `${ id }`,
role: 'doc-endnote',
id: `fn${ id }`
} );
}
/**
* Triggers when the index attribute of a footnote changes, and
* updates the editor display of footnote references accordingly.
*/
function updateFootnoteReferenceView(
data: {
item: Element;
attributeOldValue: string;
attributeNewValue: string;
},
conversionApi: DowncastConversionApi,
editor: Editor
) {
const { item, attributeNewValue: newIndex } = data;
if (
!( item instanceof Element ) ||
!conversionApi.consumable.consume( item, `attribute:${ ATTRIBUTES.footnoteIndex }:${ ELEMENTS.footnoteReference }` )
) {
return;
}
const footnoteReferenceView = conversionApi.mapper.toViewElement( item );
if ( !footnoteReferenceView ) {
return;
}
const viewWriter = conversionApi.writer;
const anchor = viewQueryElement( editor, footnoteReferenceView, element => element.name === 'a' );
const textNode = anchor?.getChild( 0 );
if ( !textNode || !anchor ) {
viewWriter.remove( footnoteReferenceView );
return;
}
viewWriter.remove( textNode );
const innerText = viewWriter.createText( `[${ newIndex }]` );
viewWriter.insert( viewWriter.createPositionAt( anchor, 0 ), innerText );
viewWriter.setAttribute( 'href', `#fn${ item.getAttribute( ATTRIBUTES.footnoteId ) }`, anchor );
viewWriter.setAttribute( ATTRIBUTES.footnoteIndex, newIndex, footnoteReferenceView );
}

View File

@@ -0,0 +1,308 @@
/**
* CKEditor dataview nodes can be converted to a output view or an editor view via downcasting
* * Upcasting is converting to the platonic ckeditor version.
* * Downcasting is converting to the output version.
*/
import { addFootnoteAutoformatting } from './auto-formatting.js';
import { defineConverters } from './converters.js';
import { defineSchema } from './schema.js';
import { ATTRIBUTES, COMMANDS, ELEMENTS } from '../constants.js';
import InsertFootnoteCommand from '../insert-footnote-command.js';
import { modelQueryElement, modelQueryElementsAll } from '../utils.js';
import { Autoformat, Batch, Element, Plugin, RootElement, viewToModelPositionOutsideModelElement, Widget, Writer } from 'ckeditor5';
export default class FootnoteEditing extends Plugin {
public static get requires() {
return [ Widget, Autoformat ] as const;
}
/**
* The root element of the document.
*/
public get rootElement(): RootElement {
const rootElement = this.editor.model.document.getRoot();
if ( !rootElement ) {
throw new Error( 'Document has no rootElement element.' );
}
return rootElement;
}
public init(): void {
defineSchema( this.editor.model.schema );
defineConverters( this.editor );
this.editor.commands.add( COMMANDS.insertFootnote, new InsertFootnoteCommand( this.editor ) );
addFootnoteAutoformatting( this.editor, this.rootElement );
this.editor.model.document.on(
'change:data',
( eventInfo, batch ) => {
const eventSource: any = eventInfo.source;
const diffItems = [ ...eventSource.differ.getChanges() ];
// If a footnote reference is inserted, ensure that footnote references remain ordered.
if ( diffItems.some( diffItem => diffItem.type === 'insert' && diffItem.name === ELEMENTS.footnoteReference ) ) {
this._orderFootnotes( batch );
}
// for each change to a footnote item's index attribute, update the corresponding references accordingly
diffItems.forEach( diffItem => {
if ( diffItem.type === 'attribute' && diffItem.attributeKey === ATTRIBUTES.footnoteIndex ) {
const { attributeNewValue: newFootnoteIndex } = diffItem;
const footnote = [ ...diffItem.range.getItems() ].find( item => item.is( 'element', ELEMENTS.footnoteItem ) );
const footnoteId = footnote instanceof Element && footnote.getAttribute( ATTRIBUTES.footnoteId );
if ( !footnoteId ) {
return;
}
this._updateReferenceIndices( batch, `${ footnoteId }`, newFootnoteIndex );
}
} );
},
{ priority: 'high' }
);
this._handleDelete();
// The following callbacks are needed to map nonempty view elements
// to empty model elements.
// See https://ckeditor.com/docs/ckeditor5/latest/api/module_widget_utils.html#function-viewToModelPositionOutsideModelElement
this.editor.editing.mapper.on(
'viewToModelPosition',
viewToModelPositionOutsideModelElement( this.editor.model, viewElement =>
viewElement.hasAttribute( ATTRIBUTES.footnoteReference )
)
);
}
/**
* This method broadly deals with deletion of text and elements, and updating the model
* accordingly. In particular, the following cases are handled:
* 1. If the footnote section gets deleted, all footnote references are removed.
* 2. If a delete operation happens in an empty footnote, the footnote is deleted.
*/
private _handleDelete() {
const viewDocument = this.editor.editing.view.document;
const editor = this.editor;
this.listenTo(
viewDocument,
'delete',
( evt, data ) => {
const doc = editor.model.document;
const deletedElement = doc.selection.getSelectedElement();
const selectionEndPos = doc.selection.getLastPosition();
const selectionStartPos = doc.selection.getFirstPosition();
if ( !selectionEndPos || !selectionStartPos ) {
throw new Error( 'Selection must have at least one range to perform delete operation.' );
}
this.editor.model.change( modelWriter => {
// delete all footnote references if footnote section gets deleted
if ( deletedElement && deletedElement.is( 'element', ELEMENTS.footnoteSection ) ) {
this._removeReferences( modelWriter );
}
const deletingFootnote = deletedElement && deletedElement.is( 'element', ELEMENTS.footnoteItem );
const currentFootnote = deletingFootnote ?
deletedElement :
selectionEndPos.findAncestor( ELEMENTS.footnoteItem );
if ( !currentFootnote ) {
return;
}
const endParagraph = selectionEndPos.findAncestor( 'paragraph' );
const startParagraph = selectionStartPos.findAncestor( 'paragraph' );
const currentFootnoteContent = selectionEndPos.findAncestor( ELEMENTS.footnoteContent );
if ( !currentFootnoteContent || !startParagraph || !endParagraph ) {
return;
}
const footnoteIsEmpty = startParagraph.maxOffset === 0 && currentFootnoteContent.childCount === 1;
if ( deletingFootnote || footnoteIsEmpty ) {
this._removeFootnote( modelWriter, currentFootnote );
data.preventDefault();
evt.stop();
}
} );
},
{ priority: 'high' }
);
}
/**
* Clear the children of the provided footnoteContent element,
* leaving an empty paragraph behind. This allows users to empty
* a footnote without deleting it. modelWriter is passed in to
* batch these changes with the ones that instantiated them,
* such that the set can be undone with a single action.
*/
private _clearContents( modelWriter: Writer, footnoteContent: Element ) {
const contents = modelWriter.createRangeIn( footnoteContent );
modelWriter.appendElement( 'paragraph', footnoteContent );
modelWriter.remove( contents );
}
/**
* Removes a footnote and its references, and renumbers subsequent footnotes. When a footnote's
* id attribute changes, it's references automatically update from a dispatcher event in converters.js,
* which triggers the `updateReferenceIds` method. modelWriter is passed in to batch these changes with
* the ones that instantiated them, such that the set can be undone with a single action.
*/
private _removeFootnote( modelWriter: Writer, footnote: Element ) {
// delete the current footnote and its references,
// and renumber subsequent footnotes.
if ( !this.editor ) {
return;
}
const footnoteSection = footnote.findAncestor( ELEMENTS.footnoteSection );
if ( !footnoteSection ) {
modelWriter.remove( footnote );
return;
}
const index = footnoteSection.getChildIndex( footnote );
const id = footnote.getAttribute( ATTRIBUTES.footnoteId );
this._removeReferences( modelWriter, `${ id }` );
modelWriter.remove( footnote );
// if no footnotes remain, remove the footnote section
if ( footnoteSection.childCount === 0 ) {
modelWriter.remove( footnoteSection );
this._removeReferences( modelWriter );
} else {
if ( index == null ) {
throw new Error( 'Index is nullish' );
}
// after footnote deletion the selection winds up surrounding the previous footnote
// (or the following footnote if no previous footnote exists). Typing in that state
// immediately deletes the footnote. This deliberately sets the new selection position
// to avoid that.
const neighborFootnote = index === 0 ? footnoteSection.getChild( index ) : footnoteSection.getChild( ( index ?? 0 ) - 1 );
if ( !( neighborFootnote instanceof Element ) ) {
return;
}
const neighborEndParagraph = modelQueryElementsAll( this.editor, neighborFootnote, element =>
element.is( 'element', 'paragraph' )
).pop();
if ( neighborEndParagraph ) {
modelWriter.setSelection( neighborEndParagraph, 'end' );
}
}
if ( index == null ) {
throw new Error( 'Index is nullish' );
}
// renumber subsequent footnotes
const subsequentFootnotes = [ ...footnoteSection.getChildren() ].slice( index ?? 0 );
for ( const [ i, child ] of subsequentFootnotes.entries() ) {
modelWriter.setAttribute( ATTRIBUTES.footnoteIndex, `${ index ?? 0 + i + 1 }`, child );
}
}
/**
* Deletes all references to the footnote with the given id. If no id is provided,
* all references are deleted. modelWriter is passed in to batch these changes with
* the ones that instantiated them, such that the set can be undone with a single action.
*/
private _removeReferences( modelWriter: Writer, footnoteId: string | undefined = undefined ) {
const removeList: Array<any> = [];
if ( !this.rootElement ) {
throw new Error( 'Document has no root element.' );
}
const footnoteReferences = modelQueryElementsAll( this.editor, this.rootElement, e =>
e.is( 'element', ELEMENTS.footnoteReference )
);
footnoteReferences.forEach( footnoteReference => {
const id = footnoteReference.getAttribute( ATTRIBUTES.footnoteId );
if ( !footnoteId || id === footnoteId ) {
removeList.push( footnoteReference );
}
} );
for ( const item of removeList ) {
modelWriter.remove( item );
}
}
/**
* Updates all references for a single footnote. This function is called when
* the index attribute of an existing footnote changes, which happens when a footnote
* with a lower index is deleted. batch is passed in to group these changes with
* the ones that instantiated them.
*/
private _updateReferenceIndices( batch: Batch, footnoteId: string, newFootnoteIndex: string ) {
const footnoteReferences = modelQueryElementsAll(
this.editor,
this.rootElement,
e => e.is( 'element', ELEMENTS.footnoteReference ) && e.getAttribute( ATTRIBUTES.footnoteId ) === footnoteId
);
this.editor.model.enqueueChange( batch, writer => {
footnoteReferences.forEach( footnoteReference => {
writer.setAttribute( ATTRIBUTES.footnoteIndex, newFootnoteIndex, footnoteReference );
} );
} );
}
/**
* Reindexes footnotes such that footnote references occur in order, and reorders
* footnote items in the footer section accordingly. batch is passed in to group changes with
* the ones that instantiated them.
*/
private _orderFootnotes( batch: Batch ) {
const footnoteReferences = modelQueryElementsAll( this.editor, this.rootElement, e =>
e.is( 'element', ELEMENTS.footnoteReference )
);
const uniqueIds = new Set( footnoteReferences.map( e => e.getAttribute( ATTRIBUTES.footnoteId ) ) );
const orderedFootnotes = [ ...uniqueIds ].map( id =>
modelQueryElement(
this.editor,
this.rootElement,
e => e.is( 'element', ELEMENTS.footnoteItem ) && e.getAttribute( ATTRIBUTES.footnoteId ) === id
)
);
this.editor.model.enqueueChange( batch, writer => {
const footnoteSection = modelQueryElement( this.editor, this.rootElement, e =>
e.is( 'element', ELEMENTS.footnoteSection )
);
if ( !footnoteSection ) {
return;
}
/**
* In order to keep footnotes with no existing references at the end of the list,
* the loop below reverses the list of footnotes with references and inserts them
* each at the beginning.
*/
for ( const footnote of orderedFootnotes.reverse() ) {
if ( footnote ) {
writer.move( writer.createRangeOn( footnote ), footnoteSection, 0 );
}
}
/**
* once the list is sorted, make one final pass to update footnote indices.
*/
for ( const footnote of modelQueryElementsAll( this.editor, footnoteSection, e =>
e.is( 'element', ELEMENTS.footnoteItem )
) ) {
const index = `${ ( footnoteSection?.getChildIndex( footnote ) ?? -1 ) + 1 }`;
if ( footnote ) {
writer.setAttribute( ATTRIBUTES.footnoteIndex, index, footnote );
}
const id = footnote.getAttribute( ATTRIBUTES.footnoteId );
// /**
// * unfortunately the following line seems to be necessary, even though updateReferenceIndices
// * should fire from the attribute change immediately above. It seems that events initiated by
// * a `change:data` event do not themselves fire another `change:data` event.
// */
if ( id ) {
this._updateReferenceIndices( batch, `${ id }`, `${ index }` );
}
}
} );
}
}

View File

@@ -0,0 +1,69 @@
// eslint-disable-next-line no-restricted-imports
import { Schema } from 'ckeditor5';
import { ATTRIBUTES, ELEMENTS } from '../constants.js';
/**
* Declares the custom element types used by the footnotes plugin.
* See here for the meanings of each rule:
* https://ckeditor.com/docs/ckeditor5/latest/api/module_engine_model_schema-SchemaItemDefinition.html#member-isObject
*/
export const defineSchema = ( schema: Schema ): void => {
/**
* Footnote section at the footer of the document.
*/
schema.register( ELEMENTS.footnoteSection, {
isObject: true,
allowWhere: '$block',
allowIn: '$root',
allowChildren: ELEMENTS.footnoteItem,
allowAttributes: [ ATTRIBUTES.footnoteSection ]
} );
/**
* Individual footnote item within the footnote section.
*/
schema.register( ELEMENTS.footnoteItem, {
isBlock: true,
isObject: true,
allowContentOf: '$root',
allowAttributes: [ ATTRIBUTES.footnoteSection, ATTRIBUTES.footnoteId, ATTRIBUTES.footnoteIndex ]
} );
/**
* Editable footnote item content container.
*/
schema.register( ELEMENTS.footnoteContent, {
allowIn: ELEMENTS.footnoteItem,
allowContentOf: '$root',
allowAttributes: [ ATTRIBUTES.footnoteSection ]
} );
/**
* Inline footnote citation, placed within the main text.
*/
schema.register( ELEMENTS.footnoteReference, {
allowWhere: '$text',
isInline: true,
isObject: true,
allowAttributes: [ ATTRIBUTES.footnoteReference, ATTRIBUTES.footnoteId, ATTRIBUTES.footnoteIndex ]
} );
/**
* return link which takes you from the footnote to the inline reference.
*/
schema.register( ELEMENTS.footnoteBackLink, {
allowIn: ELEMENTS.footnoteItem,
isInline: true,
isSelectable: false,
allowAttributes: [ ATTRIBUTES.footnoteBackLink, ATTRIBUTES.footnoteId ]
} );
schema.addChildCheck( ( context, childDefinition ) => {
if ( context.endsWith( ELEMENTS.footnoteContent ) && childDefinition.name === ELEMENTS.footnoteSection ) {
return false;
}
if ( context.endsWith( ELEMENTS.footnoteContent ) && childDefinition.name === 'listItem' ) {
return false;
}
} );
};

View File

@@ -0,0 +1,120 @@
import { Plugin, addListToDropdown, createDropdown, SplitButtonView, ViewModel, type ListDropdownItemDefinition, Collection } from 'ckeditor5';
import {
ATTRIBUTES,
COMMANDS,
ELEMENTS,
TOOLBAR_COMPONENT_NAME
} from './constants.js';
import insertFootnoteIcon from '../theme/icons/insert-footnote.svg';
import { modelQueryElement, modelQueryElementsAll } from './utils.js';
export default class FootnoteUI extends Plugin {
public init(): void {
const editor = this.editor;
const translate = editor.t;
editor.ui.componentFactory.add( TOOLBAR_COMPONENT_NAME, locale => {
const dropdownView = createDropdown( locale, SplitButtonView );
const splitButtonView = dropdownView.buttonView;
// Populate the list in the dropdown with items.
// addListToDropdown( dropdownView, getDropdownItemsDefinitions( placeholderNames ) );
const command = editor.commands.get( COMMANDS.insertFootnote );
if ( !command ) {
throw new Error( 'Command not found.' );
}
splitButtonView.set( {
label: translate( 'Footnote' ),
icon: insertFootnoteIcon,
tooltip: true,
isToggleable: true
} );
splitButtonView.bind( 'isOn' ).to( command, 'value', value => !!value );
splitButtonView.on( 'execute', () => {
editor.execute( COMMANDS.insertFootnote, {
footnoteIndex: 0
} );
editor.editing.view.focus();
} );
dropdownView.class = 'ck-code-block-dropdown';
dropdownView.bind( 'isEnabled' ).to( command );
dropdownView.on(
'change:isOpen',
( evt, propertyName, newValue ) => {
dropdownView?.listView?.items.clear();
if ( newValue ) {
addListToDropdown(
dropdownView,
this.getDropdownItemsDefinitions() as any
);
} else {
dropdownView?.listView?.items.clear();
const listElement = dropdownView?.listView?.element;
if ( listElement && listElement.parentNode ) {
listElement.parentNode.removeChild( listElement );
}
}
}
);
// Execute the command when the dropdown item is clicked (executed).
this.listenTo( dropdownView, 'execute', evt => {
editor.execute( COMMANDS.insertFootnote, {
footnoteIndex: ( evt.source as any ).commandParam
} );
editor.editing.view.focus();
} );
return dropdownView;
} );
}
public getDropdownItemsDefinitions(): Collection<ListDropdownItemDefinition> {
const itemDefinitions = new Collection<ListDropdownItemDefinition>();
const defaultDef: ListDropdownItemDefinition = {
type: 'button',
model: new ViewModel( {
commandParam: 0,
label: 'New footnote',
withText: true
} )
};
itemDefinitions.add( defaultDef );
const rootElement = this.editor.model.document.getRoot();
if ( !rootElement ) {
throw new Error( 'Document has no root element.' );
}
const footnoteSection = modelQueryElement(
this.editor,
rootElement,
element => element.is( 'element', ELEMENTS.footnoteSection )
);
if ( footnoteSection ) {
const footnoteItems = modelQueryElementsAll(
this.editor,
rootElement,
element => element.is( 'element', ELEMENTS.footnoteItem )
);
footnoteItems.forEach( footnote => {
const index = footnote.getAttribute( ATTRIBUTES.footnoteIndex );
const definition: ListDropdownItemDefinition = {
type: 'button',
model: new ViewModel( {
commandParam: index,
label: `Insert footnote ${ index }`,
withText: true
} )
};
itemDefinitions.add( definition );
} );
}
return itemDefinitions;
}
}

View File

@@ -1,39 +1,13 @@
import { Plugin, ButtonView } from 'ckeditor5';
import ckeditor5Icon from '../theme/icons/ckeditor.svg';
import { Plugin } from 'ckeditor5';
import FootnoteEditing from './footnote-editing/footnote-editing.js';
import FootnoteUI from './footnote-ui.js';
export default class Footnotes extends Plugin {
public static get pluginName() {
return 'Footnotes' as const;
}
public init(): void {
const editor = this.editor;
const t = editor.t;
const model = editor.model;
// Register the "footnotes" button, so it can be displayed in the toolbar.
editor.ui.componentFactory.add( 'footnotes', locale => {
const view = new ButtonView( locale );
view.set( {
label: t( 'Footnotes' ),
icon: ckeditor5Icon,
tooltip: true
} );
// Insert a text into the editor after clicking the button.
this.listenTo( view, 'execute', () => {
model.change( writer => {
const textNode = writer.createText( 'Hello CKEditor 5!' );
model.insertContent( textNode );
} );
editor.editing.view.focus();
} );
return view;
} );
public static get requires() {
return [ FootnoteEditing, FootnoteUI ] as const;
}
}

View File

@@ -1,8 +1,9 @@
import ckeditor from './../theme/icons/ckeditor.svg';
import insertFootnoteIcon from './../theme/icons/insert-footnote.svg';
import './augmentation.js';
import "../theme/footnote.css";
export { default as Footnotes } from './footnotes.js';
export const icons = {
ckeditor
insertFootnoteIcon
};

View File

@@ -0,0 +1,96 @@
import { Command, type Element, type RootElement, type Writer } from "ckeditor5";
import { ATTRIBUTES, ELEMENTS } from './constants.js';
import { modelQueryElement } from './utils.js';
export default class InsertFootnoteCommand extends Command {
/**
* Creates a footnote reference with the given index, and creates a matching
* footnote if one doesn't already exist. Also creates the footnote section
* if it doesn't exist. If `footnoteIndex` is 0 (or not provided), the added
* footnote is given the next unused index--e.g. 7, if 6 footnotes exist so far.
*/
public override execute( { footnoteIndex }: { footnoteIndex?: number } = { footnoteIndex: 0 } ): void {
this.editor.model.enqueueChange( modelWriter => {
const doc = this.editor.model.document;
const rootElement = doc.getRoot();
if ( !rootElement ) {
return;
}
const footnoteSection = this._getFootnoteSection( modelWriter, rootElement );
let index: string | undefined = undefined;
let id: string | undefined = undefined;
if ( footnoteIndex === 0 ) {
index = `${ footnoteSection.maxOffset + 1 }`;
id = Math.random().toString( 36 ).slice( 2 );
} else {
index = `${ footnoteIndex }`;
const matchingFootnote = modelQueryElement(
this.editor,
footnoteSection,
element =>
element.is( 'element', ELEMENTS.footnoteItem ) && element.getAttribute( ATTRIBUTES.footnoteIndex ) === index
);
if ( matchingFootnote ) {
id = matchingFootnote.getAttribute( ATTRIBUTES.footnoteId ) as string;
}
}
if ( !id || !index ) {
return;
}
modelWriter.setSelection( doc.selection.getLastPosition() );
const footnoteReference = modelWriter.createElement( ELEMENTS.footnoteReference, {
[ ATTRIBUTES.footnoteId ]: id,
[ ATTRIBUTES.footnoteIndex ]: index
} );
this.editor.model.insertContent( footnoteReference );
modelWriter.setSelection( footnoteReference, 'after' );
// if referencing an existing footnote
if ( footnoteIndex !== 0 ) {
return;
}
const footnoteContent = modelWriter.createElement( ELEMENTS.footnoteContent );
const footnoteItem = modelWriter.createElement( ELEMENTS.footnoteItem, {
[ ATTRIBUTES.footnoteId ]: id,
[ ATTRIBUTES.footnoteIndex ]: index
} );
const footnoteBackLink = modelWriter.createElement( ELEMENTS.footnoteBackLink, { [ ATTRIBUTES.footnoteId ]: id } );
const p = modelWriter.createElement( 'paragraph' );
modelWriter.append( p, footnoteContent );
modelWriter.append( footnoteContent, footnoteItem );
modelWriter.insert( footnoteBackLink, footnoteItem, 0 );
this.editor.model.insertContent(
footnoteItem,
modelWriter.createPositionAt( footnoteSection, footnoteSection.maxOffset )
);
} );
}
/**
* Called automatically when changes are applied to the document. Sets `isEnabled`
* to determine whether footnote creation is allowed at the current location.
*/
public override refresh(): void {
const model = this.editor.model;
const lastPosition = model.document.selection.getLastPosition();
const allowedIn = lastPosition && model.schema.findAllowedParent( lastPosition, ELEMENTS.footnoteReference );
this.isEnabled = allowedIn !== null;
}
/**
* Returns the footnote section if it exists, or creates on if it doesn't.
*/
private _getFootnoteSection( writer: Writer, rootElement: RootElement ): Element {
const footnoteSection = modelQueryElement( this.editor, rootElement, element =>
element.is( 'element', ELEMENTS.footnoteSection )
);
if ( footnoteSection ) {
return footnoteSection;
}
const newFootnoteSection = writer.createElement( ELEMENTS.footnoteSection );
this.editor.model.insertContent( newFootnoteSection, writer.createPositionAt( rootElement, rootElement.maxOffset ) );
return newFootnoteSection;
}
}

View File

@@ -0,0 +1,125 @@
import { type Editor } from 'ckeditor5/src/core.js';
import { Element, Text, TextProxy, ViewElement } from 'ckeditor5/src/engine.js';
// There's ample DRY violation in this file; type checking
// polymorphism without full typescript is just incredibly finicky.
// I (Jonathan) suspect there's a more elegant solution for this,
// but I tried a lot of things and none of them worked.
/**
* Returns an array of all descendant elements of
* the root for which the provided predicate returns true.
*/
export const modelQueryElementsAll = (
editor: Editor,
rootElement: Element,
predicate: ( item: Element ) => boolean = _ => true
): Array<Element> => {
const range = editor.model.createRangeIn( rootElement );
const output: Array<Element> = [];
for ( const item of range.getItems() ) {
if ( !( item instanceof Element ) ) {
continue;
}
if ( predicate( item ) ) {
output.push( item );
}
}
return output;
};
/**
* Returns an array of all descendant text nodes and text proxies of
* the root for which the provided predicate returns true.
*/
export const modelQueryTextAll = (
editor: Editor,
rootElement: Element,
predicate: ( item: Text | TextProxy ) => boolean = _ => true
): Array<Text | TextProxy> => {
const range = editor.model.createRangeIn( rootElement );
const output: Array<Text | TextProxy> = [];
for ( const item of range.getItems() ) {
if ( !( item instanceof Text || item instanceof TextProxy ) ) {
continue;
}
if ( predicate( item ) ) {
output.push( item );
}
}
return output;
};
/**
* Returns the first descendant element of the root for which the provided
* predicate returns true, or null if no such element is found.
*/
export const modelQueryElement = (
editor: Editor,
rootElement: Element,
predicate: ( item: Element ) => boolean = _ => true
): Element | null => {
const range = editor.model.createRangeIn( rootElement );
for ( const item of range.getItems() ) {
if ( !( item instanceof Element ) ) {
continue;
}
if ( predicate( item ) ) {
return item;
}
}
return null;
};
/**
* Returns the first descendant text node or text proxy of the root for which the provided
* predicate returns true, or null if no such element is found.
*/
export const modelQueryText = (
editor: Editor,
rootElement: Element,
predicate: ( item: Text | TextProxy ) => boolean = _ => true
): Text | TextProxy | null => {
const range = editor.model.createRangeIn( rootElement );
for ( const item of range.getItems() ) {
if ( !( item instanceof Text || item instanceof TextProxy ) ) {
continue;
}
if ( predicate( item ) ) {
return item;
}
}
return null;
};
/**
* Returns the first descendant element of the root for which the provided
* predicate returns true, or null if no such element is found.
*/
export const viewQueryElement = (
editor: Editor,
rootElement: ViewElement,
predicate: ( item: ViewElement ) => boolean = _ => true
): ViewElement | null => {
const range = editor.editing.view.createRangeIn( rootElement );
for ( const item of range.getItems() ) {
if ( !( item instanceof ViewElement ) ) {
continue;
}
if ( predicate( item ) ) {
return item;
}
}
return null;
};