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