diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index 3266c272835cc65a731169416a8cd6764ef14dc5..7e7d2a02a1a0f30683d759f90410e47af23e8e7e 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -2019,6 +2019,55 @@ div.wiki ul.toc a { } div.wiki ul.toc a:hover { color: var(--oc-red-9); text-decoration: underline;} +.wiki-toc-sidebar ul.toc a.is-active { + background-color: var(--oc-blue-1); + color: var(--oc-blue-9); + border-radius: 2px; + box-shadow: 0 0 0 2px var(--oc-blue-1); +} + +/* Sidebar TOC: keep the rules scoped after the sidebar's list styles. */ +.wiki-toc-sidebar { clear: both; overflow-wrap: anywhere; } +.wiki-toc-sidebar-controls { display: flex; justify-content: flex-end; flex-shrink: 0; gap: 4px; padding: 4px 8px; } +.wiki-toc-sidebar .wiki-toc-button { + background: none; + border: 0; + box-shadow: none; + color: var(--oc-blue-8); + cursor: pointer; + font: inherit; + min-inline-size: 28px; + min-block-size: 28px; + padding: 2px 6px; +} +.wiki-toc-sidebar .wiki-toc-button:hover { color: var(--oc-red-9); } +.wiki-toc-sidebar .wiki-toc-button:focus-visible { outline: 2px solid var(--oc-blue-8); outline-offset: 1px; } +.wiki-toc-sidebar .wiki-toc-button svg { vertical-align: middle; } +.flyout-menu .wiki-toc-sidebar .wiki-toc-button { color: inherit; } +#sidebar .wiki-toc-sidebar ul.toc, +.flyout-menu .wiki-toc-sidebar ul.toc { display: block; margin: 0; padding: 8px; } +#sidebar .wiki-toc-sidebar ul.toc ul, +.flyout-menu .wiki-toc-sidebar ul.toc ul { margin: 0; padding: 0; } +#sidebar .wiki-toc-sidebar ul.toc li, +.flyout-menu .wiki-toc-sidebar ul.toc li { list-style-type: none; } +#sidebar .wiki-toc-sidebar ul.toc li li, +.flyout-menu .wiki-toc-sidebar ul.toc li li { margin-inline-start: 1em; } + +@media screen and (min-width: 900px) { + #wrapper:has(.wiki-toc-sidebar) { overflow: clip; } + #sidebar:has(.wiki-toc-sidebar) { display: flex; flex-direction: column; } + #sidebar:has(.wiki-toc-sidebar) #sidebar-wrapper { flex: 1; min-block-size: 0; } + .wiki-toc-sidebar { + display: flex; + flex-direction: column; + position: sticky; + top: 10px; + max-block-size: calc(100vh - 20px); + margin-block-end: 14px; + } + .wiki-toc-sidebar-content { min-block-size: 0; overflow-y: auto; } +} + a.wiki-anchor { display: none; margin-inline-start: 6px; text-decoration: none; } a.wiki-anchor:hover { color: var(--oc-gray-5) !important; text-decoration: none; } h1:hover a.wiki-anchor, h2:hover a.wiki-anchor, h3:hover a.wiki-anchor, h4:hover a.wiki-anchor, h5:hover a.wiki-anchor, h6:hover a.wiki-anchor { display: inline; color: var(--oc-gray-4); } diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 1bc94c235a6b743b915de45764624dcac090b9f1..ec4ec8130c9f6f6830b739b7a31abb59432f0a91 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -941,6 +941,7 @@ module ApplicationHelper # * with an object and one of its attribute: textilizable(issue, :description, options) def textilizable(*args) options = args.extract_options! + @wiki_toc_sidebar = nil if options[:toc_sidebar] case args.size when 1 obj = options[:object] @@ -979,8 +980,8 @@ module ApplicationHelper parse_sections(text, project, obj, attr, only_path, options) parse_headings(text, project, obj, attr, only_path, options) - if @parsed_headings.any? - replace_toc(text, @parsed_headings) + if @parsed_headings.any? || options[:toc_sidebar] + replace_toc(text, @parsed_headings, options) end text.html_safe @@ -1497,12 +1498,16 @@ module ApplicationHelper end end - TOC_RE = /

\{\{((<|<)|(>|>))?toc\}\}<\/p>/i unless const_defined?(:TOC_RE) + TOC_RE = /

\{\{((<|<)|(>|>))?toc(\(sidebar\))?\}\}<\/p>/i unless const_defined?(:TOC_RE) # Renders the TOC with given headings - def replace_toc(text, headings) - text.gsub!(TOC_RE) do - left_align, right_align = $2, $3 + def replace_toc(text, headings, options={}) + has_headings = headings.any? + text.gsub!(TOC_RE) do |match| + left_align, right_align, sidebar = $2, $3, $4 + next match if sidebar && (!options[:toc_sidebar] || left_align || right_align) + next match if !sidebar && !has_headings + # Keep only the 4 first levels headings = headings.select{|level, anchor, item| level <= 4} if headings.empty? @@ -1523,12 +1528,20 @@ module ApplicationHelper elsif started out << '

  • ' end - out << "#{item}" + label = sidebar ? CGI.escapeHTML(item) : item + out << "#{label}" current = level started = true end out << '
  • ' * (current - root) out << '' + if sidebar + # Only markup built here, with escaped labels, is trusted. + @wiki_toc_sidebar ||= out.html_safe + '' + else + out + end end end end diff --git a/app/javascript/controllers/wiki_sidebar_toc_controller.js b/app/javascript/controllers/wiki_sidebar_toc_controller.js new file mode 100644 index 0000000000000000000000000000000000000000..2ef415df0738a4188066fb879b4d84689661c6cb --- /dev/null +++ b/app/javascript/controllers/wiki_sidebar_toc_controller.js @@ -0,0 +1,63 @@ +import { Controller } from "@hotwired/stimulus"; + +export default class extends Controller { + scrollToTop() { + window.scrollTo({ top: 0, behavior: "instant" }); + } + + scrollToBottom() { + window.scrollTo({ top: document.scrollingElement.scrollHeight, behavior: "instant" }); + } + + connect() { + const content = document.querySelector("#content .wiki-page"); + if (!content) return; + + const anchors = new Map( + Array.from(content.querySelectorAll("a[name], [id]"), anchor => + [anchor.getAttribute("name") || anchor.id, anchor]) + ); + this.items = Array.from(this.element.querySelectorAll('ul.toc a[href^="#"]')) + .map(link => { + const fragment = link.getAttribute("href").slice(1); + let name; + try { name = decodeURIComponent(fragment); } catch { name = fragment; } + return { link, anchor: anchors.get(fragment) || anchors.get(name) }; + }).filter(item => item.anchor); + this.scheduleUpdate = () => { + if (this.frame) return; + this.frame = requestAnimationFrame(() => { + this.frame = null; + this.updateCurrentHeading(); + }); + }; + window.addEventListener("scroll", this.scheduleUpdate, { passive: true }); + window.addEventListener("resize", this.scheduleUpdate); + window.addEventListener("hashchange", this.scheduleUpdate); + this.scheduleUpdate(); + } + + disconnect() { + window.removeEventListener("scroll", this.scheduleUpdate); + window.removeEventListener("resize", this.scheduleUpdate); + window.removeEventListener("hashchange", this.scheduleUpdate); + if (this.frame) cancelAnimationFrame(this.frame); + this.frame = null; + this.items?.forEach(({ link }) => { + link.classList.remove("is-active"); + link.removeAttribute("aria-current"); + }); + } + + updateCurrentHeading() { + let active = null; + for (const item of this.items) { + if (item.anchor.getBoundingClientRect().top <= 10) active = item.link; + } + for (const { link } of this.items) { + link.classList.toggle("is-active", link === active); + if (link === active) link.setAttribute("aria-current", "location"); + else link.removeAttribute("aria-current"); + } + } +} diff --git a/app/views/wiki/_content.html.erb b/app/views/wiki/_content.html.erb index 60b6a7452fc0b62aed04ad3239c027aba4521104..e9797d5267242e052ee5e218e3d6aa8b5589c887 100644 --- a/app/views/wiki/_content.html.erb +++ b/app/views/wiki/_content.html.erb @@ -1,4 +1,5 @@
    <%= textilizable content, :text, :attachments => content.page.attachments, + :toc_sidebar => local_assigns.fetch(:toc_sidebar, false), :edit_section_links => (@sections_editable && {:controller => 'wiki', :action => 'edit', :project_id => @page.project, :id => @page.title}) %>
    diff --git a/app/views/wiki/_sidebar.html.erb b/app/views/wiki/_sidebar.html.erb index cb987bbb0a196e94a2617bf1ade72d3b6a63934e..cef26af180e16de35ff01fa0cad51fcfb4cb19d2 100644 --- a/app/views/wiki/_sidebar.html.erb +++ b/app/views/wiki/_sidebar.html.erb @@ -12,6 +12,32 @@ <% end -%> +<% if @wiki_toc_sidebar.present? -%> +
    +
    + <%= button_tag :type => 'button', :class => 'wiki-toc-button', :title => l(:button_scroll_to_top), + :aria => {:label => l(:button_scroll_to_top)}, + :data => {:action => 'wiki-sidebar-toc#scrollToTop'} do %> + + <% end %> + <%= button_tag :type => 'button', :class => 'wiki-toc-button', :title => l(:button_scroll_to_bottom), + :aria => {:label => l(:button_scroll_to_bottom)}, + :data => {:action => 'wiki-sidebar-toc#scrollToBottom'} do %> + + <% end %> +
    +
    + <%= @wiki_toc_sidebar %> +
    +
    +<% end -%> +

    <%= l(:label_wiki) %>