Browse Source

naming convention

main
astanley 4 days ago
parent
commit
3a4827a6c7
  1. 85
      Changelog.txt
  2. 25
      MERGE_NOTES.md
  3. 57
      README.txt
  4. 75
      assets/css/footnotes-alternative_layout.css
  5. 82
      assets/css/footnotes.css
  6. 60
      assets/js/ckeditor/dialogs/footnotes.js
  7. BIN
      assets/js/ckeditor/icons/fn_icon2.png
  8. BIN
      assets/js/ckeditor/icons/footnotes.png
  9. 151
      assets/js/ckeditor/plugin.js
  10. 5
      bibcite_footnotes.ckeditor5.yml
  11. 1
      bibcite_footnotes.info.yml
  12. 9
      bibcite_footnotes_2.ckeditor5.plugin.yml
  13. 17
      bibcite_footnotes_2.ckeditor5.yml
  14. 7
      bibcite_footnotes_2.info.yml
  15. 19
      bibcite_footnotes_2.libraries.yml
  16. 29
      composer.json
  17. 21
      config/schema/bibcite_footnotes.schema.yml
  18. 10
      footnotes.info.yml
  19. 45
      footnotes.install
  20. 5
      footnotes.libraries.yml
  21. 92
      footnotes.module
  22. 4
      js/ckeditor5/reference-footnotes-icon.svg
  23. 198
      js/ckeditor5/reference-footnotes.js
  24. 19
      src/Plugin/CKEditor5Plugin/ReferenceFootnotes.php
  25. 54
      src/Plugin/CKEditorPlugin/Footnotes.php
  26. 470
      src/Plugin/Filter/FootnotesFilter.php
  27. 2
      templates/footnote-link.html.twig
  28. 23
      templates/footnote-list.html.twig
  29. 168
      tests/src/Functional/FootnotesFilterPluginTest.php
  30. 176
      tests/src/FunctionalJavascript/FootnotesCkeditorPluginTest.php

85
Changelog.txt

@ -1,85 +0,0 @@
2010-03-xx Footnotes 7.x-2.5
- First Drupal 7 release. No major feature changes over 6.x-2.5.
2010-12-31 Footnotes 2.5
Major features
- Add new addon module: Footnotes with Views by AlexisWilke. [#939738]
Smaller fixes
- Remove "DEPRECATED" text from Better URL filter and instead have a small
"Note" about it not being available in Footnotes 7.x-x.x. [#1002436]
- Correctly ignore also a tag containing linebreak between the > and <.
Fixes bug [#1002434]
- Add textarea to ignored tags. Anything inside a textarea will be ignored.
Bug [#974760]
The intention is to branch a Drupal 7 version out of this release.
2010-10-03 Footnotes 2.4
Major features
- Add Footnotes Wysiwyg module with TinyMCE AND CKEditor support.
This deprecates Footnotes TinyMCE module, which is kept around for backward
compatibility.
[#728642]
- Add i18n support via Drupal.t() also to TinyMCE module. [#672034]
- New feature (option): Collapse identical footnotes into one, as if using same
value="". [#808214]
- Implement [#728658] Highlight footnote when clicking the link. Add mention in
README.txt how to change the highlight color if needed (Footnotes cannot know
what is an appropriate color, I picked #eeeeee as the safest choice).
Smaller fixes
- Bug [#761390]
Two small improper CSS names
...was fixed by changing underscores to dashes in css selectors/classes.
- Deprecate Better URL filter as it is committed to Drupal 7 now. [#296208]
- Bug [#761664]
Footnotes are double numbered when CSS is not used, such as in RSS feeds.
(Due to using OL list)
...was fixed by migrating to UL list. This is also appropriate since after
introduction of the value="" parameter the footnotes needn't comprise an
ordered list.
2010-02-25 Footnotes 2.3
- Reset $used_values in _footnotes_replace_callback() after use. [#723446]
2010-01-17 Footnotes 2.2
- Add TinyMCE support as a separate plugin tinymce_footnotes
(thanks elgreg #464066)
- Can have multiple references to same footnote in body by repeating value=""
(#636808)
Small fixes
- Move translations from "po" to "translations" subdirectory. #430656
- Rename footnotes-alternative_layout.css due to typo in filename.
- fix css: Use "ol.footnotes li" instead of "ol.footnotes" as selector for
"list-style-type: none." Makes it stronger.
- fix html: columns="" should be cols="" bug: #687244
2008-09-07 Footnotes 2.1
- Add "clear: both" to css of footnotes section. http://drupal.org/node/303828
2008-07-30 Footnotes 2.0
- Add support for using [fn]square brackets[/fn] (268026)
- Change documentation to talk about [fn] by default, but <fn> is still
supported
- Add support for specifying "value" attribute. (emfabric 282104)
- Mention http://drupal.org/node/279420 in known issues
- Adding Better URL filter (fork from core). http://drupal.org/node/161217
- There appears to also be a French translation now. Thanks Beginner!
(Japanese already done earlier.)
- Fix bug where teaser might cut into the middle of a footnote:
http://drupal.org/node/253326
- Start using the Drupal theme system, footnotes can now be themed by site
admins (emfabric 221156)

25
MERGE_NOTES.md

@ -17,3 +17,28 @@ This package merges the original `bibcite_footnotes` module and the CKEditor 5-o
The optional `bibcite_footnotes_article_with_citations` submodule is still a CKEditor 4 example configuration because its installed editor config uses `editor: ckeditor` and the `reference_footnotes` CKEditor 4 toolbar button.
On a CKEditor 5 site, enable the main module and add the `Footnote/Citation` button to the desired text format's CKEditor 5 toolbar. Make sure the text format allows the custom `<fn value page reference>` element and the `filter_reference_footnotes` filter is enabled.
## 2026-06-11 CKEditor 5 toolbar fix
Restored the CKEditor 5 plugin PHP class/assets and `reference_footnotes` library from the CKEditor 5 module, with namespaces and library references changed to `bibcite_footnotes`. Also added an explicit `drupal:ckeditor5` dependency so Drupal discovers the CKEditor 5 plugin definitions reliably.
## CKEditor 5 toolbar fix
The CKEditor 5 plugin definition now lists the base `<fn>` tag and each attribute form separately:
- `<fn>`
- `<fn value>`
- `<fn page>`
- `<fn reference>`
Drupal CKEditor 5 plugin validation treats tag creation and attribute creation separately, so declaring only `<fn value page reference>` can prevent the toolbar item from being available.
## Compatibility fix
This package includes both filter plugin IDs:
- `filter_reference_footnotes` via `ReferenceFootnotesFilter`
- `filter_footnotes` via `FootnotesFilter`
The second class is a compatibility alias for existing text formats whose active config already points at `filter_footnotes`.

57
README.txt

@ -1,57 +0,0 @@
INTRODUCTION
------------------
The Footnotes module is used to easily create automatically numbered footnote references in an article or post (such as a reference to a URL).
* For a full description of the module, visit the project page:
https://www.drupal.org/project/footnotes
* To submit bug reports and feature suggestions, or track changes:
https://www.drupal.org/project/issues/footnotes
REQUIREMENTS
------------------
The Footnote module for Drupal 8 requires the following modules and plugins:
* FakeObjects (https://www.drupal.org/project/fakeobjects)
* CKEditor plugin (http://ckeditor.com/addon/fakeobjects)
INSTALLATION
----------------
* Before you can use the FakeObjects module, you need to download the plugin from http://ckeditor.com/addon/fakeobjects and place it in /libraries/fakeobjects.
* In all other steps, install the module as you would normally install a contributed Drupal module. Visit
https://www.drupal.org/docs/8/extending-drupal-8/installing-drupal-8-modules for further information.
CONFIGURATION
-------------------
* To use the footnotes filter in some input formats, go to Configuration ->
Text formats.
* For the Text formats you want to support footnotes markup, select configure and activate a suitable footnotes filter.
* In the place where you want to add a footnote enclose the footnote text within an fn tag:<code>[fn]like this[/fn]</code>. By default, footnotes are placed at the end of the text. You can also use a <code>[footnotes]</code> or <code>[footnotes /]</code> tag to position it anywhere you want.
* The filter will take the text within the tag and move it to a footnote at the
bottom of the page. In it's place it will place a number which is also a link to
the footnote. Footnotes supports both <code>[fn]square brackets[/fn]</code> and <code><fn>angle brackets</fn></code>.
* You can also use a "value" attribute to a) set the numbering to start from the given value, or b) to set an arbitrary text string as label.
Ex:
[fn value="5"]This becomes footnote #5. Subsequent are #6, #7...[/fn]
[fn value="*"]This footnote is assigned the label "*"[/fn]
Using value="" you can have multiple references to the same footnote in the text body.
[fn value="5"]This becomes footnote #5.[/fn]
[fn value="5"]This is a reference to the same footnote #5, this text itself is discarded.[/fn]
TROUBLESHOOTING & FAQ
------------------------------
Q: When trying to install the Footnotes module, I get the message: Before you can use the FakeObjects module, you need to download the plugin from ckeditor.com and place it in /libraries/fakeobjects."
A: To avoid this error message, please follow the guidelines in the Required modules and the Installation sections of this Readme file. Please mind that the Drupal 8.x-2.x branch of the Footnotes module only supports the CKEditor and does not support the TinyMCE.

75
assets/css/footnotes-alternative_layout.css

@ -1,75 +0,0 @@
/*
* CSS specific to Footnotes module.
*
* This is an alternative layout, it is not so nice but overcomes
* the layout bugs on IE. http://drupal.org/node/166628
* To use this layout, just rename this file to footnotes.css.
*/
/* Add empty space before footnotes and a black line on top. */
.footnotes {
clear: both;
margin-top: 4em;
margin-bottom: 2em;
border-top: 1px solid #000;
}
/* Make footnotes appear in a smaller font */
.footnotes {
font-size: 0.9em;
}
/*
Make the footnote a supertext^1
*/
.see-footnote {
vertical-align: top;
position: relative;
top: -0.25em;
font-size: 0.9em;
}
/* Hide the bullet of the UL list of footnotes */
ul.footnotes {
list-style-type: none;
margin-left: 0;
padding-left: 0;
}
ul.footnotes li {
margin-left: 0.5em;
list-style-type: none;
background: none; /* Garland theme sets a bullet via background image, this must be unset! See bug 861634 */
}
.footnotes .footnote-label {
vertical-align: top;
position: relative;
top: -0.35em;
left: -0.35em;
font-size: 0.8em;
}
/* Highlight the target footnote (or ref number, if a backlink was used) when user clicks a footnote. */
.see-footnote:target,
.footnotes .footnote:target {
background-color: #eee;
}
.see-footnote:target {
border: solid 1px #aaa;
}
/*
Make the multiple backlinks a supertext^1
*/
.footnotes .footnote-multi {
vertical-align: top;
position: relative;
top: -0.25em;
font-size: 0.75em;
}
/*
* Textile Footnotes
*/
/* First footnote */
#fn1 {
border-top: 1px solid #000;
margin-top: 3em;
}
.footnote {
font-size: 0.9em;
}

82
assets/css/footnotes.css

@ -1,82 +0,0 @@
/*
* CSS specific to Footnotes module.
*
* Thanks to binford2k@lug.wsu.edu for this tip and drinkypoo
* for the question leading up to it. http://drupal.org/node/80538
*/
/* Add empty space before footnotes and a black line on top. */
.footnotes {
clear: both;
margin-top: 4em;
margin-bottom: 2em;
border-top: 1px solid #000;
}
/* Make footnotes appear in a smaller font */
.footnotes {
font-size: 0.9em;
}
/*
Make the footnote a supertext^1
*/
.see-footnote {
vertical-align: top;
position: relative;
top: -0.25em;
font-size: 0.9em;
}
/* Hide the bullet of the UL list of footnotes */
ul.footnotes {
list-style-type: none;
margin-left: 0;
padding-left: 0;
}
ul.footnotes li {
margin-left: 2.5em;
list-style-type: none;
background: none; /* Garland theme sets a bullet via background image, this must be unset! See bug 861634 */
}
/* Move the footnote number outside of the margin for footnote text (hanging indent) */
ul.footnotes {
/* This is apparently very needed for the "position: absolute;" below to work correctly */
position: relative;
}
.footnotes .footnote-label {
position: absolute;
left: 0;
z-index: 2;
}
/* Highlight the target footnote (or ref number, if a backlink was used) when user clicks a footnote. */
.see-footnote:target,
.footnotes .footnote:target {
background-color: #eee;
}
.see-footnote:target {
border: solid 1px #aaa;
}
/* Note: This CSS has a minor bug on all versions of IE in that the footnote numbers
are aligned with the absolute bottom of their space, thus being a couple of pixels
lower than their corresponding line of text. IE5.5 has a serious bug in that the numbers
are not shifted left at all, thus being garbled together with the start of their text. */
/*
Make the multiple backlinks a supertext^1
*/
.footnotes .footnote-multi {
vertical-align: top;
position: relative;
top: -0.25em;
font-size: 0.75em;
}
/*
* Textile Footnotes
*/
/* First footnote */
#fn1 {
border-top: 1px solid #000;
margin-top: 3em;
}
.footnote {
font-size: 0.9em;
}

60
assets/js/ckeditor/dialogs/footnotes.js

@ -1,60 +0,0 @@
/**
* @file
*/
function footnotesDialog(editor, isEdit) {
return {
title: Drupal.t("Footnotes Dialog"),
minWidth: 500,
minHeight: 50,
contents: [
{
id: "info",
label: Drupal.t("Add a footnote"),
title: Drupal.t("Add a footnote"),
elements: [
{
id: "footnote",
type: "text",
label: Drupal.t("Footnote text :"),
setup(element) {
if (isEdit) {
this.setValue(element.getHtml());
}
}
},
{
id: "value",
type: "text",
label: Drupal.t("Value :"),
setup(element) {
if (isEdit) {
this.setValue(element.getAttribute("value"));
}
}
}
]
}
],
onShow() {
if (isEdit) {
this.fakeObj = CKEDITOR.plugins.footnotes.getSelectedFootnote(editor);
this.realObj = editor.restoreRealElement(this.fakeObj);
}
this.setupContent(this.realObj);
},
onOk() {
CKEDITOR.plugins.footnotes.createFootnote(
editor,
this.realObj,
this.getValueOf("info", "footnote"),
this.getValueOf("info", "value")
);
delete this.fakeObj;
delete this.realObj;
}
};
}
CKEDITOR.dialog.add("createfootnotes", editor => footnotesDialog(editor));
CKEDITOR.dialog.add("editfootnotes", editor => footnotesDialog(editor, 1));

BIN
assets/js/ckeditor/icons/fn_icon2.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 262 B

BIN
assets/js/ckeditor/icons/footnotes.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 263 B

151
assets/js/ckeditor/plugin.js

@ -1,151 +0,0 @@
/**
* @file
* A CKeditor plugin to insert footnotes as in-place <fn> elements (consumed by Footnotes module in Drupal).
*
* This is a rather sophisticated plugin to show a dialog to insert
* <fn> footnotes or edit existing ones. It produces and understands
* the <fn>angle bracket</fn> variant and uses the fakeObjects API to
* show a nice icon to the user, while producing proper <fn> tags when
* the text is saved or View Source is pressed.
*
* If a text contains footnotes of the [fn]square bracket[/fn] variant,
* they will be visible in the text and this plugin will not react to them.
*
* This plugin uses Drupal.t() to translate strings and will not as such
* work outside of Drupal. (But removing those functions would be the only
* change needed.) While being part of a Wysiwyg compatible module, it could
* also be used together with the CKEditor module.
*
* Drupal Wysiwyg requirement: The first argument to CKEDITOR.plugins.add()
* must be equal to the key used in $plugins[] in hook_wysiwyg_plugin().
*/
CKEDITOR.plugins.add("footnotes", {
requires: ["fakeobjects", "dialog"],
icons: "footnotes",
onLoad() {
const iconPath = `${window.location.origin + this.path}icons/fn_icon2.png`;
CKEDITOR.addCss(
`${".cke_footnote{background-image: url("}${CKEDITOR.getUrl(
iconPath
)});` +
`background - position: center center;` +
`background - repeat: no - repeat;` +
`width: 16px;` +
`height: 16px;` +
`}`
);
},
init(editor) {
editor.addCommand(
"createfootnotes",
new CKEDITOR.dialogCommand("createfootnotes", {
allowedContent: "fn[value]"
})
);
editor.addCommand(
"editfootnotes",
new CKEDITOR.dialogCommand("editfootnotes", {
allowedContent: "fn[value]"
})
);
// Drupal Wysiwyg requirement: The first argument to editor.ui.addButton()
// must be equal to the key used in $plugins[<pluginName>]['buttons'][<key>]
// in hook_wysiwyg_plugin().
if (editor.ui.addButton) {
editor.ui.addButton("footnotes", {
label: Drupal.t("Add a footnote"),
command: "createfootnotes",
icon: "footnotes"
});
}
if (editor.addMenuItems) {
editor.addMenuGroup("footnotes", 100);
editor.addMenuItems({
footnotes: {
label: Drupal.t("Edit footnote"),
command: "editfootnotes",
icon: "footnotes",
group: "footnotes"
}
});
}
if (editor.contextMenu) {
editor.contextMenu.addListener(element => {
if (!element || element.data("cke-real-element-type") !== "fn") {
return null;
}
return { footnotes: CKEDITOR.TRISTATE_ON };
});
}
editor.on("doubleclick", evt => {
if (CKEDITOR.plugins.footnotes.getSelectedFootnote(editor)) {
evt.data.dialog = "editfootnotes";
}
});
CKEDITOR.dialog.add("createfootnotes", `${this.path}dialogs/footnotes.js`);
CKEDITOR.dialog.add("editfootnotes", `${this.path}dialogs/footnotes.js`);
},
afterInit(editor) {
const { dataProcessor } = editor;
const { dataFilter } = dataProcessor;
if (dataFilter) {
dataFilter.addRules({
elements: {
fn(element) {
return editor.createFakeParserElement(
element,
"cke_footnote",
"hiddenfield",
false
);
}
}
});
}
}
});
CKEDITOR.plugins.footnotes = {
createFootnote(editor, origElement, text, value) {
let realElement;
if (!origElement) {
realElement = CKEDITOR.dom.element.createFromHtml("<fn></fn>");
} else {
realElement = origElement;
}
if (text && text.length > 0) {
realElement.setHtml(text);
}
if (value && value.length > 0) {
realElement.setAttribute("value", value);
}
const fakeElement = editor.createFakeElement(
realElement,
"cke_footnote",
"hiddenfield",
false
);
editor.insertElement(fakeElement);
},
getSelectedFootnote(editor) {
const selection = editor.getSelection();
const element = selection.getSelectedElement();
const seltype = selection.getType();
if (
seltype === CKEDITOR.SELECTION_ELEMENT &&
element.data("cke-real-element-type") === "hiddenfield"
) {
return element;
}
}
};

5
bibcite_footnotes.ckeditor5.yml

@ -11,4 +11,7 @@ bibcite_footnotes_footnote_picker:
label: 'Footnote'
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 value page reference>
- <fn>
- <fn value>
- <fn page>
- <fn reference>

1
bibcite_footnotes.info.yml

@ -7,4 +7,5 @@ dependencies:
- bibcite:bibcite
- bibcite:bibcite_entity
- drupal:editor
- drupal:ckeditor5
- drupal:filter

9
bibcite_footnotes_2.ckeditor5.plugin.yml

@ -1,9 +0,0 @@
reference_footnotes:
ckeditor5:
plugins:
- ReferenceFootnotes
drupal:
label: 'Reference Footnotes'
toolbar_items:
referenceFootnotes:
label: 'Reference Footnotes'

17
bibcite_footnotes_2.ckeditor5.yml

@ -1,17 +0,0 @@
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

@ -1,7 +0,0 @@
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

@ -1,19 +0,0 @@
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

29
composer.json

@ -1,33 +1,18 @@
{
"name": "drupal/footnotes",
"description": "Add automatically numbered footnotes to your posts.",
"name": "roblib/bibcite_footnotes",
"description": "Inline footnote links for BibCite References with CKEditor 4 and CKEditor 5 support.",
"type": "drupal-module",
"homepage": "https://drupal.org/project/footnotes",
"homepage": "https://drupal.org/project/bibcite_footnotes",
"authors": [
{
"name": "Andrii Aleksandrov (id.aleks)",
"homepage": "https://www.drupal.org/u/idaleks",
"role": "Maintainer"
},
{
"name": "Oleksandr Dekhteruk (pifagor)",
"homepage": "https://www.drupal.org/u/pifagor",
"role": "Maintainer"
},
{
"name": "Fernando Conceição (yukare)",
"homepage": "https://www.drupal.org/u/yukare",
"role": "Maintainer"
},
{
"name": "Henrik Ingo (hingo)",
"homepage": "https://www.drupal.org/u/hingo",
"name": "Alexander O'Neill (alxp)",
"homepage": "https://www.drupal.org/u/alxp",
"role": "Maintainer"
}
],
"support": {
"issues": "https://drupal.org/project/issues/footnotes",
"source": "https://cgit.drupalcode.org/footnotes"
"issues": "https://drupal.org/project/issues/bibcite_footnotes",
"source": "https://cgit.drupalcode.org/bibcite_footnotes"
},
"license": "GPL-2.0+",
"minimum-stability": "dev",

21
config/schema/bibcite_footnotes.schema.yml

@ -17,3 +17,24 @@ filter_settings.filter_reference_footnotes:
works_cited_sort_by:
type: string
label: 'Sort works cited by'
filter_settings.filter_footnotes:
type: mapping
label: 'Footnotes filter settings'
mapping:
footnotes_collapse:
type: boolean
label: 'Collapse identical footnotes'
footnotes_ibid:
type: boolean
label: 'Use Ibid.'
notes_section_label:
type: label
label: 'Notes section heading label'
reference_dont_show_backlink_text:
type: boolean
label: "Don't show note value text in reference list"
works_cited_sort_by:
type: string
label: 'Sort works cited by'

10
footnotes.info.yml

@ -1,10 +0,0 @@
name: Footnotes
description: 'Add automatically numbered footnotes to your posts.'
type: module
core_version_requirement: ^8 || ^9
dependencies:
- 'fakeobjects:fakeobjects'
test_dependencies:
- 'fakeobjects:fakeobjects'

45
footnotes.install

@ -1,45 +0,0 @@
<?php
/**
* @file
* Install, update and uninstall functions for the Footnotes module.
*/
/**
* Implements hook_requirements().
*/
function footnotes_requirements($phase) {
if ($phase != 'runtime') {
return [];
}
// Check if fakeobjects module is enabled and properly configured.
$fakeobjects_exist = \Drupal::moduleHandler()->moduleExists('fakeobjects');
if ($fakeobjects_exist) {
$fakeobjects_requirements = fakeobjects_requirements($phase);
if ($fakeobjects_requirements['fakeobjects']['severity'] === REQUIREMENT_OK) {
$requirements['footnotes'] = [
'title' => t('Footnotes'),
'value' => t('Footnotes requirements are OK.'),
'severity' => REQUIREMENT_OK,
];
}
else {
$requirements['footnotes'] = [
'title' => t('Footnotes'),
'value' => t('Footnotes requirements are not properly configured. Please check Fakeobjects module requirements.'),
'severity' => REQUIREMENT_ERROR,
];
}
}
else {
$requirements['footnotes'] = [
'title' => t('Footnotes'),
'value' => t("<a href=':href'>Fakeobjects module</a> isn't installed/enabled.", [':href' => 'https://www.drupal.org/project/fakeobjects']),
'severity' => REQUIREMENT_ERROR,
'description' => t('Footnotes module has a dependency on Fakeobjects module. Ensure that Fakeobjects module is enabled and configured.'),
];
}
return $requirements;
}

5
footnotes.libraries.yml

@ -1,5 +0,0 @@
footnotes:
version: VERSION
css:
component:
assets/css/footnotes.css: {}

92
footnotes.module

@ -1,92 +0,0 @@
<?php
/**
* @file
* This file contains the hooks for Footnotes module.
*
* The Footnotes module is a filter that can be used to insert
* automatically numbered footnotes into Drupal texts.
*/
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\Core\Url;
/**
* Implements hook_help().
*/
function footnotes_help($route_name, RouteMatchInterface $route_match) {
switch ($route_name) {
case 'help.page.footnotes':
return t("Insert automatically numbered footnotes using &lt;fn&gt; or [fn] tags. Enable the footnotes text filter <a href=':href'>here</a>", [
':href' => Url::fromRoute('filter.admin_overview')->toString(),
]);
}
}
/**
* Implements hook_theme().
*
* Thanks to emfabric for this implementation. http://drupal.org/node/221156
*/
function footnotes_theme() {
return [
'footnote_link' => [
'variables' => [
'fn' => NULL,
],
],
'footnote_list' => [
'variables' => [
'footnotes'=> NULL,
],
],
];
}
/**
* Helper for other filters, check if Footnotes is present in your filter chain.
*
* Note: Due to changes in Filter API, the arguments to this function have
* changed in Drupal 7.
*
* Other filters may leverage the Footnotes functionality in a simple way:
* by outputting markup with <fn>...</fn> tags within.
*
* This creates a dependency, the Footnotes filter must be present later in
* "Input format". By calling this helper function the other filters that
* depend on Footnotes may check whether Footnotes is present later in the chain
* of filters in the current Input format.
*
* If this function returns true, the caller may depend on Footnotes. Function
* returns false if caller may not depend on Footnotes.
*
* You should also put "dependencies = footnotes" in your module.info file.
*
* Example usage:
* <code>
* _filter_example_process( $text, $filter, $format ) {
* ...
* if(footnotes_is_footnotes_later($format, $filter)) {
* //output markup which may include [fn] tags
* }
* else {
* // must make do without footnotes features
* // can also emit warning/error that user should install and configure
* // footnotes module
* }
* ...
* }
* </code>
*
* @param object $format
* The text format object caller is part of.
* @param object $caller_filter
* The filter object representing the caller (in this text format).
*
* @return True
* If Footnotes is present after $caller in $format.
*/
function footnotes_is_footnotes_later($format, $caller_filter) {
return $format['filter_footnotes']['weight'] > $caller_filter['weight'];
}

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

@ -1,4 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 282 B

198
js/ckeditor5/reference-footnotes.js

@ -1,198 +0,0 @@
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);
}
});
}
}

19
src/Plugin/CKEditor5Plugin/ReferenceFootnotes.php

@ -1,19 +0,0 @@
<?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 {
}

54
src/Plugin/CKEditorPlugin/Footnotes.php

@ -1,54 +0,0 @@
<?php
namespace Drupal\footnotes\Plugin\CKEditorPlugin;
use Drupal\ckeditor\CKEditorPluginBase;
use Drupal\editor\Entity\Editor;
use Drupal\Core\StringTranslation\StringTranslationTrait;
/**
* Defines the "Footnotes" plugin.
*
* @CKEditorPlugin(
* id = "footnotes",
* label = @Translation("FootnotesButton")
* )
*/
class Footnotes extends CKEditorPluginBase {
use StringTranslationTrait;
/**
* {@inheritdoc}
*/
public function getDependencies(Editor $editor) {
return ['fakeobjects'];
}
/**
* {@inheritdoc}
*/
public function getFile() {
return drupal_get_path('module', 'footnotes') . '/assets/js/ckeditor/plugin.js';
}
/**
* {@inheritdoc}
*/
public function getButtons() {
return [
'footnotes' => [
'label' => $this->t('Footnotes'),
'image' => drupal_get_path('module', 'footnotes') . '/assets/js/ckeditor/icons/footnotes.png',
],
];
}
/**
* {@inheritdoc}
*/
public function getConfig(Editor $editor) {
return [];
}
}

470
src/Plugin/Filter/FootnotesFilter.php

@ -1,192 +1,146 @@
<?php
namespace Drupal\footnotes\Plugin\Filter;
declare(strict_types=1);
namespace Drupal\bibcite_footnotes\Plugin\Filter;
use Drupal\Component\Utility\Xss;
use Drupal\Core\Config\Config;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Config\ImmutableConfig;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\Render\RendererInterface;
use Drupal\filter\FilterProcessResult;
use Drupal\filter\Plugin\FilterBase;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
/**
* Provides a base filter for Footnotes filter.
* Legacy Footnotes filter compatibility alias.
*
* @Filter(
* id = "filter_footnotes",
* module = "footnotes",
* title = @Translation("Footnotes filter"),
* description = @Translation("You can insert footnotes directly into texts."),
* type = \Drupal\filter\Plugin\FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE,
* cache = FALSE,
* settings = {
* "footnotes_collapse" = FALSE,
* "footnotes_html" = FALSE
* "footnotes_ibid" = FALSE,
* "notes_section_label" = "Notes",
* "reference_dont_show_backlink_text" = FALSE,
* "works_cited_sort_by" = "weight"
* },
* weight = 0
* )
*/
class FootnotesFilter extends FilterBase {
final class FootnotesFilter extends FilterBase implements ContainerFactoryPluginInterface {
use StringTranslationTrait;
protected RendererInterface $renderer;
/**
* The renderer.
*
* @var \Drupal\Core\Render\RendererInterface
*/
protected $renderer;
protected ImmutableConfig $config;
/**
* Constructs a FootnotesFilter object.
*
* @param array $configuration
* A configuration array containing information about the plugin instance.
* @param string $plugin_id
* The plugin_id for the plugin instance.
* @param array $plugin_definition
* The plugin implementation definition.
*/
public function __construct(array $configuration, $plugin_id, array $plugin_definition) {
protected Config $configEditable;
public function __construct(
array $configuration,
string $plugin_id,
array $plugin_definition,
RendererInterface $renderer,
ConfigFactoryInterface $configFactory,
) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
$this->renderer = \Drupal::service('renderer');
}
/**
* Get the tips for the filter.
*
* @param bool $long
* If get the long or short tip.
*
* @return string
* The tip to show for the user.
*/
public function tips($long = FALSE) {
if ($long) {
return $this->t('You can insert footnotes directly into texts with <code>[fn]This text becomes a footnote.[/fn]</code>. This will be replaced with a running number (the footnote reference) and the text within the [fn] tags will be moved to the bottom of the page (the footnote). See <a href=":link">Footnotes Readme page</a> for additional usage options.', [':link' => 'http://drupal.org/project/footnotes">']);
}
else {
return $this->t('Use [fn]...[/fn] (or &lt;fn&gt;...&lt;/fn&gt;) to insert automatically numbered footnotes.');
$this->renderer = $renderer;
$this->config = $configFactory->get('reference_footnotes.settings');
$this->configEditable = $configFactory->getEditable('reference_footnotes.settings');
}
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): self {
return new self(
$configuration,
(string) $plugin_id,
$plugin_definition,
$container->get('renderer'),
$container->get('config.factory'),
);
}
/**
* {@inheritdoc}
*/
public function process($text, $langcode) {
// Supporting both [fn] and <fn> now. Thanks to fletchgqc
// http://drupal.org/node/268026.
// Convert all square brackets to angle brackets. This way all further code
// just manipulates angle brackets. (Angle brackets are preferred here for
// the simple reason that square brackets are more tedious to use in
// regexps).
if (is_array($text)) {
implode($text);
}
$text = preg_replace('|\[fn([^\]]*)\]|', '<fn$1>', $text);
$text = preg_replace('|\[/fn\]|', '</fn>', $text);
$text = preg_replace('|\[footnotes([^\]]*)\]|', '<footnotes$1>', $text);
// Check that there are an even number of open and closing tags.
// If there is one closing tag missing, append this to the end.
// If there is more disparity, throw a warning and continue.
// A closing tag may sometimes be missing when we are processing a teaser
// and it has been cut in the middle of the footnote.
// See http://drupal.org/node/253326
$foo = [];
$open_tags = preg_match_all("|<fn([^>]*)>|", $text, $foo);
$close_tags = preg_match_all("|</fn>|", $text, $foo);
public function settingsForm(array $form, FormStateInterface $form_state): array {
$settings['footnotes_collapse'] = [
'#type' => 'checkbox',
'#title' => $this->t('Collapse reference footnotes with identical content'),
'#default_value' => (bool) ($this->settings['footnotes_collapse'] ?? FALSE),
'#description' => $this->t('If two reference footnotes have the exact same content, they will be collapsed into one as if using the same value="" attribute.'),
];
if ($open_tags == $close_tags + 1) {
$text = $text . '</fn>';
}
elseif ($open_tags > $close_tags + 1) {
trigger_error($this->t("You have unclosed fn tags. This is invalid and will
produce unpredictable results."));
}
$settings['footnotes_ibid'] = [
'#type' => 'checkbox',
'#title' => $this->t('Display subsequent instances of multiple references with \'Ibid.\''),
'#default_value' => (bool) ($this->settings['footnotes_ibid'] ?? FALSE),
];
// Before doing the replacement, the callback function needs to know which
// options to use.
$this->replaceCallback($this->settings, 'prepare');
$settings['notes_section_label'] = [
'#type' => 'textfield',
'#title' => $this->t('Notes section heading label'),
'#default_value' => (string) ($this->settings['notes_section_label'] ?? 'Notes'),
];
$pattern = '|<fn([^>]*)>(.*?)</fn>|s';
$text = preg_replace_callback($pattern, [
$this,
'replaceCallback',
], $text);
// Replace tag <footnotes> with the list of footnotes.
// If tag is not present, by default add the footnotes at the end.
// Thanks to acp on drupal.org for this idea. see
// http://drupal.org/node/87226.
$footer = $this->replaceCallback(NULL, 'output footer');
$pattern = '|(<footnotes([\ \/]*)>)|';
if (preg_match($pattern, $text) > 0) {
$text = preg_replace($pattern, $footer, $text, 1);
}
elseif (!empty($footer)) {
$text .= "\n\n" . $footer;
}
$result = new FilterProcessResult($text);
$result->setAttachments([
'library' => [
'footnotes/footnotes',
$settings['reference_dont_show_backlink_text'] = [
'#type' => 'checkbox',
'#title' => $this->t("Don't show note 'value' text in reference list."),
'#description' => $this->t("Suitable for MLA-style citations, like (Smith, 33-22)"),
'#default_value' => (bool) ($this->settings['reference_dont_show_backlink_text'] ?? FALSE),
];
$settings['works_cited_sort_by'] = [
'#type' => 'select',
'#title' => $this->t('Sort Works Cited list by'),
'#options' => [
'weight' => $this->t('Manually'),
'alphabetical' => $this->t('Alphabetically'),
],
]);
return $result;
'#default_value' => (string) ($this->settings['works_cited_sort_by'] ?? 'weight'),
];
return $settings;
}
/**
* Helper function called from preg_replace_callback() above.
* Helper function called from preg_replace_callback().
*
* Uses static vars to temporarily store footnotes found.
* This is not threadsafe, but PHP isn't.
*
* @param mixed $matches
* Elements from array:
* - 0: complete matched string.
* - 1: tag name.
* - 2: tag attributes.
* - 3: tag content.
* @param string $op
* Operation.
*
* @return string
* Return the string processed by geshi library.
*/
protected function replaceCallback($matches, $op = '') {
protected function replaceCallback(array $matches, string $op = ''): string|int {
static $opt_collapse = 0;
static $opt_html = 0;
static $n = 0;
static $store_matches = [];
static $used_values = [];
$str = '';
if ($op == 'prepare') {
// In the 'prepare' case, the first argument contains the options to use.
// The name 'matches' is incorrect, we just use the variable anyway.
$opt_collapse = $matches['footnotes_collapse'];
$opt_html = $matches['footnotes_html'];
if ($op === 'prepare') {
// In the 'prepare' case, $matches contains the options to use.
$opt_collapse = !empty($matches['footnotes_collapse']);
return 0;
}
if ($op == 'output footer') {
if ($op === 'output footer') {
$str = '';
if (count($store_matches) > 0) {
// Only if there are stored fn matches, pass the array of fns to be
// themed as a list Drupal 7 requires we use "render element" which
// just introduces a wrapper around the old array.
// @FIXME
// theme() has been renamed to _theme() and should NEVER be called
// directly. Calling _theme() directly can alter the expected output and
// potentially introduce security issues
// (see https://www.drupal.org/node/2195739). You should use renderable
// arrays instead. @see https://www.drupal.org/node/2195739
if (!empty($this->settings['footnotes_ibid'])) {
$this->ibidemify($store_matches);
}
$markup = [
'#theme' => 'footnote_list',
'#footnotes' => $store_matches,
'#theme' => 'bibcite_footnote_list',
'#notes' => $store_matches,
'#config' => $this->settings,
];
$str = $this->renderer->render($markup);
$str = (string) $this->renderer->render($markup, FALSE);
}
// Reset the static variables so they can be used again next time.
// Reset static variables.
$n = 0;
$store_matches = [];
$used_values = [];
@ -194,185 +148,153 @@ class FootnotesFilter extends FilterBase {
return $str;
}
// Default op: act as called by preg_replace_callback()
// Random string used to ensure footnote id's are unique, even
// when contents of multiple nodes reside on same page.
// (fixes http://drupal.org/node/194558).
// Default op: act as called by preg_replace_callback().
$randstr = $this->randstr();
$value = '';
// Did the pattern match anything in the <fn> tag?
if ($matches[1]) {
// See if value attribute can parsed, either well-formed in quotes eg
// <fn value="3">.
if (preg_match('|value=["\'](.*?)["\']|', $matches[1], $value_match)) {
$value = $value_match[1];
// Or without quotes eg <fn value=8>.
}
elseif (preg_match('|value=(\S*)|', $matches[1], $value_match)) {
$value = $value_match[1];
}
}
$value = $this->extractAttribute($matches, 'value');
$page = $this->extractAttribute($matches, 'page');
$reference = $this->extractAttribute($matches, 'reference');
if ($value) {
// A value label was found. If it is numeric, record it in $n so further
// notes can increment from there.
// After adding support for multiple references to same footnote in the
// body (http://drupal.org/node/636808) also must check that $n is
// monotonously increasing.
if (is_numeric($value) && $n < $value) {
$n = $value;
if ($value !== '') {
if (is_numeric($value) && $n < (int) $value) {
$n = (int) $value;
}
}
elseif ($opt_collapse and $value_existing = $this->findFootnote($matches[2], $store_matches)) {
// An identical footnote already exists. Set value to the previously
// existing value.
$value = $value_existing;
elseif ($opt_collapse && ($value_existing = $this->findFootnote($matches[2], $reference, $store_matches)) !== FALSE) {
$value = (string) $value_existing;
}
else {
// No value label, either a plain <fn> or unparsable attributes. Increment
// the footnote counter, set label equal to it.
$n++;
$value = $n;
$value = (string) $n;
}
// Remove illegal characters from $value so it can be used as an HTML id
// attribute.
$value_id = preg_replace('|[^\w\-]|', '', $value);
$value_id = preg_replace('|[^\w\-]|', '', $value) ?? '';
// Create a sanitized version of $text that is suitable for using as HTML
// attribute value. (In particular, as the title attribute to the footnote
// link).
// Sanitize content for title attribute.
$allowed_tags = [];
$text_clean = Xss::filter($matches['2'], $allowed_tags);
// HTML attribute cannot contain quotes.
$text_clean = str_replace('"', "&quot;", $text_clean);
// Remove newlines. Browsers don't support them anyway and they'll confuse
// line break converter in filter.module.
$text_clean = str_replace("\n", " ", $text_clean);
$text_clean = str_replace("\r", "", $text_clean);
// Create a footnote item as an array.
$text_clean = Xss::filter($matches[2], $allowed_tags);
$text_clean = str_replace('"', '&quot;', $text_clean);
$text_clean = str_replace("\n", ' ', $text_clean);
$text_clean = str_replace("\r", '', $text_clean);
$fn = [
'value' => $value,
'text' => $opt_html ? html_entity_decode($matches[2]) : $matches[2],
'text' => $matches[2],
'text_clean' => $text_clean,
'page' => $page,
'reference' => $reference,
'fn_id' => 'footnote' . $value_id . '_' . $randstr,
'ref_id' => 'footnoteref' . $value_id . '_' . $randstr,
];
// We now allow to repeat the footnote value label, in which case the link
// to the previously existing footnote is returned. Content of the current
// footnote is ignored. See http://drupal.org/node/636808 .
if (!in_array($value, $used_values)) {
// This is the normal case, add the footnote to $store_matches.
// Store the footnote item.
array_push($store_matches, $fn);
array_push($used_values, $value);
if (!in_array($value, $used_values, TRUE)) {
$store_matches[] = $fn;
$used_values[] = $value;
}
else {
// A footnote with the same label already exists.
// Use the text and id from the first footnote with this value.
// Any text in this footnote is discarded.
$i = array_search($value, $used_values);
$i = array_search($value, $used_values, TRUE);
$fn['text'] = $store_matches[$i]['text'];
$fn['text_clean'] = $store_matches[$i]['text_clean'];
$fn['fn_id'] = $store_matches[$i]['fn_id'];
// Push the new ref_id into the first occurrence of this footnote label
// The stored footnote thus holds a list of ref_id's rather than just one
// id.
$ref_array = is_array($store_matches[$i]['ref_id']) ? $store_matches[$i]['ref_id'] : [$store_matches[$i]['ref_id']];
array_push($ref_array, $fn['ref_id']);
$ref_array[] = $fn['ref_id'];
$store_matches[$i]['ref_id'] = $ref_array;
}
// Return the item themed into a footnote link.
// Drupal 7 requires we use "render element" which just introduces a wrapper
// around the old array.
$fn = [
'#theme' => 'footnote_link',
'#fn' => $fn,
$render_array = [
'#theme' => 'bibcite_footnote_link',
'fn' => $fn,
];
$result = $this->renderer->render($fn);
return $result;
return (string) $this->renderer->render($render_array, FALSE);
}
/**
* Helper function to return a random text string.
*
* @return string
* Random (lowercase) alphanumeric string.
*/
public function randstr() {
$chars = "abcdefghijklmnopqrstuwxyz1234567890";
$str = "";
private function findFootnote(string $text, string $reference, array &$store_matches): string|false {
foreach ($store_matches as $fn) {
if (($fn['text'] ?? NULL) === $text && ($fn['reference'] ?? NULL) === $reference) {
return (string) $fn['value'];
}
}
return FALSE;
}
// Seeding with srand() not necessary in modern PHP versions.
for ($i = 0; $i < 7; $i++) {
$n = rand(0, strlen($chars) - 1);
$str .= substr($chars, $n, 1);
protected function extractAttribute(array $matches, string $attribute): string {
$value = '';
if (!empty($matches[1])) {
if (preg_match('|' . preg_quote($attribute, '|') . '=["\'](.*?)["\']|', $matches[1], $value_match)) {
$value = $value_match[1];
}
return $str;
elseif (preg_match('|' . preg_quote($attribute, '|') . '=(\S*)|', $matches[1], $value_match)) {
$value = $value_match[1];
}
}
return $value;
}
/**
* Create the settings form for the filter.
*
* @param array $form
* A minimally prepopulated form array.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The state of the (entire) configuration form.
*
* @return array
* The $form array with additional form elements for the settings of
* this filter. The submitted form values should match $this->settings.
*
* @todo Add validation of submited form values, it already exists for
* drupal 7, must update it only.
*/
public function settingsForm(array $form, FormStateInterface $form_state) {
$settings['footnotes_collapse'] = [
'#type' => 'checkbox',
'#title' => $this->t('Collapse footnotes with identical content'),
'#default_value' => $this->settings['footnotes_collapse'],
'#description' => $this->t('If two footnotes have the exact same content, they will be collapsed into one as if using the same value="" attribute.'),
];
$settings['footnotes_html'] = [
'#type' => 'checkbox',
'#title' => $this->t('Handle footnote text as HTML'),
'#default_value' => $this->settings['footnotes_html'],
'#description' => $this->t('If not checked, a HTML tag in the footnote text will be shown as-is to the user.'),
];
return $settings;
protected function ibidemify(array &$footnotes): void {
$prev_reference_id = FALSE;
foreach ($footnotes as $index => $fn) {
if ($prev_reference_id && ($fn['reference'] ?? NULL) === $prev_reference_id) {
$footnotes[$index]['ibid'] = TRUE;
continue;
}
$prev_reference_id = $fn['reference'] ?? FALSE;
}
}
/**
* Search the $store_matches array for footnote text that matches.
*
* Note: This does a linear search on the $store_matches array. For a large
* list of footnotes it would be more efficient to maintain a separate array
* with the footnote content as key, in order to do a hash lookup at this
* stage. Since you typically only have a handful of footnotes, this simple
* search is assumed to be more efficient, but was not tested.
*
* @param string $text
* The footnote text.
* @param array $store_matches
* The matches array.
*
* @return string|false
* The value of the existing footnote, FALSE otherwise.
*/
private function findFootnote($text, array &$store_matches) {
if (!empty($store_matches)) {
foreach ($store_matches as &$fn) {
if ($fn['text'] == $text) {
return $fn['value'];
public function process($text, $langcode): FilterProcessResult {
if (is_array($text)) {
$text = implode('', $text);
}
$text = preg_replace('|\[fn([^\]]*)\]|', '<fn$1>', (string) $text);
$text = preg_replace('|\[/fn\]|', '</fn>', (string) $text);
$text = preg_replace('|\[footnotes([^\]]*)\]|', '<footnotes$1>', (string) $text);
$foo = [];
$open_tags = preg_match_all("|<fn([^>]*)>|", $text, $foo);
$close_tags = preg_match_all("|</fn>|", $text, $foo);
if ($open_tags === $close_tags + 1) {
$text .= '</fn>';
}
elseif ($open_tags > $close_tags + 1) {
// Keep behavior, but avoid translating inside trigger_error message.
trigger_error('You have unclosed fn tags. This is invalid and will produce unpredictable results.');
}
return FALSE;
// Let the callback know which options to use.
$this->replaceCallback($this->settings, 'prepare');
$pattern = '|<fn([^>]*)>(.*?)</fn>|s';
$text = preg_replace_callback($pattern, [$this, 'replaceCallback'], $text);
$footer = $this->replaceCallback([], 'output footer');
$pattern = '|(<footnotes([^\]]*)>)|';
if (preg_match($pattern, $text) > 0) {
$text = preg_replace($pattern, (string) $footer, $text, 1);
}
elseif (!empty($footer)) {
$text .= "\n\n" . $footer;
}
$result = new FilterProcessResult((string) $text);
$result->setAttachments([
'library' => ['footnotes/footnotes'],
]);
return $result;
}
public function randstr(): string {
$chars = 'abcdefghijklmnopqrstuwxyz1234567890';
$str = '';
for ($i = 0; $i < 7; $i++) {
$n = rand(0, strlen($chars) - 1);
$str .= substr($chars, $n, 1);
}
return $str;
}
}

2
templates/footnote-link.html.twig

@ -1,2 +0,0 @@
{# footnotes/footnote-link.html.twig #}
<a class="see-footnote" id="{{ fn.ref_id }}" title="{{ fn.text_clean }}" href="#{{ fn.fn_id }}">{{ fn.value }}</a>

23
templates/footnote-list.html.twig

@ -1,23 +0,0 @@
{# footnotes/footnote-list.html.twig #}
<ul class="footnotes">
{% for fn in footnotes %}
{% if fn.ref_id is iterable %}
{#
// Output footnote that has more than one reference to it in the body.
// The only difference is to insert backlinks to all references.
// Helper: we need to enumerate a, b, c...
#}
{% set abc = "abcdefghijklmnopqrstuvwxyz"|split('') %}
{% set i = 0 %}
<li class="footnote" id="{{ fn.fn_id }}"><a href="#{{ fn.ref_id.0 }}" class="footnote-label">{{ fn.value }}</a>
{% for ref in fn.ref_id %}
<a class="footnote-multi" href="#{{ ref }}">{{ attribute(abc, i) }}</a>
{% set i = i + 1 %}
{% endfor %}
{{ fn.text|raw }}</li>
{% else %}
{# Output normal footnote. #}
<li class="footnote" id="{{ fn.fn_id }}"><a class="footnote-label" href="#{{ fn.ref_id }}">{{ fn.value }}</a>{{ fn.text|raw }}</li>
{% endif %}
{% endfor %}
</ul>

168
tests/src/Functional/FootnotesFilterPluginTest.php

@ -1,168 +0,0 @@
<?php
namespace Drupal\Tests\footnotes\Functional;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Tests\BrowserTestBase;
/**
* Contains Footnotes Filter plugin functionality tests.
*
* @group footnotes
*/
class FootnotesFilterPluginTest extends BrowserTestBase {
use StringTranslationTrait;
/**
* {@inheritdoc}
*/
protected static $modules = [
'fakeobjects',
'footnotes',
'node',
];
/**
* An user with permissions to proper permissions.
*
* @var \Drupal\user\UserInterface
*/
protected $adminUser;
/**
* Text format name.
*
* @var string
*/
protected $formatName;
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
// Create a filter admin user.
$permissions = [
'administer filters',
'administer nodes',
'access administration pages',
'administer site configuration',
];
$this->adminUser = $this->drupalCreateUser($permissions);
$this->formatName = strtolower($this->randomMachineName());
$this->drupalLogin($this->adminUser);
$this->createTextFormat();
$this->drupalCreateContentType(['type' => 'page']);
}
/**
* Tests CKEditor Filter plugin functionality.
*/
public function testDefaultFunctionality() {
// Verify a title with HTML entities is properly escaped.
$text1 = 'This is the note one.';
$note1 = '[fn]' . $text1 . '[/fn]';
$text2 = 'And this is the note two.';
$note2 = "<fn>$text2</fn>";
$body = '<p>' . $this->randomMachineName(100) . $note1 . '</p><p>' .
$this->randomMachineName(100) . $note2 . '</p>';
// Create a node.
$node = $this->drupalCreateNode([
'title' => $this->randomString(),
'body' => [
0 => [
'value' => $body,
'format' => $this->formatName,
],
],
]);
$this->drupalGet('node/' . $node->id());
// Footnote with [fn].
$this->assertNoRaw($note1);
$this->assertText($text1);
// Footnote with <fn>.
$this->assertNoRaw($note2);
$this->assertText($text2);
// Css file:
$this->assertRaw('/assets/css/footnotes.css');
// @todo currently additional settings doesn't work as expected.
// So we don't check additional settings for now.
// $this->createTextFormat(TRUE);
$text1 = 'This is the note one.';
$note1 = "[fn value='1']{$text1}[/fn]";
$text2 = 'And this is the note two.';
$note2 = "<fn value='1'>{$text2}</fn>";
$body = '<p>' . $this->randomMachineName(100) . $note1 . '</p><p>' .
$this->randomMachineName(100) . $note2 . '</p>';
// Create a node.
$node = $this->drupalCreateNode([
'title' => $this->randomString(),
'body' => [
0 => [
'value' => $body,
'format' => $this->formatName,
],
],
]);
$this->drupalGet('node/' . $node->id());
// Footnote with [fn].
$this->assertNoRaw($note1);
$this->assertText($text1);
// Elements with the same value should be collapsed.
// @todo This should work only if footnotes_collapse setting is enabled.
$this->assertNoRaw($note2);
$this->assertNoText($text2);
}
/**
* Create a new text format.
*
* @param bool $additional_settings
* Indicates if filter settings should be enabled.
*/
protected function createTextFormat($additional_settings = FALSE) {
$button_groups = json_encode([
[
[
'name' => 'Tools',
'items' => ['Source', 'footnotes'],
],
],
]);
$edit = [
'format' => $this->formatName,
'name' => $this->formatName,
'roles[' . AccountInterface::AUTHENTICATED_ROLE . ']' => TRUE,
'editor[editor]' => 'ckeditor',
'filters[filter_footnotes][status]' => TRUE,
];
$this->drupalGet("admin/config/content/formats/add");
// Keep the "CKEditor" editor selected and click the "Configure" button.
$this->drupalPostForm(NULL, $edit, 'editor_configure');
$edit['editor[settings][toolbar][button_groups]'] = $button_groups;
$edit['filters[filter_footnotes][settings][footnotes_collapse]'] = $button_groups;
if ($additional_settings) {
$edit['filters[filter_footnotes][settings][footnotes_collapse]'] = 1;
$edit['filters[filter_footnotes][settings][footnotes_html]'] = 1;
}
$this->drupalPostForm(NULL, $edit, $this->t('Save configuration'));
$this->assertText($this->t('Added text format @format.', ['@format' => $this->formatName]));
}
}

176
tests/src/FunctionalJavascript/FootnotesCkeditorPluginTest.php

@ -1,176 +0,0 @@
<?php
namespace Drupal\Tests\footnotes\FunctionalJavascript;
use Drupal\Core\Entity\Entity\EntityFormDisplay;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\editor\Entity\Editor;
use Drupal\field\Entity\FieldConfig;
use Drupal\field\Entity\FieldStorageConfig;
use Drupal\filter\Entity\FilterFormat;
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
use Drupal\node\Entity\NodeType;
use Drupal\Tests\ckeditor\Traits\CKEditorTestTrait;
/**
* Contains Footnotes CKEditor plugin functionality tests.
*
* @group footnotes
*/
class FootnotesCkeditorPluginTest extends WebDriverTestBase
{
use StringTranslationTrait;
use CKEditorTestTrait;
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* The account.
*
* @var \Drupal\user\UserInterface
*/
protected $account;
/**
* The FilterFormat config entity used for testing.
*
* @var \Drupal\filter\FilterFormatInterface
*/
protected $filterFormat;
/**
* {@inheritdoc}
*/
public static $modules = ['node', 'ckeditor', 'filter', 'ckeditor_test', 'fakeobjects', 'footnotes'];
/**
* {@inheritdoc}
*/
protected function setUp()
{
parent::setUp();
// Create a text format and associate CKEditor.
$this->filterFormat = FilterFormat::create([
'format' => 'filtered_html',
'name' => 'Filtered HTML',
'filters' => [
'filter_footnotes' => [
'status' => TRUE,
'settings' => [
'footnotes_collapse' => 0,
'footnotes_html' => 0
],
],
],
]);
$this->filterFormat->save();
Editor::create([
'format' => 'filtered_html',
'editor' => 'ckeditor',
'settings' => [
'toolbar' => [
'rows' => [
[
[
'name' => 'All the things',
'items' => [
'Source',
'Bold',
'Italic',
'footnotes',
],
],
],
],
],
],
])->save();
// Create a node type for testing.
NodeType::create(['type' => 'page', 'name' => 'page'])->save();
$field_storage = FieldStorageConfig::loadByName('node', 'body');
// Create a body field instance for the 'page' node type.
FieldConfig::create([
'field_storage' => $field_storage,
'bundle' => 'page',
'label' => 'Body',
'settings' => ['display_summary' => TRUE],
'required' => TRUE,
])->save();
// Assign widget settings for the 'default' form mode.
EntityFormDisplay::create([
'targetEntityType' => 'node',
'bundle' => 'page',
'mode' => 'default',
'status' => TRUE,
])->setComponent('body', ['type' => 'text_textarea_with_summary'])
->save();
$this->account = $this->drupalCreateUser([
'administer nodes',
'create page content',
'use text format filtered_html',
]);
$this->drupalLogin($this->account);
}
/**
* Tests CKEditor plugin functionality for body field.
*/
public function testUi()
{
$session = $this->getSession();
$assert_session = $this->assertSession();
$this->drupalGet("node/add/page");
$page = $session->getPage();
$this->waitForEditor();
$this->pressEditorButton('footnotes');
$this->assertNotEmpty(
$assert_session->waitForElementVisible('css', '.cke_1.cke_editor_edit-body-0-value_dialog')
);
$assert_session->elementTextContains('css', 'table.cke_dialog .cke_dialog_title', $this->t('Footnotes Dialog'));
$assert_session->elementTextContains('css', '.cke_dialog_page_contents table tr:first-child', $this->t('Footnote text :'));
$assert_session->elementTextContains('css', '.cke_dialog_page_contents table tr:last-child', $this->t('Value :'));
$page->find('css', 'a.cke_dialog_ui_button_cancel')->click();
$this->assertEmpty($assert_session->elementExists('css', '.cke_1.cke_editor_edit-body-0-value_dialog')->isVisible());
$texts = ['Text one.', 'Text two.', 'Text tree', 'Text four', 'Text five'];
foreach ($texts as $key => $value) {
$this->pressEditorButton('footnotes');
$this->assertNotEmpty(
$assert_session->waitForElementVisible('css', '.cke_1.cke_editor_edit-body-0-value_dialog')
);
$assert_session->elementExists('css', '.cke_dialog_page_contents table tr:last-child input')->setValue($key);
$assert_session->elementExists('css', '.cke_dialog_page_contents table tr:first-child input')->setValue($value);
$page->find('css', 'a.cke_dialog_ui_button_ok')->click();
$this->assertEmpty($assert_session->elementExists('css', '.cke_1.cke_editor_edit-body-0-value_dialog')->isVisible());
}
$this->pressEditorButton('source');
$body_value = $assert_session->elementExists('css', '.cke .cke_contents .cke_source')->getValue();
$body_value = str_replace(["\r\n", "\r", "\n"], "", $body_value);
$body_value = trim($body_value);
$expected_value = '<p>';
foreach ($texts as $key => $value) {
$expected_value .= '<fn value="' . $key . '">' . $value . '</fn>';
}
$expected_value .= '</p>';
$this->assertEqual($body_value, $expected_value, $this->t('String, formed by CKEditor, is correct.'));
}
}
Loading…
Cancel
Save