Files
Trilium/packages/ckeditor5-footnotes/src/footnote-editing/footnote-editing.ts

314 lines
12 KiB
TypeScript

/**
* 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, ModelRootElement, viewToModelPositionOutsideModelElement, Widget, Writer } from 'ckeditor5';
export default class FootnoteEditing extends Plugin {
public static get pluginName() {
return 'FootnotesEditing' as const;
}
public static get requires() {
return [ Widget, Autoformat ] as const;
}
/**
* The root element of the document.
*/
public get rootElement(): ModelRootElement {
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 }` );
}
}
} );
}
}