mirror of
				https://github.com/zadam/trilium.git
				synced 2025-11-04 05:28:59 +01:00 
			
		
		
		
	
		
			
				
	
	
		
			371 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			371 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
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 );
 | 
						|
}
 |