diff --git a/LICENSE.md b/LICENSE.md index de483eb83..d9785fc8b 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -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. \ No newline at end of file diff --git a/README.md b/README.md index 8045330dd..7e1831e2e 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/ckeditor5-metadata.json b/ckeditor5-metadata.json index f88f59c33..40825886a 100644 --- a/ckeditor5-metadata.json +++ b/ckeditor5-metadata.json @@ -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" } ] } diff --git a/package.json b/package.json index 7a43397f7..1fe57ab5c 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/sample/ckeditor.ts b/sample/ckeditor.ts index 5c9c2477f..df451b957 100644 --- a/sample/ckeditor.ts +++ b/sample/ckeditor.ts @@ -65,7 +65,7 @@ ClassicEditor 'undo', 'redo', '|', - 'footnotesButton', + 'footnote', '|', 'heading', '|', diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 000000000..bb84f3d56 --- /dev/null +++ b/src/constants.ts @@ -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' +}; diff --git a/src/footnote-editing/auto-formatting.ts b/src/footnote-editing/auto-formatting.ts new file mode 100644 index 000000000..705875498 --- /dev/null +++ b/src/footnote-editing/auto-formatting.ts @@ -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, 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 ) => formatCallback( ranges, editor, rootElement ) + ); + } +}; diff --git a/src/footnote-editing/converters.ts b/src/footnote-editing/converters.ts new file mode 100644 index 000000000..4da3d4a35 --- /dev/null +++ b/src/footnote-editing/converters.ts @@ -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 ); +} diff --git a/src/footnote-editing/footnote-editing.ts b/src/footnote-editing/footnote-editing.ts new file mode 100644 index 000000000..bdf279220 --- /dev/null +++ b/src/footnote-editing/footnote-editing.ts @@ -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 = []; + 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 }` ); + } + } + } ); + } +} diff --git a/src/footnote-editing/schema.ts b/src/footnote-editing/schema.ts new file mode 100644 index 000000000..5ce246dfd --- /dev/null +++ b/src/footnote-editing/schema.ts @@ -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; + } + } ); +}; diff --git a/src/footnote-ui.ts b/src/footnote-ui.ts new file mode 100644 index 000000000..7ca407702 --- /dev/null +++ b/src/footnote-ui.ts @@ -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 { + const itemDefinitions = new Collection(); + 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; + } +} diff --git a/src/footnote.css b/src/footnote.css new file mode 100644 index 000000000..02b271619 --- /dev/null +++ b/src/footnote.css @@ -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; +} \ No newline at end of file diff --git a/src/footnotes.ts b/src/footnotes.ts index ed1216bf3..9db4c7975 100644 --- a/src/footnotes.ts +++ b/src/footnotes.ts @@ -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; } } diff --git a/src/insert-footnote-command.ts b/src/insert-footnote-command.ts new file mode 100644 index 000000000..0467b1ad7 --- /dev/null +++ b/src/insert-footnote-command.ts @@ -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; + } +} diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 000000000..53ef3b440 --- /dev/null +++ b/src/utils.ts @@ -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 => { + const range = editor.model.createRangeIn( rootElement ); + const output: Array = []; + + 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 => { + const range = editor.model.createRangeIn( rootElement ); + const output: Array = []; + + 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 => { + const range = editor.editing.view.createRangeIn( rootElement ); + const output: Array = []; + + 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 => { + const range = editor.editing.view.createRangeIn( rootElement ); + const output: Array = []; + + 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; +}; diff --git a/tests/footnotes.ts b/tests/footnotes.ts deleted file mode 100644 index f2eec1e7a..000000000 --- a/tests/footnotes.ts +++ /dev/null @@ -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( '

Hello CKEditor 5!

' ); - } ); - } ); -} ); diff --git a/tests/index.ts b/tests/index.ts deleted file mode 100644 index e450ee12b..000000000 --- a/tests/index.ts +++ /dev/null @@ -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 ); - } ); - } ); -} ); diff --git a/theme/icons/ckeditor.svg b/theme/icons/ckeditor.svg deleted file mode 100644 index 25436f4b8..000000000 --- a/theme/icons/ckeditor.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/theme/icons/insert-footnote.svg b/theme/icons/insert-footnote.svg new file mode 100644 index 000000000..4dacd372a --- /dev/null +++ b/theme/icons/insert-footnote.svg @@ -0,0 +1,4 @@ + + ab + 1 +