commit e4ea789e9dd9da3fc0a91798972df32680590458 Author: astanley Date: Mon Feb 2 15:08:05 2026 -0400 intital commit diff --git a/bibcite_footnotes_2.ckeditor5.plugin.yml b/bibcite_footnotes_2.ckeditor5.plugin.yml new file mode 100644 index 0000000..32a11b3 --- /dev/null +++ b/bibcite_footnotes_2.ckeditor5.plugin.yml @@ -0,0 +1,9 @@ +reference_footnotes: + ckeditor5: + plugins: + - ReferenceFootnotes + drupal: + label: 'Reference Footnotes' + toolbar_items: + referenceFootnotes: + label: 'Reference Footnotes' diff --git a/bibcite_footnotes_2.ckeditor5.yml b/bibcite_footnotes_2.ckeditor5.yml new file mode 100644 index 0000000..a3674da --- /dev/null +++ b/bibcite_footnotes_2.ckeditor5.yml @@ -0,0 +1,17 @@ +bibcite_footnotes_2_footnote_picker: + ckeditor5: + plugins: + - footnotepicker2.FootnotePicker2 + + drupal: + label: 'Footnote/Citation (Test)' + library: bibcite_footnotes_2/footnote_picker + admin_library: bibcite_footnotes_2/footnote_picker + + toolbar_items: + footnotePicker2: + label: 'Footnoe' + icon: '' + + elements: + - diff --git a/bibcite_footnotes_2.info.yml b/bibcite_footnotes_2.info.yml new file mode 100644 index 0000000..0b67b5f --- /dev/null +++ b/bibcite_footnotes_2.info.yml @@ -0,0 +1,7 @@ +name: 'Bibcite Footnotes CKEditor 5' +type: module +description: 'Provides a CKEditor 5 plugin for inserting and editing reference footnotes.' +package: 'Custom' +core_version_requirement: '^10.2 || ^11' +dependencies: + - drupal:ckeditor5 diff --git a/bibcite_footnotes_2.libraries.yml b/bibcite_footnotes_2.libraries.yml new file mode 100644 index 0000000..311dfaf --- /dev/null +++ b/bibcite_footnotes_2.libraries.yml @@ -0,0 +1,19 @@ +reference_footnotes: + js: + js/ckeditor5/reference-footnotes.js: { type: module } + css: + theme: + css/reference-footnotes.css: {} + +footnote_picker: + css: + theme: + css/footnote_picker.admin.css: { } + css/footnote_picker.dialog.css: {} + js: + js/ckeditor5_plugins/footnotepicker2/build/footnotepicker2.js: {} + dependencies: + - core/ckeditor5 + - core/drupal + - core/drupal.dialog + - core/jquery diff --git a/css/footnote_picker.admin.css b/css/footnote_picker.admin.css new file mode 100644 index 0000000..5fda10e --- /dev/null +++ b/css/footnote_picker.admin.css @@ -0,0 +1,5 @@ +/* Admin UI icon for the toolbar configurator. */ +.ckeditor5-toolbar-button-footnotePicker2 { + background-image: url("../icons/footnotes.svg"); +} + diff --git a/css/footnote_picker.dialog.css b/css/footnote_picker.dialog.css new file mode 100644 index 0000000..a699d40 --- /dev/null +++ b/css/footnote_picker.dialog.css @@ -0,0 +1,16 @@ +.reference-footnotes-dialog { padding-top: 4px; } +.reference-footnotes-dialog .rf-row { margin: 10px 0; } +.reference-footnotes-dialog .rf-select { min-width: 140px; } +.reference-footnotes-dialog .rf-textarea { width: 100%; } +.reference-footnotes-dialog .rf-help { margin: 10px 0 18px; } +.reference-footnotes-dialog .rf-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 40px; + align-items: start; + margin-top: 6px; +} +.reference-footnotes-dialog .rf-field .rf-label { display: block; margin-bottom: 6px; } +.reference-footnotes-dialog .rf-input { width: 100%; max-width: 520px; } +.reference-footnotes-dialog .rf-input--value { max-width: 220px; } +.reference-footnotes-dialog .rf-hint { margin-top: 14px; } diff --git a/css/reference-footnotes.css b/css/reference-footnotes.css new file mode 100644 index 0000000..6267fbe --- /dev/null +++ b/css/reference-footnotes.css @@ -0,0 +1,21 @@ +/** + * Styling for the inline reference footnote widget in CKEditor 5. + * + * This mimics a "fake object" icon similar to CKEditor 4. + */ +span.reference-footnote { + display: inline-block; + width: 16px; + height: 16px; + vertical-align: middle; + background: no-repeat center center; + background-size: 16px 16px; + border: 1px solid #ccc; + border-radius: 2px; + padding: 0; + margin: 0 2px; + box-sizing: border-box; +} + +/* You can optionally point this to a PNG if you prefer. + For now, this is intended to be used with the SVG icon in toolbar only. */ diff --git a/icons/citation.svg b/icons/citation.svg new file mode 100644 index 0000000..1546ffc --- /dev/null +++ b/icons/citation.svg @@ -0,0 +1,3 @@ + + + diff --git a/icons/footnotes.svg b/icons/footnotes.svg new file mode 100644 index 0000000..3f2ec27 --- /dev/null +++ b/icons/footnotes.svg @@ -0,0 +1,9 @@ + + + + + + + diff --git a/js/ckeditor5/reference-footnotes-icon.svg b/js/ckeditor5/reference-footnotes-icon.svg new file mode 100644 index 0000000..48f2f3c --- /dev/null +++ b/js/ckeditor5/reference-footnotes-icon.svg @@ -0,0 +1,4 @@ + + + fn + diff --git a/js/ckeditor5/reference-footnotes.js b/js/ckeditor5/reference-footnotes.js new file mode 100644 index 0000000..73af071 --- /dev/null +++ b/js/ckeditor5/reference-footnotes.js @@ -0,0 +1,198 @@ +import { Plugin } from 'ckeditor5/src/core'; +import { ButtonView } from 'ckeditor5/src/ui'; +import { Widget, toWidget } from 'ckeditor5/src/widget'; +import icon from './reference-footnotes-icon.svg'; + +/** + * ReferenceFootnotes CKEditor 5 plugin. + * + * Provides: + * - A toolbar button that inserts a element. + * - Double-click editing of existing elements. + * - Simple browser-prompt based dialog for editing attributes. + * + * This keeps the implementation simple and reliable in Drupal's environment. + * If you later want a full ContextualBalloon-based dialog, this is the place + * to extend. + */ +export default class ReferenceFootnotes extends Plugin { + static get requires() { + return [ Widget ]; + } + + static get pluginName() { + return 'ReferenceFootnotes'; + } + + init() { + const editor = this.editor; + + // --- SCHEMA ------------------------------------------------- + // Model element: text + editor.model.schema.register('fn', { + allowWhere: '$text', + allowContentOf: '$text', + isInline: true, + isObject: true, + allowAttributes: [ 'value', 'page', 'reference' ] + }); + + // --- MODEL → VIEW (editing) -------------------------------- + // Show an inline widget with a class that you can style with an icon. + editor.conversion.for('editingDowncast').elementToElement({ + model: 'fn', + view: (modelElement, { writer }) => { + const viewElement = writer.createContainerElement('span', { + class: 'reference-footnote', + 'data-value': modelElement.getAttribute('value') || '', + 'data-reference': modelElement.getAttribute('reference') || '', + 'data-page': modelElement.getAttribute('page') || '' + }); + + return toWidget(viewElement, writer, { label: editor.t('Reference footnote') }); + } + }); + + // --- MODEL → VIEW (data/HTML) ------------------------------- + // Persist as TEXT in the saved HTML. + editor.conversion.for('dataDowncast').elementToElement({ + model: 'fn', + view: (modelElement, { writer }) => { + const attrs = { + value: modelElement.getAttribute('value') || '', + reference: modelElement.getAttribute('reference') || '', + page: modelElement.getAttribute('page') || '' + }; + + const fnElement = writer.createContainerElement('fn', attrs); + + // Put inner text into the element. + const firstChild = modelElement.getChild(0); + const textData = firstChild && firstChild.is( 'text' ) ? firstChild.data : ''; + if (textData) { + const textNode = writer.createText(textData); + writer.insert(textNode, fnElement, 0); + } + + return fnElement; + } + }); + + // --- VIEW → MODEL (upcast) --------------------------------- + // Convert tags back into model elements. + editor.conversion.for('upcast').elementToElement({ + view: { + name: 'fn' + }, + model: (viewElement, { writer }) => { + return writer.createElement('fn', { + value: viewElement.getAttribute('value') || '', + reference: viewElement.getAttribute('reference') || '', + page: viewElement.getAttribute('page') || '' + }); + } + }); + + // --- TOOLBAR BUTTON ----------------------------------------- + editor.ui.componentFactory.add('referenceFootnotes', locale => { + const view = new ButtonView(locale); + + view.set({ + label: editor.t('Add reference footnote'), + icon, + tooltip: true + }); + + view.on('execute', () => { + this.openFootnoteDialog(null); + }); + + return view; + }); + + // --- DOUBLE CLICK HANDLER ----------------------------------- + editor.editing.view.document.on('dblclick', (evt, data) => { + const viewElement = data.target; + const modelElement = editor.editing.mapper.toModelElement(viewElement); + + if (modelElement && modelElement.name === 'fn') { + this.openFootnoteDialog(modelElement); + } + }); + } + + /** + * Opens a simple "dialog" (browser prompts) to create or edit a footnote. + * + * @param {?module:engine/model/element~Element} existingFn + * The existing model element, or null for a new one. + */ + openFootnoteDialog(existingFn) { + const editor = this.editor; + + // --- Read existing values (if editing) ---------------------- + let currentText = ''; + let currentValue = ''; + let currentReference = ''; + let currentPage = ''; + + if (existingFn) { + const firstChild = existingFn.getChild(0); + currentText = firstChild && firstChild.is('text') ? firstChild.data : ''; + + currentValue = existingFn.getAttribute('value') || ''; + currentReference = existingFn.getAttribute('reference') || ''; + currentPage = existingFn.getAttribute('page') || ''; + } + + // --- Simple prompts for now -------------------------------- + const text = window.prompt(editor.t('Footnote text:'), currentText || ''); + if (text === null) { + return; // user cancelled + } + + const value = window.prompt(editor.t('Footnote value (e.g. 1, 2, 3):'), currentValue || ''); + if (value === null) { + return; + } + + const reference = window.prompt(editor.t('Reference (optional):'), currentReference || ''); + if (reference === null) { + return; + } + + const page = window.prompt(editor.t('Page (optional):'), currentPage || ''); + if (page === null) { + return; + } + + // --- Write changes to the model ----------------------------- + editor.model.change(writer => { + if (!existingFn) { + // Insert a brand new element at the selection. + const fnElement = writer.createElement('fn', { + value: value, + reference: reference, + page: page + }); + + // Insert the element and then its visible text. + editor.model.insertObject(fnElement, editor.model.document.selection); + writer.insertText(text, fnElement, 0); + } + else { + // Update attributes. + writer.setAttribute('value', value, existingFn); + writer.setAttribute('reference', reference, existingFn); + writer.setAttribute('page', page, existingFn); + + // Replace inner text. + const children = Array.from(existingFn.getChildren()); + for (const child of children) { + writer.remove(child); + } + writer.insertText(text, existingFn, 0); + } + }); + } +} diff --git a/js/ckeditor5_plugins/footnotepicker2/build/footnotepicker2.js b/js/ckeditor5_plugins/footnotepicker2/build/footnotepicker2.js new file mode 100644 index 0000000..46e0921 --- /dev/null +++ b/js/ckeditor5_plugins/footnotepicker2/build/footnotepicker2.js @@ -0,0 +1,259 @@ +/** + * Prebuilt CKEditor 5 plugin bundle for Drupal (sandbox quality). + * + * Provides a toolbar button "Footnote" that opens a Drupal dialog asking for: + * - Reference ID / key (free text for now) + * - Optional page + * Then inserts: + * + * Replace this with a proper build + entity autocomplete later. + */ +(function () { + const w = window; + + // Guard: CKEditor5 globals must exist. + w.CKEditor5 = w.CKEditor5 || {}; + w.CKEditor5.footnotepicker2 = w.CKEditor5.footnotepicker2 || {}; + + const Plugin = (w.CKEditor5 && w.CKEditor5.core && w.CKEditor5.core.Plugin) || null; + const ButtonView = (w.CKEditor5 && w.CKEditor5.ui && w.CKEditor5.ui.ButtonView) || null; + + + if (!Plugin || !ButtonView) { + // eslint-disable-next-line no-console + console.warn('[bibcite_footnotes_2] CKEditor5 globals not found; FootnotePicker2 not registered.'); + return; + } + // Sanitize ONLY the footnote body (not the article). + // Allows: , , and plain text. + function sanitizeFootnoteHtml(input) { + if (!input) return ''; + + const allowedTags = new Set(['STRONG', 'EM', 'A']); + const allowedAttrs = { + A: new Set(['href', 'title', 'target', 'rel']), + }; + + const template = document.createElement('template'); + template.innerHTML = input; + + const cleanNode = (node) => { + // Remove comments. + if (node.nodeType === Node.COMMENT_NODE) { + node.remove(); + return; + } + + // Text nodes are fine. + if (node.nodeType === Node.TEXT_NODE) return; + + // Element nodes. + if (node.nodeType === Node.ELEMENT_NODE) { + // Strip disallowed elements but keep their text content. + if (!allowedTags.has(node.tagName)) { + const textNode = document.createTextNode(node.textContent || ''); + node.replaceWith(textNode); + return; + } + + // Strip disallowed attributes. + [...node.attributes].forEach((attr) => { + const name = attr.name.toLowerCase(); + const ok = allowedAttrs[node.tagName] && allowedAttrs[node.tagName].has(name); + if (!ok) node.removeAttribute(attr.name); + }); + + // Extra link safety. + if (node.tagName === 'A') { + const href = node.getAttribute('href') || ''; + // Allow only http(s), mailto, and relative URLs. + if (!/^(https?:|mailto:|\/)/i.test(href)) { + node.removeAttribute('href'); + } + node.setAttribute('rel', 'noopener noreferrer'); + } + } + + // Recurse. + [...node.childNodes].forEach(cleanNode); + }; + + [...template.content.childNodes].forEach(cleanNode); + return template.innerHTML; + } + + + function openFootnoteDialog(onSubmit) { + const $ = w.jQuery; + const Drupal = w.Drupal; + + // Fallback: prompt() if dialog isn't available. + if (!Drupal || !Drupal.dialog || !$) { + const reference = w.prompt('Reference ID (optional):', '') || ''; + const text = w.prompt('Free-form footnote text (optional):', '') || ''; + const page = w.prompt('Page(s) (optional):', '') || ''; + const value = w.prompt('Value (optional):', '') || ''; + onSubmit({reference: reference.trim(), text: text.trim(), page: page.trim(), value: value.trim()}); + return; + } + + const $wrapper = $('
'); + + // Row 1: reference selector + $wrapper.append('
'); + const $ref = $(` + + `); + $wrapper.find('.rf-row--ref').append($ref); + + // Row 2: free-form textarea + $wrapper.append('
Or add free-form footnote text :
'); + const $text = $(''); + $wrapper.append($('
').append($text)); + + // Helper line + $wrapper.append('
HTML tags can be used, e.g., <strong>, <em>, <a href="...">
'); + + // Row 3: pages + value side-by-side + const $grid = $('
'); + + const $pageWrap = $(` +
+ +
+ `); + const $page = $(''); + $pageWrap.append($page); + + const $valueWrap = $(` +
+ +
+ `); + const $value = $(''); + $valueWrap.append($value); + + $grid.append($pageWrap, $valueWrap); + $wrapper.append($grid); + + // Hint line + $wrapper.append('
Leave blank for an automatic sequential reference number, or enter a custom footnote value
'); + + const dialog = Drupal.dialog($wrapper.get(0), { + title: 'Reference Footnotes Dialog', + width: 900, + buttons: [ + { + text: 'OK', + classes: 'button button--primary', + click: function (event) { + event.preventDefault(); + console.log('[footnotes] OK clicked'); + + const reference = ($ref.val() || '').toString().trim(); + const text = ($text.val() || '').toString().trim(); + const page = ($page.val() || '').toString().trim(); + const value = ($value.val() || '').toString().trim(); + + console.log('[footnotes] values', { reference, text, page, value }); + + if (!reference && !text) { + console.warn('[footnotes] blocked: reference + text are empty'); + $text.trigger('focus'); + return; + } + + // IMPORTANT: do the work first. + console.log('[footnotes] calling onSubmit()'); + onSubmit({ reference, text, page, value }); + + // Then close/destroy, but never let this prevent insertion. + try { dialog.close(); } catch (e) { console.warn('[footnotes] dialog.close failed', e); } + } + + }, + { + text: 'Cancel', + click: function () { + dialog.close(); + dialog.destroy(); + } + } + ] + }); + + dialog.showModal(); + setTimeout(() => $ref.trigger('focus'), 0); + } + + + class FootnotePicker2 extends Plugin { + init() { + const editor = this.editor; + + editor.ui.componentFactory.add('footnotePicker2', (locale) => { + const button = new ButtonView(locale); + + const footnoteIcon = ` + + + +`; + + button.set({ + label: 'Footnote', + icon: footnoteIcon, + tooltip: 'Insert footnote', + withText: false, + }); + + button.on('execute', () => { + openFootnoteDialog(({reference, text: footnoteText, page, value}) => { + + const attrs = []; + if (reference) attrs.push('reference="' + reference.replace(/"/g, '"') + '"'); + if (page) attrs.push('page="' + page.replace(/"/g, '"') + '"'); + if (value) attrs.push('value="' + value.replace(/"/g, '"') + '"'); + + const inner = footnoteText ? sanitizeFootnoteHtml(footnoteText) : ''; + const html = '' + inner + '
'; + + let viewFragment, modelFragment; + + try { + viewFragment = editor.data.processor.toView(html); + modelFragment = editor.data.toModel(viewFragment); + } catch (e) { + console.error('[footnotes] toModel failed', e); + return; + } + + console.log('[footnotes] inserting html:', html); + console.log('[footnotes] modelFragment childCount:', modelFragment ? modelFragment.childCount : null); + + + editor.model.change((writer) => { + // If got stripped, modelFragment will be empty. + if (!modelFragment || modelFragment.childCount === 0) { + console.warn('[footnotes] was stripped; inserting as plain text fallback'); + editor.model.insertContent(writer.createText(html), editor.model.document.selection); + return; + } + + editor.model.insertContent(modelFragment, editor.model.document.selection); + }); + + }); + }); + + + return button; + }); + } + } + + w.CKEditor5.footnotepicker2.FootnotePicker2 = FootnotePicker2; +})(); diff --git a/js/ckeditor5_plugins/footnotepicker2/src/plugin.js b/js/ckeditor5_plugins/footnotepicker2/src/plugin.js new file mode 100644 index 0000000..631f203 --- /dev/null +++ b/js/ckeditor5_plugins/footnotepicker2/src/plugin.js @@ -0,0 +1,32 @@ +import { Plugin } from 'ckeditor5/src/core'; +import { ButtonView } from 'ckeditor5/src/ui'; + +/** + * Minimal citation picker for Drupal CKEditor5 (sandbox). + * + * NOTE: This source file is not used by Drupal unless you build it. + * The build output lives in ../build/footnotepicker2.js + */ +import { Plugin } from 'ckeditor5/src/core'; +import { ButtonView } from 'ckeditor5/src/ui'; + +export default class FootnotePicker2 extends Plugin { + init() { + const editor = this.editor; + + editor.ui.componentFactory.add('footnotePicker2', (locale) => { + const button = new ButtonView(locale); + button.set({ + label: 'Footnote', + withText: true, + tooltip: 'Insert footnote', + }); + + button.on('execute', () => { + // Implemented in the built file for now. + }); + + return button; + }); + } +} diff --git a/src/Plugin/CKEditor5Plugin/ReferenceFootnotes.php b/src/Plugin/CKEditor5Plugin/ReferenceFootnotes.php new file mode 100644 index 0000000..d50695f --- /dev/null +++ b/src/Plugin/CKEditor5Plugin/ReferenceFootnotes.php @@ -0,0 +1,19 @@ +', + ], +)] +class ReferenceFootnotes extends CKEditor5PluginDefault { + +}