mirror of
https://github.com/TriliumNext/Notes.git
synced 2025-07-29 11:02:28 +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.
|
||||
|
||||
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
|
||||
==============================
|
||||
|
||||
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).
|
||||
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
@ -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",
|
||||
|
@ -65,7 +65,7 @@ ClassicEditor
|
||||
'undo',
|
||||
'redo',
|
||||
'|',
|
||||
'footnotesButton',
|
||||
'footnote',
|
||||
'|',
|
||||
'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 {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
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