diff --git a/app/modules/niklan/assets/js/footnote.tooltip.js b/app/modules/niklan/assets/js/footnote.tooltip.js
new file mode 100644
index 00000000..9bd68978
--- /dev/null
+++ b/app/modules/niklan/assets/js/footnote.tooltip.js
@@ -0,0 +1,93 @@
+// cspell:ignore UIDOM
+((Drupal, once, { computePosition, offset, shift, flip, inline }) => {
+
+ const createTooltipElement = (content) => {
+ const tooltip = document.createElement('div');
+ tooltip.innerHTML = Drupal.theme.niklanFootnoteTooltipWrapper(content);
+ return tooltip.firstElementChild;
+ };
+
+ const setupTooltipBehavior = (footnoteLink, context) => {
+ const href = footnoteLink.getAttribute('href');
+ const id = href.startsWith('#') ? href.substring(1) : href;
+ const footnoteContent = context.querySelector(`#${CSS.escape(id)}`);
+
+ if (!footnoteContent) return null;
+
+ let tooltip = null;
+ let hideTimeout = null;
+
+ const cancelScheduledHide = () => {
+ clearTimeout(hideTimeout);
+ hideTimeout = null;
+ };
+
+ const scheduleHide = () => {
+ hideTimeout = setTimeout(() => {
+ tooltip?.style.setProperty('visibility', 'hidden');
+ tooltip?.remove();
+ tooltip = null;
+ }, 300);
+ };
+
+ const setupEventListeners = (element) => {
+ element.addEventListener('mouseenter', cancelScheduledHide);
+ element.addEventListener('mouseleave', scheduleHide);
+ };
+
+ const updatePosition = async () => {
+ if (!tooltip) {
+ tooltip = createTooltipElement(footnoteContent);
+ document.body.appendChild(tooltip);
+ setupEventListeners(tooltip);
+ }
+
+ try {
+ const { x, y } = await computePosition(footnoteLink, tooltip, {
+ strategy: 'absolute',
+ placement: 'top',
+ middleware: [inline(), flip({ padding: 16 }), offset(16), shift({ padding: 16 })]
+ });
+
+ Object.assign(tooltip.style, {
+ left: `${x}px`,
+ top: `${y}px`,
+ visibility: 'visible'
+ });
+ } catch (error) {
+ console.error('Tooltip positioning failed:', error);
+ }
+ };
+
+ const handleLinkEnter = () => {
+ cancelScheduledHide();
+ updatePosition();
+ };
+
+ footnoteLink.addEventListener('mouseenter', handleLinkEnter);
+ footnoteLink.addEventListener('mouseleave', scheduleHide);
+
+ return () => {
+ footnoteLink.removeEventListener('mouseenter', handleLinkEnter);
+ footnoteLink.removeEventListener('mouseleave', scheduleHide);
+ tooltip?.remove();
+ };
+ };
+
+ Drupal.theme.niklanFootnoteTooltipWrapper = (content) => `
+
+ `;
+
+ Drupal.behaviors.niklanFootnoteTooltip = {
+ attach: (context) => {
+ const callback = () => once('niklan-footnote-tooltip', '.footnote-ref', context)
+ .forEach(link => setupTooltipBehavior(link, context));
+
+ // eslint-disable-next-line no-unused-expressions
+ window.requestIdleCallback ? requestIdleCallback(callback) : callback();
+ }
+ };
+
+})(Drupal, once, FloatingUIDOM);
diff --git a/app/modules/niklan/niklan.libraries.yml b/app/modules/niklan/niklan.libraries.yml
index 426b785f..dbff32ca 100644
--- a/app/modules/niklan/niklan.libraries.yml
+++ b/app/modules/niklan/niklan.libraries.yml
@@ -62,3 +62,12 @@ content-editing.toolbar:
css:
theme:
assets/css/content-editing.toolbar.css: { }
+
+footnote.tooltip:
+ js:
+ assets/js/footnote.tooltip.js: { }
+ dependencies:
+ - core/drupal
+ - core/once
+ # Intentional, to avoid JS conflicts.
+ - core/internal.floating-ui
\ No newline at end of file
diff --git a/app/modules/niklan/niklan.services.yml b/app/modules/niklan/niklan.services.yml
index cd74222a..9d7a3e03 100644
--- a/app/modules/niklan/niklan.services.yml
+++ b/app/modules/niklan/niklan.services.yml
@@ -136,6 +136,7 @@ services:
Drupal\niklan\ExternalContent\Nodes\Callout\RenderArrayBuilder: {}
Drupal\niklan\ExternalContent\Nodes\CodeBlock\RenderArrayBuilder: {}
Drupal\niklan\ExternalContent\Nodes\MediaReference\RenderArrayBuilder: {}
+ Drupal\niklan\ExternalContent\Nodes\Footnote\RenderArrayBuilder: {}
# External Content: Extensions.
Drupal\niklan\ExternalContent\Extension\RenderArrayBuilderExtension: {}
diff --git a/app/modules/niklan/src/ExternalContent/Extension/RenderArrayBuilderExtension.php b/app/modules/niklan/src/ExternalContent/Extension/RenderArrayBuilderExtension.php
index 333fed39..6174190b 100644
--- a/app/modules/niklan/src/ExternalContent/Extension/RenderArrayBuilderExtension.php
+++ b/app/modules/niklan/src/ExternalContent/Extension/RenderArrayBuilderExtension.php
@@ -11,6 +11,7 @@
use Drupal\niklan\ExternalContent\Nodes\CodeBlock\RenderArrayBuilder as CodeBlockBuilder;
use Drupal\niklan\ExternalContent\Nodes\Figcaption\RenderArrayBuilder as FigcaptionBuilder;
use Drupal\niklan\ExternalContent\Nodes\Figure\RenderArrayBuilder as FigureBuilder;
+use Drupal\niklan\ExternalContent\Nodes\Footnote\RenderArrayBuilder as FootnoteBuilder;
use Drupal\niklan\ExternalContent\Nodes\MediaReference\RenderArrayBuilder as MediaReferenceBuilder;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
@@ -31,6 +32,7 @@ public function __construct(
private CalloutBuilder $calloutBuilder,
private MediaReferenceBuilder $mediaReferenceBuilder,
private ArticleLinkBuilder $articleLinkBuilder,
+ private FootnoteBuilder $footnoteBuilder,
) {}
public function register(object $target): void {
@@ -40,6 +42,7 @@ public function register(object $target): void {
$target->add($this->articleLinkBuilder, 10);
$target->add(new FigureBuilder());
$target->add(new FigcaptionBuilder());
+ $target->add($this->footnoteBuilder);
}
}
diff --git a/app/modules/niklan/src/ExternalContent/Nodes/Footnote/RenderArrayBuilder.php b/app/modules/niklan/src/ExternalContent/Nodes/Footnote/RenderArrayBuilder.php
new file mode 100644
index 00000000..693bfc16
--- /dev/null
+++ b/app/modules/niklan/src/ExternalContent/Nodes/Footnote/RenderArrayBuilder.php
@@ -0,0 +1,45 @@
+
+ */
+final readonly class RenderArrayBuilder implements Builder {
+
+ public function supports(Node $node): bool {
+ return $node instanceof HtmlElement
+ && isset($node->attributes['class'])
+ && \str_contains($node->attributes['class'], 'footnote-ref');
+ }
+
+ public function buildElement(Node $node, ChildBuilder $child_builder): RenderArray {
+ $element = new RenderArray([
+ '#type' => 'html_tag',
+ '#tag' => $node->tag,
+ '#attributes' => $node->attributes,
+ '#pre_render' => [
+ HtmlTag::preRenderHtmlTag(...),
+ HtmlTagHelper::preRenderTag(...),
+ ],
+ '#attached' => [
+ 'library' => [
+ 'niklan/footnote.tooltip',
+ ],
+ ],
+ ]);
+ $child_builder->buildChildren($node, $element);
+ return $element;
+ }
+
+}
diff --git a/app/modules/niklan/src/Markup/Markdown/Extension/ArticleMarkdownExtension.php b/app/modules/niklan/src/Markup/Markdown/Extension/ArticleMarkdownExtension.php
index 3c72cc5e..42772043 100644
--- a/app/modules/niklan/src/Markup/Markdown/Extension/ArticleMarkdownExtension.php
+++ b/app/modules/niklan/src/Markup/Markdown/Extension/ArticleMarkdownExtension.php
@@ -57,6 +57,12 @@ public function configureSchema(ConfigurationBuilderInterface $builder): void {
// Note that soft break must be a space, or words will be merged.
'soft_break' => ' ',
]);
+
+ // Default prefixes are not valid CSS IDs (with ':' char).
+ $builder->set('footnote', [
+ 'ref_id_prefix' => 'fn-ref-',
+ 'footnote_id_prefix' => 'fn-',
+ ]);
}
}
diff --git a/app/themes/laszlo/assets/css/02-element/code.css b/app/themes/laszlo/assets/css/02-element/code.css
index 68106c81..bd95f196 100644
--- a/app/themes/laszlo/assets/css/02-element/code.css
+++ b/app/themes/laszlo/assets/css/02-element/code.css
@@ -19,5 +19,6 @@
word-break: break-word;
border-radius: var(--spacing-1);
background: var(--color-surface-variant);
+ font-size: 0.8em;
}
}
diff --git a/app/themes/laszlo/assets/css/02-element/html.css b/app/themes/laszlo/assets/css/02-element/html.css
index e2e2b844..de6fef2d 100644
--- a/app/themes/laszlo/assets/css/02-element/html.css
+++ b/app/themes/laszlo/assets/css/02-element/html.css
@@ -1,5 +1,6 @@
@layer element {
html {
font-size: 16px;
+ scroll-behavior: smooth;
}
}
diff --git a/app/themes/laszlo/assets/css/03-component/ui/prose.css b/app/themes/laszlo/assets/css/03-component/ui/prose.css
index 0b9a10ac..722b5c4c 100644
--- a/app/themes/laszlo/assets/css/03-component/ui/prose.css
+++ b/app/themes/laszlo/assets/css/03-component/ui/prose.css
@@ -86,16 +86,23 @@
--link-color: var(--color-on-secondary-container);
padding: var(--spacing-1) var(--spacing-2);
+ transition: all ease-in-out 0.1s;
text-decoration: none;
border-radius: var(--spacing-4);
background-color: var(--color-secondary-container);
font: var(--typography-label-small);
+
+ &:hover {
+ --link-color: var(--color-on-secondary);
+
+ background-color: var(--color-secondary);
+ }
}
.prose .footnotes {
--link-text-decoration-thickness: 1px;
- font: var(--typography-body-medium);
+ font: var(--typography-body-small);
}
.prose .footnotes hr {
diff --git a/app/themes/laszlo/assets/css/03-component/ui/tooltip.css b/app/themes/laszlo/assets/css/03-component/ui/tooltip.css
new file mode 100644
index 00000000..95b3fee2
--- /dev/null
+++ b/app/themes/laszlo/assets/css/03-component/ui/tooltip.css
@@ -0,0 +1,21 @@
+.tooltip {
+ --link-text-decoration-thickness: 1px;
+
+ position: absolute;
+ width: 100%;
+ max-width: 360px;
+ padding: var(--spacing-4);
+ color: var(--color-on-background);
+ border: 1px solid var(--color-outline-variant);
+ border-radius: var(--spacing-2);
+ background: color-mix(in sRGB, var(--color-background), transparent);
+ box-shadow:
+ rgb(0 0 0 / 0.1) 0 20px 25px -5px,
+ rgb(0 0 0 / 0.04) 0 10px 10px -5px;
+ font: var(--typography-body-medium);
+ backdrop-filter: blur(20px);
+}
+
+.tooltip .footnote-backref {
+ display: none;
+}
diff --git a/app/themes/laszlo/laszlo.info.yml b/app/themes/laszlo/laszlo.info.yml
index 54041e2f..0a37aedb 100644
--- a/app/themes/laszlo/laszlo.info.yml
+++ b/app/themes/laszlo/laszlo.info.yml
@@ -21,4 +21,6 @@ libraries-override:
libraries-extend:
niklan/hljs:
- laszlo/hljs
+ niklan/footnote.tooltip:
+ - laszlo/tooltip
core/drupal.message: false
diff --git a/app/themes/laszlo/laszlo.libraries.yml b/app/themes/laszlo/laszlo.libraries.yml
index f59000e9..ac569bff 100644
--- a/app/themes/laszlo/laszlo.libraries.yml
+++ b/app/themes/laszlo/laszlo.libraries.yml
@@ -57,4 +57,9 @@ navigation-active-trail:
comment-comment-node-blog-entry-form:
css:
theme:
- assets/css/03-component/form/comment-comment-node-blog-entry-form.css: { }
\ No newline at end of file
+ assets/css/03-component/form/comment-comment-node-blog-entry-form.css: { }
+
+tooltip:
+ css:
+ theme:
+ assets/css/03-component/ui/tooltip.css: { }