Bibcite footnotes modified for CKEditor 5
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

259 lines
10 KiB

/**
* 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;
})();