From 482a288c9fb7391d16fabb3f78c360155ddc9228 Mon Sep 17 00:00:00 2001 From: Niklan Date: Mon, 4 Aug 2025 21:15:51 +0500 Subject: [PATCH 1/3] Add tooltip for footnotes --- .../niklan/assets/js/footnote.tooltip.js | 92 +++++++++++++++++++ app/modules/niklan/niklan.libraries.yml | 9 ++ app/modules/niklan/niklan.services.yml | 1 + .../Extension/RenderArrayBuilderExtension.php | 9 +- .../Nodes/Footnote/RenderArrayBuilder.php | 45 +++++++++ .../Extension/ArticleMarkdownExtension.php | 6 ++ .../laszlo/assets/css/02-element/code.css | 1 + .../assets/css/03-component/ui/prose.css | 2 +- .../assets/css/03-component/ui/tooltip.css | 19 ++++ app/themes/laszlo/laszlo.info.yml | 2 + app/themes/laszlo/laszlo.libraries.yml | 7 +- 11 files changed, 188 insertions(+), 5 deletions(-) create mode 100644 app/modules/niklan/assets/js/footnote.tooltip.js create mode 100644 app/modules/niklan/src/ExternalContent/Nodes/Footnote/RenderArrayBuilder.php create mode 100644 app/themes/laszlo/assets/css/03-component/ui/tooltip.css 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..1d12f9ff --- /dev/null +++ b/app/modules/niklan/assets/js/footnote.tooltip.js @@ -0,0 +1,92 @@ +((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) => ` +
+
${content.innerHTML}
+
+ `; + + 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..1a1099f8 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; @@ -27,10 +28,11 @@ public const string ID = 'niklan.render_array_builder'; public function __construct( - private CodeBlockBuilder $codeBlockBuilder, - private CalloutBuilder $calloutBuilder, + private CodeBlockBuilder $codeBlockBuilder, + private CalloutBuilder $calloutBuilder, private MediaReferenceBuilder $mediaReferenceBuilder, - private ArticleLinkBuilder $articleLinkBuilder, + 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..f3730285 --- /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/03-component/ui/prose.css b/app/themes/laszlo/assets/css/03-component/ui/prose.css index 91c33c1f..a7610c4e 100644 --- a/app/themes/laszlo/assets/css/03-component/ui/prose.css +++ b/app/themes/laszlo/assets/css/03-component/ui/prose.css @@ -95,7 +95,7 @@ .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..b51a3b58 --- /dev/null +++ b/app/themes/laszlo/assets/css/03-component/ui/tooltip.css @@ -0,0 +1,19 @@ +.tooltip { + --link-text-decoration-thickness: 1px; + + position: absolute; + width: 100%; + max-width: 360px; + padding: var(--spacing-4); + border: 1px solid var(--color-outline-variant); + border-radius: var(--spacing-2); + background: color-mix(in sRGB, var(--color-surface), transparent 25%); + box-shadow: rgb(0 0 0 / 0.25) 0 25px 50px -12px; + font: var(--typography-label-large); + backdrop-filter: saturate(280%) blur(40px); + color: var(--color-on-surface); +} + +.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: { } From 588ae8d0898b93dd7ddeab8e8d7dc447c00bc835 Mon Sep 17 00:00:00 2001 From: Niklan Date: Tue, 5 Aug 2025 10:07:12 +0500 Subject: [PATCH 2/3] Add tooltip for footnotes --- app/modules/niklan/assets/js/footnote.tooltip.js | 3 ++- .../Extension/RenderArrayBuilderExtension.php | 8 ++++---- .../Nodes/Footnote/RenderArrayBuilder.php | 4 ++-- .../laszlo/assets/css/03-component/ui/prose.css | 7 +++++++ .../laszlo/assets/css/03-component/ui/tooltip.css | 12 +++++++----- 5 files changed, 22 insertions(+), 12 deletions(-) diff --git a/app/modules/niklan/assets/js/footnote.tooltip.js b/app/modules/niklan/assets/js/footnote.tooltip.js index 1d12f9ff..9bd68978 100644 --- a/app/modules/niklan/assets/js/footnote.tooltip.js +++ b/app/modules/niklan/assets/js/footnote.tooltip.js @@ -1,3 +1,4 @@ +// cspell:ignore UIDOM ((Drupal, once, { computePosition, offset, shift, flip, inline }) => { const createTooltipElement = (content) => { @@ -75,7 +76,7 @@ Drupal.theme.niklanFootnoteTooltipWrapper = (content) => `
-
${content.innerHTML}
+
${content.innerHTML}
`; diff --git a/app/modules/niklan/src/ExternalContent/Extension/RenderArrayBuilderExtension.php b/app/modules/niklan/src/ExternalContent/Extension/RenderArrayBuilderExtension.php index 1a1099f8..6174190b 100644 --- a/app/modules/niklan/src/ExternalContent/Extension/RenderArrayBuilderExtension.php +++ b/app/modules/niklan/src/ExternalContent/Extension/RenderArrayBuilderExtension.php @@ -28,11 +28,11 @@ public const string ID = 'niklan.render_array_builder'; public function __construct( - private CodeBlockBuilder $codeBlockBuilder, - private CalloutBuilder $calloutBuilder, + private CodeBlockBuilder $codeBlockBuilder, + private CalloutBuilder $calloutBuilder, private MediaReferenceBuilder $mediaReferenceBuilder, - private ArticleLinkBuilder $articleLinkBuilder, - private FootnoteBuilder $footnoteBuilder, + private ArticleLinkBuilder $articleLinkBuilder, + private FootnoteBuilder $footnoteBuilder, ) {} public function register(object $target): void { diff --git a/app/modules/niklan/src/ExternalContent/Nodes/Footnote/RenderArrayBuilder.php b/app/modules/niklan/src/ExternalContent/Nodes/Footnote/RenderArrayBuilder.php index f3730285..693bfc16 100644 --- a/app/modules/niklan/src/ExternalContent/Nodes/Footnote/RenderArrayBuilder.php +++ b/app/modules/niklan/src/ExternalContent/Nodes/Footnote/RenderArrayBuilder.php @@ -13,14 +13,14 @@ use Drupal\external_content\Utils\HtmlTagHelper; /** - * @implements \Drupal\external_content\Contract\Builder\RenderArray\Builder + * @implements \Drupal\external_content\Contract\Builder\RenderArray\Builder<\Drupal\external_content\Nodes\HtmlElement\HtmlElement> */ 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'); + && \str_contains($node->attributes['class'], 'footnote-ref'); } public function buildElement(Node $node, ChildBuilder $child_builder): RenderArray { 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 644eb40b..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,10 +86,17 @@ --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 { diff --git a/app/themes/laszlo/assets/css/03-component/ui/tooltip.css b/app/themes/laszlo/assets/css/03-component/ui/tooltip.css index b51a3b58..95b3fee2 100644 --- a/app/themes/laszlo/assets/css/03-component/ui/tooltip.css +++ b/app/themes/laszlo/assets/css/03-component/ui/tooltip.css @@ -5,13 +5,15 @@ 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-surface), transparent 25%); - box-shadow: rgb(0 0 0 / 0.25) 0 25px 50px -12px; - font: var(--typography-label-large); - backdrop-filter: saturate(280%) blur(40px); - color: var(--color-on-surface); + 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 { From f89969bb4d6eacd5b5dd021ccd017addfa4137da Mon Sep 17 00:00:00 2001 From: Niklan Date: Tue, 5 Aug 2025 10:17:03 +0500 Subject: [PATCH 3/3] Add tooltip for footnotes --- app/themes/laszlo/assets/css/02-element/html.css | 1 + 1 file changed, 1 insertion(+) 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; } }