Browse Source

intital commit

main
astanley 1 day ago
commit
e4ea789e9d
  1. 9
      bibcite_footnotes_2.ckeditor5.plugin.yml
  2. 17
      bibcite_footnotes_2.ckeditor5.yml
  3. 7
      bibcite_footnotes_2.info.yml
  4. 19
      bibcite_footnotes_2.libraries.yml
  5. 5
      css/footnote_picker.admin.css
  6. 16
      css/footnote_picker.dialog.css
  7. 21
      css/reference-footnotes.css
  8. 3
      icons/citation.svg
  9. 9
      icons/footnotes.svg
  10. 4
      js/ckeditor5/reference-footnotes-icon.svg
  11. 198
      js/ckeditor5/reference-footnotes.js
  12. 259
      js/ckeditor5_plugins/footnotepicker2/build/footnotepicker2.js
  13. 32
      js/ckeditor5_plugins/footnotepicker2/src/plugin.js
  14. 19
      src/Plugin/CKEditor5Plugin/ReferenceFootnotes.php

9
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'

17
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: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path d="M6.5 4.5c-.6 0-1 .4-1 1v4.2c0 .6.4 1 1 1h.8v1.7H6.2c-.6 0-1 .4-1 1v.4h3.3c.6 0 1-.4 1-1V10c0-.5-.3-.9-.7-1.1.4-.3.7-.7.7-1.3V5.5c0-.6-.4-1-1-1H6.5z"/><path d="M11 6h7v1.4h-7V6zm0 3h7v1.4h-7V9zm0 3h5v1.4h-5V12z"/></svg>'
elements:
- <fn>

7
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

19
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

5
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");
}

16
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; }

21
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. */

3
icons/citation.svg

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path d="M7.8 6.6c0-1.1-.9-2-2-2H4.2c-1.1 0-2 .9-2 2v2.3c0 1.1.9 2 2 2h1.1v1.7H3.7c-1 0-1.7.8-1.7 1.7v.3h4.3c1 0 1.7-.8 1.7-1.7V10c0-.7-.4-1.3-1-1.6.5-.4.8-1 .8-1.8V6.6zm10 0c0-1.1-.9-2-2-2h-1.6c-1.1 0-2 .9-2 2v2.3c0 1.1.9 2 2 2h1.1v1.7h-1.7c-1 0-1.7.8-1.7 1.7v.3h4.3c1 0 1.7-.8 1.7-1.7V10c0-.7-.4-1.3-1-1.6.5-.4.8-1 .8-1.8V6.6z"/>
</svg>

After

Width:  |  Height:  |  Size: 402 B

9
icons/footnotes.svg

@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor">
<!-- Footnote marker -->
<path d="M6.5 4.5c-.6 0-1 .4-1 1v4.2c0 .6.4 1 1 1h.8v1.7H6.2c-.6 0-1 .4-1 1v.4h3.3c.6 0 1-.4 1-1V10c0-.5-.3-.9-.7-1.1.4-.3.7-.7.7-1.3V5.5c0-.6-.4-1-1-1H6.5z"/>
<!-- Text lines representing notes -->
<path d="M11 6h7v1.4h-7V6zm0 3h7v1.4h-7V9zm0 3h5v1.4h-5V12z"/>
</svg>

After

Width:  |  Height:  |  Size: 394 B

4
js/ckeditor5/reference-footnotes-icon.svg

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
<rect x="1" y="1" width="14" height="14" rx="2" ry="2" fill="#ffffff" stroke="#000000" stroke-width="1"/>
<text x="8" y="11" text-anchor="middle" font-size="9" font-family="sans-serif" fill="#000000">fn</text>
</svg>

After

Width:  |  Height:  |  Size: 282 B

198
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 <fn> element.
* - Double-click editing of existing <fn> 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: <fn value="" page="" reference="">text</fn>
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 <fn ...>TEXT</fn> 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 <fn> 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 <fn> 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 <fn> 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 <fn> 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);
}
});
}
}

259
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: <fn reference="..." page="..."></fn>
*
* 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: <strong>, <em>, <a href="..."> 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 = $('<div class="reference-footnotes-dialog"></div>');
// Row 1: reference selector
$wrapper.append('<div class="rf-row rf-row--ref"><label class="rf-label"><strong>Reference Footnote item:</strong></label></div>');
const $ref = $(`
<select class="form-select rf-select">
<option value="">- None -</option>
<!-- TODO: populate dynamically / autocomplete later -->
</select>
`);
$wrapper.find('.rf-row--ref').append($ref);
// Row 2: free-form textarea
$wrapper.append('<div class="rf-row rf-row--or"><div class="rf-or"><strong>Or add free-form footnote text :</strong></div></div>');
const $text = $('<textarea class="form-textarea rf-textarea" rows="6"></textarea>');
$wrapper.append($('<div class="rf-row rf-row--text"></div>').append($text));
// Helper line
$wrapper.append('<div class="rf-help">HTML tags can be used, e.g., &lt;strong&gt;, &lt;em&gt;, &lt;a href="..."&gt;</div>');
// Row 3: pages + value side-by-side
const $grid = $('<div class="rf-grid"></div>');
const $pageWrap = $(`
<div class="rf-field">
<label class="rf-label">Page(s):</label>
</div>
`);
const $page = $('<input type="text" class="form-text rf-input" />');
$pageWrap.append($page);
const $valueWrap = $(`
<div class="rf-field">
<label class="rf-label">Value :</label>
</div>
`);
const $value = $('<input type="text" class="form-text rf-input rf-input--value" />');
$valueWrap.append($value);
$grid.append($pageWrap, $valueWrap);
$wrapper.append($grid);
// Hint line
$wrapper.append('<div class="rf-hint">Leave blank for an automatic sequential reference number, or enter a custom footnote value</div>');
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 = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path d="M7.8 6.6c0-1.1-.9-2-2-2H4.2c-1.1 0-2 .9-2 2v2.3c0 1.1.9 2 2 2h1.1v1.7H3.7c-1 0-1.7.8-1.7 1.7v.3h4.3c1 0 1.7-.8 1.7-1.7V10c0-.7-.4-1.3-1-1.6.5-.4.8-1 .8-1.8V6.6zm10 0c0-1.1-.9-2-2-2h-1.6c-1.1 0-2 .9-2 2v2.3c0 1.1.9 2 2 2h1.1v1.7h-1.7c-1 0-1.7.8-1.7 1.7v.3h4.3c1 0 1.7-.8 1.7-1.7V10c0-.7-.4-1.3-1-1.6.5-.4.8-1 .8-1.8V6.6z"/>
</svg>
`;
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, '&quot;') + '"');
if (page) attrs.push('page="' + page.replace(/"/g, '&quot;') + '"');
if (value) attrs.push('value="' + value.replace(/"/g, '&quot;') + '"');
const inner = footnoteText ? sanitizeFootnoteHtml(footnoteText) : '';
const html = '<fn' + (attrs.length ? ' ' + attrs.join(' ') : '') + '>' + inner + '</fn>';
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 <fn> got stripped, modelFragment will be empty.
if (!modelFragment || modelFragment.childCount === 0) {
console.warn('[footnotes] <fn> 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;
})();

32
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;
});
}
}

19
src/Plugin/CKEditor5Plugin/ReferenceFootnotes.php

@ -0,0 +1,19 @@
<?php
namespace Drupal\bibcite_footnotes_2\Plugin\CKEditor5Plugin;
use Drupal\ckeditor5\Plugin\CKEditor5PluginDefault;
use Drupal\Core\StringTranslation\TranslatableMarkup;
#[CKEditor5Plugin(
id: 'reference_footnotes',
label: new TranslatableMarkup('Reference Footnotes'),
ckeditor5: 'reference_footnotes',
library: 'bibcite_footnotes_2/reference_footnotes',
elements: [
'<fn value page reference>',
],
)]
class ReferenceFootnotes extends CKEditor5PluginDefault {
}
Loading…
Cancel
Save