mirror of
https://github.com/zadam/trilium.git
synced 2025-10-26 07:46:30 +01:00
fix(build): adjustments for legacy build
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -6,5 +6,3 @@ tmp/
|
||||
sample/ckeditor.dist.js
|
||||
|
||||
# Ignore compiled TypeScript files.
|
||||
src/**/*.js
|
||||
src/**/*.d.ts
|
||||
|
||||
17
package.json
17
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@triliumnext/ckeditor5-footnotes",
|
||||
"version": "0.0.4-hotfix7",
|
||||
"version": "0.0.4-hotfix8",
|
||||
"description": "A plugin for CKEditor 5 to allow footnotes.",
|
||||
"keywords": [
|
||||
"ckeditor",
|
||||
@@ -11,18 +11,9 @@
|
||||
"ckeditor5-package-generator"
|
||||
],
|
||||
"type": "module",
|
||||
"main": "dist/index.ts",
|
||||
"module": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./*": "./dist/*",
|
||||
"./browser/*": null,
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"main": "src/index.ts",
|
||||
"module": "src/index.js",
|
||||
"types": "src/index.d.ts",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=18.0.0",
|
||||
|
||||
2
src/augmentation.js
Normal file
2
src/augmentation.js
Normal file
@@ -0,0 +1,2 @@
|
||||
export {};
|
||||
//# sourceMappingURL=augmentation.js.map
|
||||
32
src/constants.js
Normal file
32
src/constants.js
Normal file
@@ -0,0 +1,32 @@
|
||||
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',
|
||||
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'
|
||||
};
|
||||
//# sourceMappingURL=constants.js.map
|
||||
101
src/footnote-editing/auto-formatting.js
Normal file
101
src/footnote-editing/auto-formatting.js
Normal file
@@ -0,0 +1,101 @@
|
||||
import { Text, TextProxy } from 'ckeditor5/src/engine.js';
|
||||
import { inlineAutoformatEditing } from "@ckeditor/ckeditor5-autoformat";
|
||||
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, text) => {
|
||||
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, editor, rootElement) => {
|
||||
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, rootElement) => {
|
||||
if (editor.plugins.has('Autoformat')) {
|
||||
const autoformatPluginInstance = editor.plugins.get('Autoformat');
|
||||
inlineAutoformatEditing(editor, autoformatPluginInstance, text => regexMatchCallback(editor, text), (_, ranges) => formatCallback(ranges, editor, rootElement));
|
||||
}
|
||||
};
|
||||
//# sourceMappingURL=auto-formatting.js.map
|
||||
296
src/footnote-editing/converters.js
Normal file
296
src/footnote-editing/converters.js
Normal file
@@ -0,0 +1,296 @@
|
||||
import { Element } from "ckeditor5/src/engine.js";
|
||||
import { toWidget, toWidgetEditable } from 'ckeditor5/src/widget.js';
|
||||
import { ATTRIBUTES, CLASSES, ELEMENTS } from '../constants.js';
|
||||
import { viewQueryElement } from '../utils.js';
|
||||
/**
|
||||
* Defines methods for converting between model, data view, and editing view representations of each element type.
|
||||
*/
|
||||
export const defineConverters = (editor) => {
|
||||
const conversion = editor.conversion;
|
||||
/** *********************************Attribute Conversion************************************/
|
||||
conversion.for('downcast').attributeToAttribute({
|
||||
model: ATTRIBUTES.footnoteId,
|
||||
view: ATTRIBUTES.footnoteId
|
||||
});
|
||||
conversion.for('downcast').attributeToAttribute({
|
||||
model: ATTRIBUTES.footnoteIndex,
|
||||
view: ATTRIBUTES.footnoteIndex
|
||||
});
|
||||
/** *********************************Footnote Section Conversion************************************/
|
||||
// ((data) view → model)
|
||||
conversion.for('upcast').elementToElement({
|
||||
view: {
|
||||
attributes: {
|
||||
[ATTRIBUTES.footnoteSection]: true
|
||||
}
|
||||
},
|
||||
model: ELEMENTS.footnoteSection,
|
||||
converterPriority: 'high'
|
||||
});
|
||||
// (model → data view)
|
||||
conversion.for('dataDowncast').elementToElement({
|
||||
model: ELEMENTS.footnoteSection,
|
||||
view: {
|
||||
name: 'ol',
|
||||
attributes: {
|
||||
[ATTRIBUTES.footnoteSection]: '',
|
||||
role: 'doc-endnotes'
|
||||
},
|
||||
classes: [CLASSES.footnoteSection, CLASSES.footnotes]
|
||||
}
|
||||
});
|
||||
// (model → editing view)
|
||||
conversion.for('editingDowncast').elementToElement({
|
||||
model: ELEMENTS.footnoteSection,
|
||||
view: (_, conversionApi) => {
|
||||
const viewWriter = conversionApi.writer;
|
||||
// eslint-disable-next-line max-len
|
||||
/** The below is a div rather than an ol because using an ol here caused weird behavior, including randomly duplicating the footnotes section.
|
||||
* This is techincally invalid HTML, but it's valid in the data view (that is, the version shown in the post). I've added role='list'
|
||||
* as a next-best option, in accordance with ARIA recommendations.
|
||||
*/
|
||||
const section = viewWriter.createContainerElement('div', {
|
||||
[ATTRIBUTES.footnoteSection]: '',
|
||||
role: 'doc-endnotes list',
|
||||
class: CLASSES.footnoteSection
|
||||
});
|
||||
return toWidget(section, viewWriter, { label: 'footnote widget' });
|
||||
}
|
||||
});
|
||||
/** *********************************Footnote Content Conversion************************************/
|
||||
conversion.for('upcast').elementToElement({
|
||||
view: {
|
||||
attributes: {
|
||||
[ATTRIBUTES.footnoteContent]: true
|
||||
}
|
||||
},
|
||||
model: (viewElement, conversionApi) => {
|
||||
const modelWriter = conversionApi.writer;
|
||||
return modelWriter.createElement(ELEMENTS.footnoteContent);
|
||||
}
|
||||
});
|
||||
conversion.for('dataDowncast').elementToElement({
|
||||
model: ELEMENTS.footnoteContent,
|
||||
view: {
|
||||
name: 'div',
|
||||
attributes: { [ATTRIBUTES.footnoteContent]: '' },
|
||||
classes: [CLASSES.footnoteContent]
|
||||
}
|
||||
});
|
||||
conversion.for('editingDowncast').elementToElement({
|
||||
model: ELEMENTS.footnoteContent,
|
||||
view: (_, conversionApi) => {
|
||||
const viewWriter = conversionApi.writer;
|
||||
// Note: You use a more specialized createEditableElement() method here.
|
||||
const section = viewWriter.createEditableElement('div', {
|
||||
[ATTRIBUTES.footnoteContent]: '',
|
||||
class: CLASSES.footnoteContent
|
||||
});
|
||||
return toWidgetEditable(section, viewWriter);
|
||||
}
|
||||
});
|
||||
/** *********************************Footnote Item Conversion************************************/
|
||||
conversion.for('upcast').elementToElement({
|
||||
view: {
|
||||
attributes: {
|
||||
[ATTRIBUTES.footnoteItem]: true
|
||||
}
|
||||
},
|
||||
model: (viewElement, conversionApi) => {
|
||||
const modelWriter = conversionApi.writer;
|
||||
const id = viewElement.getAttribute(ATTRIBUTES.footnoteId);
|
||||
const index = viewElement.getAttribute(ATTRIBUTES.footnoteIndex);
|
||||
if (id === undefined || index === undefined) {
|
||||
return null;
|
||||
}
|
||||
return modelWriter.createElement(ELEMENTS.footnoteItem, {
|
||||
[ATTRIBUTES.footnoteIndex]: index,
|
||||
[ATTRIBUTES.footnoteId]: id
|
||||
});
|
||||
},
|
||||
/** converterPriority is needed to supersede the builtin upcastListItemStyle
|
||||
* which for unknown reasons causes a null reference error.
|
||||
*/
|
||||
converterPriority: 'high'
|
||||
});
|
||||
conversion.for('dataDowncast').elementToElement({
|
||||
model: ELEMENTS.footnoteItem,
|
||||
view: createFootnoteItemViewElement
|
||||
});
|
||||
conversion.for('editingDowncast').elementToElement({
|
||||
model: ELEMENTS.footnoteItem,
|
||||
view: createFootnoteItemViewElement
|
||||
});
|
||||
/** *********************************Footnote Reference Conversion************************************/
|
||||
conversion.for('upcast').elementToElement({
|
||||
view: {
|
||||
attributes: {
|
||||
[ATTRIBUTES.footnoteReference]: true
|
||||
}
|
||||
},
|
||||
model: (viewElement, conversionApi) => {
|
||||
const modelWriter = conversionApi.writer;
|
||||
const index = viewElement.getAttribute(ATTRIBUTES.footnoteIndex);
|
||||
const id = viewElement.getAttribute(ATTRIBUTES.footnoteId);
|
||||
if (index === undefined || id === undefined) {
|
||||
return null;
|
||||
}
|
||||
return modelWriter.createElement(ELEMENTS.footnoteReference, {
|
||||
[ATTRIBUTES.footnoteIndex]: index,
|
||||
[ATTRIBUTES.footnoteId]: id
|
||||
});
|
||||
}
|
||||
});
|
||||
conversion.for('editingDowncast').elementToElement({
|
||||
model: ELEMENTS.footnoteReference,
|
||||
view: (modelElement, conversionApi) => {
|
||||
const viewWriter = conversionApi.writer;
|
||||
const footnoteReferenceViewElement = createFootnoteReferenceViewElement(modelElement, conversionApi);
|
||||
return toWidget(footnoteReferenceViewElement, viewWriter);
|
||||
}
|
||||
});
|
||||
conversion.for('dataDowncast').elementToElement({
|
||||
model: ELEMENTS.footnoteReference,
|
||||
view: createFootnoteReferenceViewElement
|
||||
});
|
||||
/** This is an event listener for changes to the `data-footnote-index` attribute on `footnoteReference` elements.
|
||||
* When that event fires, the callback function below updates the displayed view of the footnote reference in the
|
||||
* editor to match the new index.
|
||||
*/
|
||||
conversion.for('editingDowncast').add(dispatcher => {
|
||||
dispatcher.on(`attribute:${ATTRIBUTES.footnoteIndex}:${ELEMENTS.footnoteReference}`, (_, data, conversionApi) => updateFootnoteReferenceView(data, conversionApi, editor), { priority: 'high' });
|
||||
});
|
||||
/** *********************************Footnote Back Link Conversion************************************/
|
||||
conversion.for('upcast').elementToElement({
|
||||
view: {
|
||||
attributes: {
|
||||
[ATTRIBUTES.footnoteBackLink]: true
|
||||
}
|
||||
},
|
||||
model: (viewElement, conversionApi) => {
|
||||
const modelWriter = conversionApi.writer;
|
||||
const id = viewElement.getAttribute(ATTRIBUTES.footnoteId);
|
||||
if (id === undefined) {
|
||||
return null;
|
||||
}
|
||||
return modelWriter.createElement(ELEMENTS.footnoteBackLink, {
|
||||
[ATTRIBUTES.footnoteId]: id
|
||||
});
|
||||
}
|
||||
});
|
||||
conversion.for('dataDowncast').elementToElement({
|
||||
model: ELEMENTS.footnoteBackLink,
|
||||
view: createFootnoteBackLinkViewElement
|
||||
});
|
||||
conversion.for('editingDowncast').elementToElement({
|
||||
model: ELEMENTS.footnoteBackLink,
|
||||
view: createFootnoteBackLinkViewElement
|
||||
});
|
||||
};
|
||||
/**
|
||||
* Creates and returns a view element for a footnote backlink,
|
||||
* which navigates back to the inline reference in the text. Used
|
||||
* for both data and editing downcasts.
|
||||
*/
|
||||
function createFootnoteBackLinkViewElement(modelElement, conversionApi) {
|
||||
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, conversionApi) {
|
||||
const viewWriter = conversionApi.writer;
|
||||
const index = `${modelElement.getAttribute(ATTRIBUTES.footnoteIndex)}`;
|
||||
const id = `${modelElement.getAttribute(ATTRIBUTES.footnoteId)}`;
|
||||
if (index === 'undefined') {
|
||||
throw new Error('Footnote reference has no provided index.');
|
||||
}
|
||||
if (id === 'undefined') {
|
||||
throw new Error('Footnote reference has no provided id.');
|
||||
}
|
||||
const footnoteReferenceView = viewWriter.createContainerElement('span', {
|
||||
class: CLASSES.footnoteReference,
|
||||
[ATTRIBUTES.footnoteReference]: '',
|
||||
[ATTRIBUTES.footnoteIndex]: index,
|
||||
[ATTRIBUTES.footnoteId]: id,
|
||||
role: 'doc-noteref',
|
||||
id: `fnref${id}`
|
||||
});
|
||||
const innerText = viewWriter.createText(`[${index}]`);
|
||||
const link = viewWriter.createContainerElement('a', { href: `#fn${id}` });
|
||||
const superscript = viewWriter.createContainerElement('sup');
|
||||
viewWriter.insert(viewWriter.createPositionAt(link, 0), innerText);
|
||||
viewWriter.insert(viewWriter.createPositionAt(superscript, 0), link);
|
||||
viewWriter.insert(viewWriter.createPositionAt(footnoteReferenceView, 0), superscript);
|
||||
return footnoteReferenceView;
|
||||
}
|
||||
/**
|
||||
* Creates and returns a view element for an inline footnote reference. Used for both
|
||||
* data downcast and editing downcast conversions.
|
||||
*/
|
||||
function createFootnoteItemViewElement(modelElement, conversionApi) {
|
||||
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, conversionApi, 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 anchor = viewQueryElement(editor, footnoteReferenceView, element => element.name === 'a');
|
||||
const textNode = anchor === null || anchor === void 0 ? void 0 : anchor.getChild(0);
|
||||
if (!textNode || !anchor) {
|
||||
viewWriter.remove(footnoteReferenceView);
|
||||
return;
|
||||
}
|
||||
viewWriter.remove(textNode);
|
||||
const innerText = viewWriter.createText(`[${newIndex}]`);
|
||||
viewWriter.insert(viewWriter.createPositionAt(anchor, 0), innerText);
|
||||
viewWriter.setAttribute('href', `#fn${item.getAttribute(ATTRIBUTES.footnoteId)}`, anchor);
|
||||
viewWriter.setAttribute(ATTRIBUTES.footnoteIndex, newIndex, footnoteReferenceView);
|
||||
}
|
||||
//# sourceMappingURL=converters.js.map
|
||||
252
src/footnote-editing/footnote-editing.js
Normal file
252
src/footnote-editing/footnote-editing.js
Normal file
@@ -0,0 +1,252 @@
|
||||
/**
|
||||
* 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 { Element } from 'ckeditor5/src/engine.js';
|
||||
import { Autoformat } from "@ckeditor/ckeditor5-autoformat";
|
||||
import { Plugin } from "ckeditor5/src/core.js";
|
||||
import { Widget } from 'ckeditor5/src/widget.js';
|
||||
import { viewToModelPositionOutsideModelElement } from '@ckeditor/ckeditor5-widget';
|
||||
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 {
|
||||
static get requires() {
|
||||
return [Widget, Autoformat];
|
||||
}
|
||||
/**
|
||||
* The root element of the document.
|
||||
*/
|
||||
get rootElement() {
|
||||
const rootElement = this.editor.model.document.getRoot();
|
||||
if (!rootElement) {
|
||||
throw new Error('Document has no rootElement element.');
|
||||
}
|
||||
return rootElement;
|
||||
}
|
||||
init() {
|
||||
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 = eventInfo.source;
|
||||
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);
|
||||
}
|
||||
});
|
||||
}, { 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.
|
||||
*/
|
||||
_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.
|
||||
*/
|
||||
_clearContents(modelWriter, footnoteContent) {
|
||||
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.
|
||||
*/
|
||||
_removeFootnote(modelWriter, footnote) {
|
||||
// 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 !== null && index !== void 0 ? 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 !== null && index !== void 0 ? index : 0);
|
||||
for (const [i, child] of subsequentFootnotes.entries()) {
|
||||
modelWriter.setAttribute(ATTRIBUTES.footnoteIndex, `${index !== null && index !== void 0 ? 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.
|
||||
*/
|
||||
_removeReferences(modelWriter, footnoteId = undefined) {
|
||||
const removeList = [];
|
||||
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.
|
||||
*/
|
||||
_updateReferenceIndices(batch, footnoteId, newFootnoteIndex) {
|
||||
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.
|
||||
*/
|
||||
_orderFootnotes(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 => {
|
||||
var _a;
|
||||
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 = `${((_a = footnoteSection === null || footnoteSection === void 0 ? void 0 : footnoteSection.getChildIndex(footnote)) !== null && _a !== void 0 ? _a : -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}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
//# sourceMappingURL=footnote-editing.js.map
|
||||
63
src/footnote-editing/schema.js
Normal file
63
src/footnote-editing/schema.js
Normal file
@@ -0,0 +1,63 @@
|
||||
// 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) => {
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
});
|
||||
};
|
||||
//# sourceMappingURL=schema.js.map
|
||||
84
src/footnote-ui.js
Normal file
84
src/footnote-ui.js
Normal file
@@ -0,0 +1,84 @@
|
||||
import { Plugin } from 'ckeditor5/src/core.js';
|
||||
import { addListToDropdown, createDropdown, ViewModel } from '@ckeditor/ckeditor5-ui';
|
||||
import { Collection } from '@ckeditor/ckeditor5-utils';
|
||||
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 {
|
||||
init() {
|
||||
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) => {
|
||||
var _a, _b, _c;
|
||||
(_a = dropdownView === null || dropdownView === void 0 ? void 0 : dropdownView.listView) === null || _a === void 0 ? void 0 : _a.items.clear();
|
||||
if (newValue) {
|
||||
addListToDropdown(dropdownView, this.getDropdownItemsDefinitions());
|
||||
}
|
||||
else {
|
||||
(_b = dropdownView === null || dropdownView === void 0 ? void 0 : dropdownView.listView) === null || _b === void 0 ? void 0 : _b.items.clear();
|
||||
const listElement = (_c = dropdownView === null || dropdownView === void 0 ? void 0 : dropdownView.listView) === null || _c === void 0 ? void 0 : _c.element;
|
||||
if (listElement && listElement.parentNode) {
|
||||
listElement.parentNode.removeChild(listElement);
|
||||
}
|
||||
}
|
||||
});
|
||||
// Execute the command when the dropdown item is clicked (executed).
|
||||
this.listenTo(dropdownView, 'execute', evt => {
|
||||
editor.execute(COMMANDS.insertFootnote, {
|
||||
footnoteIndex: evt.source.commandParam
|
||||
});
|
||||
editor.editing.view.focus();
|
||||
});
|
||||
return dropdownView;
|
||||
});
|
||||
}
|
||||
getDropdownItemsDefinitions() {
|
||||
const itemDefinitions = new Collection();
|
||||
const defaultDef = {
|
||||
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 = {
|
||||
type: 'button',
|
||||
model: new ViewModel({
|
||||
commandParam: index,
|
||||
label: `Insert footnote ${index}`,
|
||||
withText: true
|
||||
})
|
||||
};
|
||||
itemDefinitions.add(definition);
|
||||
});
|
||||
}
|
||||
return itemDefinitions;
|
||||
}
|
||||
}
|
||||
//# sourceMappingURL=footnote-ui.js.map
|
||||
12
src/footnotes.js
Normal file
12
src/footnotes.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Plugin } from 'ckeditor5/src/core.js';
|
||||
import FootnoteEditing from './footnote-editing/footnote-editing.js';
|
||||
import FootnoteUI from './footnote-ui.js';
|
||||
export default class Footnotes extends Plugin {
|
||||
static get pluginName() {
|
||||
return 'Footnotes';
|
||||
}
|
||||
static get requires() {
|
||||
return [FootnoteEditing, FootnoteUI];
|
||||
}
|
||||
}
|
||||
//# sourceMappingURL=footnotes.js.map
|
||||
7
src/index.js
Normal file
7
src/index.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import insertFootnoteIcon from './../theme/icons/insert-footnote.svg';
|
||||
import './augmentation.js';
|
||||
export { default as Footnotes } from './footnotes.js';
|
||||
export const icons = {
|
||||
insertFootnoteIcon
|
||||
};
|
||||
//# sourceMappingURL=index.js.map
|
||||
82
src/insert-footnote-command.js
Normal file
82
src/insert-footnote-command.js
Normal file
@@ -0,0 +1,82 @@
|
||||
import { Command } from 'ckeditor5/src/core.js';
|
||||
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.
|
||||
*/
|
||||
execute({ footnoteIndex } = { footnoteIndex: 0 }) {
|
||||
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 = undefined;
|
||||
let id = 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);
|
||||
}
|
||||
}
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
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);
|
||||
this.editor.model.insertContent(footnoteItem, modelWriter.createPositionAt(footnoteSection, footnoteSection.maxOffset));
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Called automatically when changes are applied to the document. Sets `isEnabled`
|
||||
* to determine whether footnote creation is allowed at the current location.
|
||||
*/
|
||||
refresh() {
|
||||
const model = this.editor.model;
|
||||
const lastPosition = model.document.selection.getLastPosition();
|
||||
const allowedIn = lastPosition && model.schema.findAllowedParent(lastPosition, ELEMENTS.footnoteSection);
|
||||
this.isEnabled = allowedIn !== null;
|
||||
}
|
||||
/**
|
||||
* Returns the footnote section if it exists, or creates on if it doesn't.
|
||||
*/
|
||||
_getFootnoteSection(writer, rootElement) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
//# sourceMappingURL=insert-footnote-command.js.map
|
||||
88
src/utils.js
Normal file
88
src/utils.js
Normal file
@@ -0,0 +1,88 @@
|
||||
import { Element, Text, TextProxy, ViewElement } from 'ckeditor5/src/engine.js';
|
||||
// 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, rootElement, predicate = _ => true) => {
|
||||
const range = editor.model.createRangeIn(rootElement);
|
||||
const output = [];
|
||||
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, rootElement, predicate = _ => true) => {
|
||||
const range = editor.model.createRangeIn(rootElement);
|
||||
const output = [];
|
||||
for (const item of range.getItems()) {
|
||||
if (!(item instanceof Text || 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, rootElement, predicate = _ => true) => {
|
||||
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, rootElement, predicate = _ => true) => {
|
||||
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, rootElement, predicate = _ => true) => {
|
||||
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;
|
||||
};
|
||||
//# sourceMappingURL=utils.js.map
|
||||
Reference in New Issue
Block a user