all changes

This commit is contained in:
Tom Aitken 2024-08-29 08:41:45 +10:00
parent 33a95bc1a9
commit 4205db0147
19 changed files with 1438 additions and 129 deletions

View File

@ -3,4 +3,22 @@ Software License Agreement
Copyright (c) 2024. All rights reserved.
Licensed under the terms of [MIT license](https://opensource.org/licenses/MIT).
Licensed under the terms of [ISC license](https://opensource.org/licenses/ISC).
This code is highly derivative of [Forum Magnum Footnote Plugin](https://github.com/ForumMagnum/ForumMagnum/tree/master/public/lesswrong-editor/src/ckeditor5-footnote/src) with original license reproduced below:
ISC License
Copyright (c) 2020 Bohan Niu
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
PERFORMANCE OF THIS SOFTWARE.

View File

@ -1,7 +1,7 @@
@tomaitken/ckeditor5-footnotes
==============================
This package was created by the [ckeditor5-package-generator](https://www.npmjs.com/package/ckeditor5-package-generator) package.
This package was created by the [ckeditor5-package-generator](https://www.npmjs.com/package/ckeditor5-package-generator) package. It is highly derivative of [ForumMagnum Footnote Plugin](https://github.com/ForumMagnum/ForumMagnum/tree/master/public/lesswrong-editor/src/ckeditor5-footnote/src). All intellectual credit should go to the developers of this plugin.
## Table of contents
@ -51,24 +51,10 @@ yarn run start --no-open
yarn run start --language=de
```
### `test`
### ~`test`~
Allows executing unit tests for the package, specified in the `tests/` directory. The command accepts the following modifiers:
There are no tests for this plugin! Too lazy!
* `--coverage` – to create the code coverage report,
* `--watch` – to observe the source files (the command does not end after executing tests),
* `--source-map` – to generate source maps of sources,
* `--verbose` – to print additional webpack logs.
Examples:
```bash
# Execute tests.
yarn run test
# Generate code coverage report after each change in the sources.
yarn run test --coverage --test
```
### `lint`
@ -153,6 +139,4 @@ These scripts compile TypeScript and remove the compiled files. They are used in
## License
The `@tomaitken/ckeditor5-footnotes` package is available under [MIT license](https://opensource.org/licenses/MIT).
However, it is the default license of packages created by the [ckeditor5-package-generator](https://www.npmjs.com/package/ckeditor5-package-generator) package and can be changed.
The `@tomaitken/ckeditor5-footnotes` package is available under [IST license](https://opensource.org/licenses/IST).

View File

@ -7,9 +7,9 @@
"path": "src/footnotes.ts",
"uiComponents": [
{
"name": "footnotesButton",
"name": "insertFootnote",
"type": "Button",
"iconPath": "theme/icons/ckeditor.svg"
"iconPath": "theme/icons/insert-footnote.svg"
}
]
}

View File

@ -1,7 +1,7 @@
{
"name": "@tomaitken/ckeditor5-footnotes",
"version": "0.0.1",
"description": "A plugin for CKEditor 5.",
"description": "A plugin for CKEditor 5 to allow footnotes.",
"keywords": [
"ckeditor",
"ckeditor5",
@ -61,7 +61,6 @@
"lint": "eslint \"**/*.{js,ts}\" --quiet",
"start": "ckeditor5-package-tools start",
"stylelint": "stylelint --quiet --allow-empty-input 'theme/**/*.css'",
"test": "ckeditor5-package-tools test",
"prepare": "yarn run build:dist",
"prepublishOnly": "yarn run ts:build && ckeditor5-package-tools export-package-as-javascript",
"postpublish": "yarn run ts:clear && ckeditor5-package-tools export-package-as-typescript",

View File

@ -65,7 +65,7 @@ ClassicEditor
'undo',
'redo',
'|',
'footnotesButton',
'footnote',
'|',
'heading',
'|',

35
src/constants.ts Normal file
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 { inlineAutoformatEditing, Text, TextProxy, type Autoformat, type Editor, type Element, type Range } 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,382 @@
import {
type DowncastConversionApi,
type Editor,
type ViewContainerElement,
Element,
toWidget,
toWidgetEditable } from 'ckeditor5';
import { ATTRIBUTES, CLASSES, ELEMENTS } from '../constants.js';
import { viewQueryElement, viewQueryText } 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
} );
console.log( 'section', section );
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 );
console.log( 'footnoteReferenceViewElement', footnoteReferenceViewElement );
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 {
console.log( 'createFootnoteReferenceViewElement' );
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.' );
}
console.log( 'index', index );
console.log( 'id', 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 textNode = viewQueryText( editor, footnoteReferenceView, _ => true );
const anchor = viewQueryElement( editor, footnoteReferenceView, element => element.name === 'a' );
if ( !textNode || !anchor ) {
viewWriter.remove( footnoteReferenceView );
return;
}
// @ts-expect-error TextNode not accepted
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,322 @@
/**
* 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 {
Autoformat,
Element,
Plugin,
viewToModelPositionOutsideModelElement,
Widget,
type Batch,
type RootElement,
type Writer
} from 'ckeditor5';
import '../footnote.css';
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';
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;
console.log( eventSource.differ.getChanges() );
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 );
}
} );
console.log( 'reached end of change:data' );
},
{ 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,70 @@
import type { Schema } from 'ckeditor5';
// eslint-disable-next-line no-restricted-imports
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;
}
} );
};

116
src/footnote-ui.ts Normal file
View File

@ -0,0 +1,116 @@
import {
addListToDropdown,
Collection,
createDropdown,
Plugin,
ViewModel,
type ListDropdownItemDefinition
} 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 );
// 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.' );
}
dropdownView.buttonView.set( {
label: translate( 'Footnote' ),
icon: insertFootnoteIcon,
tooltip: true
} );
dropdownView.class = 'ck-code-block-dropdown';
dropdownView.bind( 'isEnabled' ).to( command );
dropdownView.on(
'change:isOpen',
( evt, propertyName, newValue ) => {
if ( newValue ) {
addListToDropdown(
dropdownView,
this.getDropdownItemsDefinitions() as any
);
} else {
dropdownView?.listView?.items.clear();
}
}
);
// Execute the command when the dropdown item is clicked (executed).
this.listenTo( dropdownView, 'execute', evt => {
console.log( 'commandParam', ( evt.source as any ).commandParam );
editor.execute( COMMANDS.insertFootnote, {
footnoteIndex: ( evt.source as any ).commandParam
} );
console.log( 'completed execution' );
editor.editing.view.focus();
console.log( 'post 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;
}
}

60
src/footnote.css Normal file
View File

@ -0,0 +1,60 @@
.ck .footnote-section {
padding: 10px;
margin: 1em 0;
border: solid 1px hsl(0, 0%, 77%);
border-radius: 2px;
counter-reset: footnote-counter;
}
.ck .footnote-item {
list-style: none;
counter-increment: footnote-counter;
margin-left: 0.5em;
display: flex;
}
.ck .footnote-item > * {
vertical-align: text-top;
}
.ck .footnote-back-link {
position: relative;
margin-right: 0.1em;
top: -0.2em;
}
.ck .footnotes .footnote-back-link > sup {
margin-right: 0;
}
.ck .footnote-item::before {
content: counter(footnote-counter) ". ";
display: inline-block;
position: relative;
right: 0.2em;
min-width: fit-content;
text-align: right;
}
.ck .footnote-content {
display: inline-block;
padding: 0 0.3em;
width: 95%;
border-radius: 2px;
flex-grow: 1;
}
.ck .ck-widget.footnote-section .ck-widget__type-around__button_after {
display:none; /* hides the 'insert after' button from the ckeditor widget */
}
.placeholder {
padding: 2px 2px;
outline-offset: -2px;
line-height: 1em;
margin: 0 1px;
}
.placeholder::selection {
display: none;
}

View File

@ -1,39 +1,14 @@
import { Plugin, ButtonView } from 'ckeditor5';
import { Plugin } from 'ckeditor5';
import ckeditor5Icon from '../theme/icons/ckeditor.svg';
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;
// Add the "footnotesButton" to feature components.
editor.ui.componentFactory.add( 'footnotesButton', 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

@ -0,0 +1,102 @@
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 ) {
console.log( 'erroneous footnoteIndex' );
return;
}
console.log( 'we made it here' );
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 );
console.log( 'we made it here 1' );
this.editor.model.insertContent(
footnoteItem,
modelWriter.createPositionAt( footnoteSection, footnoteSection.maxOffset )
);
console.log( 'we made it here 2' );
} );
}
/**
* 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;
console.log( 'over here' );
const lastPosition = model.document.selection.getLastPosition();
const allowedIn = lastPosition && model.schema.findAllowedParent( lastPosition, ELEMENTS.footnoteSection );
this.isEnabled = allowedIn !== null;
console.log( 'now here' );
}
/**
* 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;
}
}

194
src/utils.ts Normal file
View File

@ -0,0 +1,194 @@
import { type Editor, Element, Text, TextProxy, ViewElement, ViewText } from 'ckeditor5';
// 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 an array of all descendant elements of
* the root for which the provided predicate returns true.
*/
export const viewQueryElementsAll = (
editor: Editor,
rootElement: ViewElement,
predicate: ( item: ViewElement ) => boolean = _ => true
): Array<ViewElement> => {
const range = editor.editing.view.createRangeIn( rootElement );
const output: Array<ViewElement> = [];
for ( const item of range.getItems() ) {
if ( !( item instanceof ViewElement ) ) {
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 viewQueryTextAll = (
editor: Editor,
rootElement: ViewElement,
predicate: ( item: ViewText | TextProxy ) => boolean = _ => true
): Array<ViewText | TextProxy> => {
const range = editor.editing.view.createRangeIn( rootElement );
const output: Array<ViewText | TextProxy> = [];
for ( const item of range.getItems() ) {
if ( !( item instanceof ViewText || 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;
};
/**
* 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 viewQueryText = (
editor: Editor,
rootElement: ViewElement,
predicate: ( item: ViewText | TextProxy ) => boolean = _ => true
): ViewText | TextProxy | null => {
const range = editor.editing.view.createRangeIn( rootElement );
for ( const item of range.getItems() ) {
if ( !( item instanceof ViewText || item instanceof TextProxy ) ) {
continue;
}
if ( predicate( item ) ) {
return item;
}
}
return null;
};

View File

@ -1,55 +0,0 @@
import { expect } from 'chai';
import { ClassicEditor, Essentials, Paragraph, Heading } from 'ckeditor5';
import Footnotes from '../src/footnotes.js';
describe( 'Footnotes', () => {
it( 'should be named', () => {
expect( Footnotes.pluginName ).to.equal( 'Footnotes' );
} );
describe( 'init()', () => {
let domElement: HTMLElement, editor: ClassicEditor;
beforeEach( async () => {
domElement = document.createElement( 'div' );
document.body.appendChild( domElement );
editor = await ClassicEditor.create( domElement, {
plugins: [
Paragraph,
Heading,
Essentials,
Footnotes
],
toolbar: [
'footnotesButton'
]
} );
} );
afterEach( () => {
domElement.remove();
return editor.destroy();
} );
it( 'should load Footnotes', () => {
const myPlugin = editor.plugins.get( 'Footnotes' );
expect( myPlugin ).to.be.an.instanceof( Footnotes );
} );
it( 'should add an icon to the toolbar', () => {
expect( editor.ui.componentFactory.has( 'footnotesButton' ) ).to.equal( true );
} );
it( 'should add a text into the editor after clicking the icon', () => {
const icon = editor.ui.componentFactory.create( 'footnotesButton' );
expect( editor.getData() ).to.equal( '' );
icon.fire( 'execute' );
expect( editor.getData() ).to.equal( '<p>Hello CKEditor 5!</p>' );
} );
} );
} );

View File

@ -1,17 +0,0 @@
import { expect } from 'chai';
import { Footnotes as FootnotesDll, icons } from '../src/index.js';
import Footnotes from '../src/footnotes.js';
import ckeditor from './../theme/icons/ckeditor.svg';
describe( 'CKEditor5 Footnotes DLL', () => {
it( 'exports Footnotes', () => {
expect( FootnotesDll ).to.equal( Footnotes );
} );
describe( 'icons', () => {
it( 'exports the "ckeditor" icon', () => {
expect( icons.ckeditor ).to.equal( ckeditor );
} );
} );
} );

View File

@ -1 +0,0 @@
<svg width='68' height='64' viewBox='0 0 68 64' xmlns='http://www.w3.org/2000/svg'><g fill='none' fill-rule='evenodd'><path d='M43.71 11.025a11.508 11.508 0 0 0-1.213 5.159c0 6.42 5.244 11.625 11.713 11.625.083 0 .167 0 .25-.002v16.282a5.464 5.464 0 0 1-2.756 4.739L30.986 60.7a5.548 5.548 0 0 1-5.512 0L4.756 48.828A5.464 5.464 0 0 1 2 44.089V20.344c0-1.955 1.05-3.76 2.756-4.738L25.474 3.733a5.548 5.548 0 0 1 5.512 0l12.724 7.292z' fill='#FFF'/><path d='M45.684 8.79a12.604 12.604 0 0 0-1.329 5.65c0 7.032 5.744 12.733 12.829 12.733.091 0 .183-.001.274-.003v17.834a5.987 5.987 0 0 1-3.019 5.19L31.747 63.196a6.076 6.076 0 0 1-6.037 0L3.02 50.193A5.984 5.984 0 0 1 0 45.003V18.997c0-2.14 1.15-4.119 3.019-5.19L25.71.804a6.076 6.076 0 0 1 6.037 0L45.684 8.79zm-29.44 11.89c-.834 0-1.51.671-1.51 1.498v.715c0 .828.676 1.498 1.51 1.498h25.489c.833 0 1.51-.67 1.51-1.498v-.715c0-.827-.677-1.498-1.51-1.498h-25.49.001zm0 9.227c-.834 0-1.51.671-1.51 1.498v.715c0 .828.676 1.498 1.51 1.498h18.479c.833 0 1.509-.67 1.509-1.498v-.715c0-.827-.676-1.498-1.51-1.498H16.244zm0 9.227c-.834 0-1.51.671-1.51 1.498v.715c0 .828.676 1.498 1.51 1.498h25.489c.833 0 1.51-.67 1.51-1.498v-.715c0-.827-.677-1.498-1.51-1.498h-25.49.001zm41.191-14.459c-5.835 0-10.565-4.695-10.565-10.486 0-5.792 4.73-10.487 10.565-10.487C63.27 3.703 68 8.398 68 14.19c0 5.791-4.73 10.486-10.565 10.486v-.001z' fill='#1EBC61' fill-rule='nonzero'/><path d='M60.857 15.995c0-.467-.084-.875-.251-1.225a2.547 2.547 0 0 0-.686-.88 2.888 2.888 0 0 0-1.026-.531 4.418 4.418 0 0 0-1.259-.175c-.134 0-.283.006-.447.018-.15.01-.3.034-.446.07l.075-1.4h3.587v-1.8h-5.462l-.214 5.06c.319-.116.682-.21 1.089-.28.406-.071.77-.107 1.088-.107.218 0 .437.021.655.063.218.041.413.114.585.218s.313.244.422.419c.109.175.163.391.163.65 0 .424-.132.745-.396.961a1.434 1.434 0 0 1-.938.325c-.352 0-.656-.1-.912-.3-.256-.2-.43-.453-.523-.762l-1.925.588c.1.35.258.664.472.943.214.279.47.514.767.706.298.191.63.339.995.443.365.104.749.156 1.151.156.437 0 .86-.064 1.272-.193.41-.13.778-.323 1.1-.581a2.8 2.8 0 0 0 .775-.981c.193-.396.29-.864.29-1.405h-.001z' fill='#FFF' fill-rule='nonzero'/></g></svg>

Before

Width:  |  Height:  |  Size: 2.1 KiB

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">
<text x="2" y="15" font-family="Arial, sans-serif" font-size="14" fill="black">ab</text>
<text x="17" y="10" font-family="Arial, sans-serif" font-size="8" fill="black">1</text>
</svg>

After

Width:  |  Height:  |  Size: 370 B