mirror of
https://github.com/TriliumNext/Notes.git
synced 2025-07-30 03:32:26 +08:00
all changes
This commit is contained in:
parent
33a95bc1a9
commit
4205db0147
20
LICENSE.md
20
LICENSE.md
@ -3,4 +3,22 @@ Software License Agreement
|
|||||||
|
|
||||||
Copyright (c) 2024. All rights reserved.
|
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.
|
24
README.md
24
README.md
@ -1,7 +1,7 @@
|
|||||||
@tomaitken/ckeditor5-footnotes
|
@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
|
## Table of contents
|
||||||
|
|
||||||
@ -51,24 +51,10 @@ yarn run start --no-open
|
|||||||
yarn run start --language=de
|
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`
|
### `lint`
|
||||||
|
|
||||||
@ -153,6 +139,4 @@ These scripts compile TypeScript and remove the compiled files. They are used in
|
|||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
The `@tomaitken/ckeditor5-footnotes` package is available under [MIT license](https://opensource.org/licenses/MIT).
|
The `@tomaitken/ckeditor5-footnotes` package is available under [IST license](https://opensource.org/licenses/IST).
|
||||||
|
|
||||||
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.
|
|
||||||
|
@ -7,9 +7,9 @@
|
|||||||
"path": "src/footnotes.ts",
|
"path": "src/footnotes.ts",
|
||||||
"uiComponents": [
|
"uiComponents": [
|
||||||
{
|
{
|
||||||
"name": "footnotesButton",
|
"name": "insertFootnote",
|
||||||
"type": "Button",
|
"type": "Button",
|
||||||
"iconPath": "theme/icons/ckeditor.svg"
|
"iconPath": "theme/icons/insert-footnote.svg"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@tomaitken/ckeditor5-footnotes",
|
"name": "@tomaitken/ckeditor5-footnotes",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"description": "A plugin for CKEditor 5.",
|
"description": "A plugin for CKEditor 5 to allow footnotes.",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"ckeditor",
|
"ckeditor",
|
||||||
"ckeditor5",
|
"ckeditor5",
|
||||||
@ -61,7 +61,6 @@
|
|||||||
"lint": "eslint \"**/*.{js,ts}\" --quiet",
|
"lint": "eslint \"**/*.{js,ts}\" --quiet",
|
||||||
"start": "ckeditor5-package-tools start",
|
"start": "ckeditor5-package-tools start",
|
||||||
"stylelint": "stylelint --quiet --allow-empty-input 'theme/**/*.css'",
|
"stylelint": "stylelint --quiet --allow-empty-input 'theme/**/*.css'",
|
||||||
"test": "ckeditor5-package-tools test",
|
|
||||||
"prepare": "yarn run build:dist",
|
"prepare": "yarn run build:dist",
|
||||||
"prepublishOnly": "yarn run ts:build && ckeditor5-package-tools export-package-as-javascript",
|
"prepublishOnly": "yarn run ts:build && ckeditor5-package-tools export-package-as-javascript",
|
||||||
"postpublish": "yarn run ts:clear && ckeditor5-package-tools export-package-as-typescript",
|
"postpublish": "yarn run ts:clear && ckeditor5-package-tools export-package-as-typescript",
|
||||||
|
@ -65,7 +65,7 @@ ClassicEditor
|
|||||||
'undo',
|
'undo',
|
||||||
'redo',
|
'redo',
|
||||||
'|',
|
'|',
|
||||||
'footnotesButton',
|
'footnote',
|
||||||
'|',
|
'|',
|
||||||
'heading',
|
'heading',
|
||||||
'|',
|
'|',
|
||||||
|
35
src/constants.ts
Normal file
35
src/constants.ts
Normal 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'
|
||||||
|
};
|
121
src/footnote-editing/auto-formatting.ts
Normal file
121
src/footnote-editing/auto-formatting.ts
Normal 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 )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
382
src/footnote-editing/converters.ts
Normal file
382
src/footnote-editing/converters.ts
Normal 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 );
|
||||||
|
}
|
322
src/footnote-editing/footnote-editing.ts
Normal file
322
src/footnote-editing/footnote-editing.ts
Normal 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 }` );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} );
|
||||||
|
}
|
||||||
|
}
|
70
src/footnote-editing/schema.ts
Normal file
70
src/footnote-editing/schema.ts
Normal 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
116
src/footnote-ui.ts
Normal 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
60
src/footnote.css
Normal 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;
|
||||||
|
}
|
@ -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 {
|
export default class Footnotes extends Plugin {
|
||||||
public static get pluginName() {
|
public static get pluginName() {
|
||||||
return 'Footnotes' as const;
|
return 'Footnotes' as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
public init(): void {
|
public static get requires() {
|
||||||
const editor = this.editor;
|
return [ FootnoteEditing, FootnoteUI ] as const;
|
||||||
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;
|
|
||||||
} );
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
102
src/insert-footnote-command.ts
Normal file
102
src/insert-footnote-command.ts
Normal 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
194
src/utils.ts
Normal 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;
|
||||||
|
};
|
@ -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>' );
|
|
||||||
} );
|
|
||||||
} );
|
|
||||||
} );
|
|
@ -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 );
|
|
||||||
} );
|
|
||||||
} );
|
|
||||||
} );
|
|
@ -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 |
4
theme/icons/insert-footnote.svg
Normal file
4
theme/icons/insert-footnote.svg
Normal 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 |
Loading…
x
Reference in New Issue
Block a user