import { type Editor, type DowncastConversionApi, type ViewContainerElement, ModelElement, 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: ModelElement, 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: ModelElement, 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: ModelElement, 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: ModelElement; attributeOldValue: string; attributeNewValue: string; }, conversionApi: DowncastConversionApi, editor: Editor ) { const { item, attributeNewValue: newIndex } = data; if ( !( item instanceof ModelElement ) || !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 ); }