diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 3267d17a4..38e2812b2 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -2,10 +2,6 @@ ## [Unreleased] -### Refactored -* Split `MarkdownParser.fs` (1500 lines) into `MarkdownInlineParser.fs` (inline formatting) and `MarkdownParser.fs` (block-level parsing) for better maintainability. [#1022](https://github.com/fsprojects/FSharp.Formatting/issues/1022) -* Split pipe-table and Emacs-table parsing out of `MarkdownBlockParser.fs` into a new `MarkdownTableParser.fs` (196 lines), reducing `MarkdownBlockParser.fs` from 958 to 760 lines. [#1022](https://github.com/fsprojects/FSharp.Formatting/issues/1022) - ### Added * Add `dotnet fsdocs convert` command to convert a single `.md`, `.fsx`, or `.ipynb` file to HTML (or another output format) without building a full documentation site. [#811](https://github.com/fsprojects/FSharp.Formatting/issues/811) * `fsdocs convert` now accepts the input file as a positional argument (e.g. `fsdocs convert notebook.ipynb -o notebook.html`). [#1019](https://github.com/fsprojects/FSharp.Formatting/pull/1019) @@ -13,6 +9,8 @@ * `fsdocs convert` now accepts `-o` as a shorthand for `--output`. [#1019](https://github.com/fsprojects/FSharp.Formatting/pull/1019) ### Changed +* Tooltip elements (`div.fsdocs-tip`) now use the [Popover API](https://developer.mozilla.org/en-US/docs/Web/API/Popover_API) (Baseline 2024: Chrome 114+, Firefox 125+, Safari 17+). Tooltips are placed in the browser's top layer — no `z-index` needed, always above all other content. Fixes a positioning bug where tooltips appeared offset when the page was scrolled. The previous `display`-toggle fallback has been removed. Tooltips also fade in with a subtle animation. [#422](https://github.com/fsprojects/FSharp.Formatting/issues/422), [#1061](https://github.com/fsprojects/FSharp.Formatting/pull/1061) +* Generated code tokens no longer use inline `onmouseover`/`onmouseout` event handlers. Tooltips are now triggered via `data-fsdocs-tip` / `data-fsdocs-tip-unique` attributes and a delegated event listener in `fsdocs-tips.js`. The `popover` attribute is also added to API-doc tooltip divs so they use the same top-layer path. [#1061](https://github.com/fsprojects/FSharp.Formatting/pull/1061) * Changed `range` fields in `MarkdownSpan` and `MarkdownParagraph` DU cases from `MarkdownRange option` to `MarkdownRange`, using `MarkdownRange.zero` as the default/placeholder value instead of `None`. * When no template is provided (e.g. `fsdocs convert` without `--template`), `fsdocs-tip` tooltip divs are no longer included in the output. Tooltips require JavaScript/CSS from a template to function, so omitting them produces cleaner raw output. [#1019](https://github.com/fsprojects/FSharp.Formatting/pull/1019) diff --git a/docs/content/fsdocs-default.css b/docs/content/fsdocs-default.css index 41981afc2..d54b0359b 100644 --- a/docs/content/fsdocs-default.css +++ b/docs/content/fsdocs-default.css @@ -983,8 +983,6 @@ table.pre, code, pre.fssnip { /* tooltips */ div.fsdocs-tip { - z-index: 1000; - display: none; background-color: var(--doc-tip-background); border-radius: var(--radius); border: 1px solid var(--header-border); @@ -993,16 +991,30 @@ div.fsdocs-tip { font-variant-ligatures: none; color: var(--code-color); box-shadow: 0 1px 1px var(--shadow-color); + margin: 0; & code { color: var(--code-color); } } -span[onmouseout] { +@keyframes fsdocs-tip-fade-in { + from { opacity: 0; } + to { opacity: 1; } +} + +/* Reset UA inset so JS-supplied left/top control position. */ +div.fsdocs-tip:popover-open { + position: fixed; + inset: unset; + animation: fsdocs-tip-fade-in 120ms ease-out; +} + +[data-fsdocs-tip] { cursor: pointer; } + /* API docs */ #content > div > h2:first-child { margin-top: 0; @@ -1212,7 +1224,7 @@ span[onmouseout] { } /* Search */ -::backdrop { +dialog::backdrop { background-color: #020202; opacity: 0.5; } diff --git a/docs/content/fsdocs-tips.js b/docs/content/fsdocs-tips.js index 873882535..7bb111290 100644 --- a/docs/content/fsdocs-tips.js +++ b/docs/content/fsdocs-tips.js @@ -1,19 +1,26 @@ let currentTip = null; let currentTipElement = null; -function hideTip(evt, name, unique) { +function hideTip(name) { const el = document.getElementById(name); - el.style.display = "none"; + if (el) { + try { el.hidePopover(); } catch (_) { } + } currentTip = null; + currentTipElement = null; } -function hideUsingEsc(e) { - hideTip(e, currentTipElement, currentTip); -} - -function showTip(evt, name, unique, owner) { - document.onkeydown = hideUsingEsc; +function showTip(evt, name, unique) { if (currentTip === unique) return; + + // Hide the previously shown tooltip before showing the new one + if (currentTipElement !== null) { + const prev = document.getElementById(currentTipElement); + if (prev) { + try { prev.hidePopover(); } catch (_) { } + } + } + currentTip = unique; currentTipElement = name; @@ -22,28 +29,45 @@ function showTip(evt, name, unique, owner) { let y = evt.clientY + offset; const el = document.getElementById(name); - el.style.position = "absolute"; - el.style.display = "block"; - el.style.left = `${x}px`; - el.style.top = `${y}px`; const maxWidth = document.documentElement.clientWidth - x - 16; el.style.maxWidth = `${maxWidth}px`; + el.style.left = `${x}px`; + el.style.top = `${y}px`; + + try { el.showPopover(); } catch (_) { } - const rect = el.getBoundingClientRect(); - // Move tooltip if it is out of sight - if(rect.bottom > window.innerHeight) { + const rect = el.getBoundingClientRect(); + // Move tooltip if it would appear outside the viewport + if (rect.bottom > window.innerHeight) { y = y - el.clientHeight - offset; el.style.top = `${y}px`; } - if (rect.right > window.innerWidth) { - x = y - el.clientWidth - offset; + x = x - el.clientWidth - offset; el.style.left = `${x}px`; - const maxWidth = document.documentElement.clientWidth - x - 16; - el.style.maxWidth = `${maxWidth}px`; + el.style.maxWidth = `${document.documentElement.clientWidth - x - 16}px`; } } +// Event delegation: trigger tooltips from data-fsdocs-tip attributes +document.addEventListener('mouseover', function (evt) { + const target = evt.target.closest('[data-fsdocs-tip]'); + if (!target) return; + const name = target.dataset.fsdocsTip; + const unique = parseInt(target.dataset.fsdocsTipUnique, 10); + showTip(evt, name, unique); +}); + +document.addEventListener('mouseout', function (evt) { + const target = evt.target.closest('[data-fsdocs-tip]'); + if (!target) return; + // Only hide when the mouse has left the trigger element entirely + if (target.contains(evt.relatedTarget)) return; + const name = target.dataset.fsdocsTip; + const unique = parseInt(target.dataset.fsdocsTipUnique, 10); + hideTip(name); +}); + function Clipboard_CopyTo(value) { if (navigator.clipboard) { navigator.clipboard.writeText(value); @@ -57,7 +81,4 @@ function Clipboard_CopyTo(value) { } } -window.showTip = showTip; -window.hideTip = hideTip; -// Used by API documentation -window.Clipboard_CopyTo = Clipboard_CopyTo; \ No newline at end of file +window.Clipboard_CopyTo = Clipboard_CopyTo; diff --git a/src/FSharp.Formatting.ApiDocs/GenerateHtml.fs b/src/FSharp.Formatting.ApiDocs/GenerateHtml.fs index e2f38c5f8..0303f325b 100644 --- a/src/FSharp.Formatting.ApiDocs/GenerateHtml.fs +++ b/src/FSharp.Formatting.ApiDocs/GenerateHtml.fs @@ -48,14 +48,9 @@ type HtmlRender(model: ApiDocModel, ?menuTemplateFolder: string) = div [] [ let id = UniqueID().ToString() - code - [ - OnMouseOut(sprintf "hideTip(event, '%s', %s)" id id) - OnMouseOver(sprintf "showTip(event, '%s', %s)" id id) - ] - content + code [ Custom("data-fsdocs-tip", id); Custom("data-fsdocs-tip-unique", id) ] content - div [ Class "fsdocs-tip"; Id id ] tip + div [ Custom("popover", ""); Class "fsdocs-tip"; Id id ] tip ] let sourceLink url = diff --git a/src/FSharp.Formatting.CodeFormat/HtmlFormatting.fs b/src/FSharp.Formatting.CodeFormat/HtmlFormatting.fs index 307406abe..a86a35997 100644 --- a/src/FSharp.Formatting.CodeFormat/HtmlFormatting.fs +++ b/src/FSharp.Formatting.CodeFormat/HtmlFormatting.fs @@ -23,8 +23,8 @@ type ToolTipFormatter(prefix) = let mutable count = 0 let mutable uniqueId = 0 - /// Formats tip and returns assignments for 'onmouseover' and 'onmouseout' - member x.FormatTip (tip: ToolTipSpans) overlapping formatFunction = + /// Formats tip and returns data attributes for tooltip triggering + member x.FormatTip (tip: ToolTipSpans) formatFunction = uniqueId <- uniqueId + 1 let stringIndex = @@ -34,32 +34,15 @@ type ToolTipFormatter(prefix) = count <- count + 1 tips.Add(tip, (count, formatFunction tip)) count - // stringIndex is the index of the tool tip - // uniqueId is globally unique id of the occurrence - if overlapping then - // The may contain other , so we need to - // get the element and check where the mouse goes... - String.Format( - "id=\"{0}t{1}\" onmouseout=\"hideTip(event, '{0}{1}', {2})\" " - + "onmouseover=\"showTip(event, '{0}{1}', {2}, document.getElementById('{0}t{1}'))\" ", - prefix, - stringIndex, - uniqueId - ) - else - String.Format( - "onmouseout=\"hideTip(event, '{0}{1}', {2})\" " - + "onmouseover=\"showTip(event, '{0}{1}', {2})\" ", - prefix, - stringIndex, - uniqueId - ) + // stringIndex is the index of the tool tip div + // uniqueId is the globally unique id of this hover occurrence + String.Format("data-fsdocs-tip=\"{0}{1}\" data-fsdocs-tip-unique=\"{2}\" ", prefix, stringIndex, uniqueId) /// Returns all generated tool tip elements member x.WriteTipElements(writer: TextWriter) = for (KeyValue(_, (index, html))) in tips do - writer.WriteLine(sprintf "
%s
" prefix index html) + writer.WriteLine(sprintf "
%s
" prefix index html) /// Represents context used by the formatter @@ -71,7 +54,7 @@ type FormattingContext = CloseTag: string OpenLinesTag: string CloseLinesTag: string - FormatTip: ToolTipSpans -> bool -> (ToolTipSpans -> string) -> string + FormatTip: ToolTipSpans -> (ToolTipSpans -> string) -> string TokenKindToCss: (TokenKind -> string) } // -------------------------------------------------------------------------------------- @@ -106,12 +89,12 @@ let rec formatTokenSpans (ctx: FormattingContext) = | TokenSpan.Error(_kind, message, body) when ctx.GenerateErrors -> let tip = ToolTipReader.formatMultilineString (message.Trim().Split('\n')) - let tipAttributes = ctx.FormatTip tip true formatToolTipSpans + let tipAttributes = ctx.FormatTip tip formatToolTipSpans ctx.Writer.Write("") - formatTokenSpans { ctx with FormatTip = fun _ _ _ -> "" } body + formatTokenSpans { ctx with FormatTip = fun _ _ -> "" } body ctx.Writer.Write("") | TokenSpan.Error(_, _, body) -> formatTokenSpans ctx body @@ -124,7 +107,7 @@ let rec formatTokenSpans (ctx: FormattingContext) = | TokenSpan.Omitted(body, hidden) -> let tip = ToolTipReader.formatMultilineString (hidden.Trim().Split('\n')) - let tipAttributes = ctx.FormatTip tip true formatToolTipSpans + let tipAttributes = ctx.FormatTip tip formatToolTipSpans ctx.Writer.Write(" ctx.FormatTip tip false formatToolTipSpans + | Some(tip) -> ctx.FormatTip tip formatToolTipSpans | _ -> "" // Get CSS class name of the token diff --git a/tests/fsdocs-tool.Tests/ConvertCommandTests.fs b/tests/fsdocs-tool.Tests/ConvertCommandTests.fs index e30f2f110..19123926c 100644 --- a/tests/fsdocs-tool.Tests/ConvertCommandTests.fs +++ b/tests/fsdocs-tool.Tests/ConvertCommandTests.fs @@ -66,7 +66,9 @@ let ``ConvertCommand omits fsdocs-tip divs when no template given`` () = result |> shouldEqual 0 let html = File.ReadAllText(outputFile) - html |> shouldNotContainText "fsdocs-tip" + // Tooltip trigger spans use data-fsdocs-tip attributes; the assertion checks that + // the tooltip *div* elements (class="fsdocs-tip") are NOT emitted without a template. + html |> shouldNotContainText "class=\"fsdocs-tip\"" [] let ``ConvertCommand converts .ipynb file to HTML`` () =