diff --git a/.gitignore b/.gitignore index 437dda1d0..881847f13 100644 --- a/.gitignore +++ b/.gitignore @@ -6,5 +6,3 @@ tmp/ sample/ckeditor.dist.js # Ignore compiled TypeScript files. -src/**/*.js -src/**/*.d.ts diff --git a/package.json b/package.json index cabfc2584..0fbe6b60e 100644 --- a/package.json +++ b/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", diff --git a/src/augmentation.js b/src/augmentation.js new file mode 100644 index 000000000..134d8d9d6 --- /dev/null +++ b/src/augmentation.js @@ -0,0 +1,2 @@ +export {}; +//# sourceMappingURL=augmentation.js.map \ No newline at end of file diff --git a/src/constants.js b/src/constants.js new file mode 100644 index 000000000..dd24ddc32 --- /dev/null +++ b/src/constants.js @@ -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 \ No newline at end of file diff --git a/src/footnote-editing/auto-formatting.js b/src/footnote-editing/auto-formatting.js new file mode 100644 index 000000000..e1753294c --- /dev/null +++ b/src/footnote-editing/auto-formatting.js @@ -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 \ No newline at end of file diff --git a/src/footnote-editing/converters.js b/src/footnote-editing/converters.js new file mode 100644 index 000000000..0650150b1 --- /dev/null +++ b/src/footnote-editing/converters.js @@ -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 \ No newline at end of file diff --git a/src/footnote-editing/footnote-editing.js b/src/footnote-editing/footnote-editing.js new file mode 100644 index 000000000..e4b7924a2 --- /dev/null +++ b/src/footnote-editing/footnote-editing.js @@ -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 \ No newline at end of file diff --git a/src/footnote-editing/schema.js b/src/footnote-editing/schema.js new file mode 100644 index 000000000..b0c13e47f --- /dev/null +++ b/src/footnote-editing/schema.js @@ -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 \ No newline at end of file diff --git a/src/footnote-ui.js b/src/footnote-ui.js new file mode 100644 index 000000000..b03605b4a --- /dev/null +++ b/src/footnote-ui.js @@ -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 \ No newline at end of file diff --git a/src/footnotes.js b/src/footnotes.js new file mode 100644 index 000000000..e0cb959d5 --- /dev/null +++ b/src/footnotes.js @@ -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 \ No newline at end of file diff --git a/src/index.js b/src/index.js new file mode 100644 index 000000000..5a7aecc75 --- /dev/null +++ b/src/index.js @@ -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 \ No newline at end of file diff --git a/src/insert-footnote-command.js b/src/insert-footnote-command.js new file mode 100644 index 000000000..9aa0b0b3b --- /dev/null +++ b/src/insert-footnote-command.js @@ -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 \ No newline at end of file diff --git a/src/utils.js b/src/utils.js new file mode 100644 index 000000000..9c4282a34 --- /dev/null +++ b/src/utils.js @@ -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 \ No newline at end of file