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
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., <strong>, <em>, <a href="..."></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, '"') + '"'); |
|
if (page) attrs.push('page="' + page.replace(/"/g, '"') + '"'); |
|
if (value) attrs.push('value="' + value.replace(/"/g, '"') + '"'); |
|
|
|
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; |
|
})();
|
|
|