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