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? -%>
+
+<% end -%>
+
<%= l(:label_wiki) %>
- <%= link_to(l(:field_start_page), {:action => 'show', :id => nil}) %>
diff --git a/app/views/wiki/show.html.erb b/app/views/wiki/show.html.erb
index 4b222ef4b70fd3d6d01fcebf0ff87ec490db1eff..e0bc4a3051710a9d6f95f0974da34efd80b37f2d 100644
--- a/app/views/wiki/show.html.erb
+++ b/app/views/wiki/show.html.erb
@@ -59,7 +59,7 @@
<% end %>
-<%= render(:partial => "wiki/content", :locals => {:content => @content}) %>
+<%= render(:partial => "wiki/content", :locals => {:content => @content, :toc_sidebar => true}) %>