Feature #4301 » redmine-7.0.1-wiki-sidebar-toc.patch
| app/assets/stylesheets/application.css | ||
|---|---|---|
| 2019 | 2019 |
} |
| 2020 | 2020 |
div.wiki ul.toc a:hover { color: var(--oc-red-9); text-decoration: underline;}
|
| 2021 | 2021 | |
| 2022 |
.wiki-toc-sidebar ul.toc a.is-active {
|
|
| 2023 |
background-color: var(--oc-blue-1); |
|
| 2024 |
color: var(--oc-blue-9); |
|
| 2025 |
border-radius: 2px; |
|
| 2026 |
box-shadow: 0 0 0 2px var(--oc-blue-1); |
|
| 2027 |
} |
|
| 2028 | ||
| 2029 |
/* Sidebar TOC: keep the rules scoped after the sidebar's list styles. */ |
|
| 2030 |
.wiki-toc-sidebar { clear: both; overflow-wrap: anywhere; }
|
|
| 2031 |
.wiki-toc-sidebar-controls { display: flex; justify-content: flex-end; flex-shrink: 0; gap: 4px; padding: 4px 8px; }
|
|
| 2032 |
.wiki-toc-sidebar .wiki-toc-button {
|
|
| 2033 |
background: none; |
|
| 2034 |
border: 0; |
|
| 2035 |
box-shadow: none; |
|
| 2036 |
color: var(--oc-blue-8); |
|
| 2037 |
cursor: pointer; |
|
| 2038 |
font: inherit; |
|
| 2039 |
min-inline-size: 28px; |
|
| 2040 |
min-block-size: 28px; |
|
| 2041 |
padding: 2px 6px; |
|
| 2042 |
} |
|
| 2043 |
.wiki-toc-sidebar .wiki-toc-button:hover { color: var(--oc-red-9); }
|
|
| 2044 |
.wiki-toc-sidebar .wiki-toc-button:focus-visible { outline: 2px solid var(--oc-blue-8); outline-offset: 1px; }
|
|
| 2045 |
.wiki-toc-sidebar .wiki-toc-button svg { vertical-align: middle; }
|
|
| 2046 |
.flyout-menu .wiki-toc-sidebar .wiki-toc-button { color: inherit; }
|
|
| 2047 |
#sidebar .wiki-toc-sidebar ul.toc, |
|
| 2048 |
.flyout-menu .wiki-toc-sidebar ul.toc { display: block; margin: 0; padding: 8px; }
|
|
| 2049 |
#sidebar .wiki-toc-sidebar ul.toc ul, |
|
| 2050 |
.flyout-menu .wiki-toc-sidebar ul.toc ul { margin: 0; padding: 0; }
|
|
| 2051 |
#sidebar .wiki-toc-sidebar ul.toc li, |
|
| 2052 |
.flyout-menu .wiki-toc-sidebar ul.toc li { list-style-type: none; }
|
|
| 2053 |
#sidebar .wiki-toc-sidebar ul.toc li li, |
|
| 2054 |
.flyout-menu .wiki-toc-sidebar ul.toc li li { margin-inline-start: 1em; }
|
|
| 2055 | ||
| 2056 |
@media screen and (min-width: 900px) {
|
|
| 2057 |
#wrapper:has(.wiki-toc-sidebar) { overflow: clip; }
|
|
| 2058 |
#sidebar:has(.wiki-toc-sidebar) { display: flex; flex-direction: column; }
|
|
| 2059 |
#sidebar:has(.wiki-toc-sidebar) #sidebar-wrapper { flex: 1; min-block-size: 0; }
|
|
| 2060 |
.wiki-toc-sidebar {
|
|
| 2061 |
display: flex; |
|
| 2062 |
flex-direction: column; |
|
| 2063 |
position: sticky; |
|
| 2064 |
top: 10px; |
|
| 2065 |
max-block-size: calc(100vh - 20px); |
|
| 2066 |
margin-block-end: 14px; |
|
| 2067 |
} |
|
| 2068 |
.wiki-toc-sidebar-content { min-block-size: 0; overflow-y: auto; }
|
|
| 2069 |
} |
|
| 2070 | ||
| 2022 | 2071 |
a.wiki-anchor { display: none; margin-inline-start: 6px; text-decoration: none; }
|
| 2023 | 2072 |
a.wiki-anchor:hover { color: var(--oc-gray-5) !important; text-decoration: none; }
|
| 2024 | 2073 |
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); }
|
| app/helpers/application_helper.rb | ||
|---|---|---|
| 941 | 941 |
# * with an object and one of its attribute: textilizable(issue, :description, options) |
| 942 | 942 |
def textilizable(*args) |
| 943 | 943 |
options = args.extract_options! |
| 944 |
@wiki_toc_sidebar = nil if options[:toc_sidebar] |
|
| 944 | 945 |
case args.size |
| 945 | 946 |
when 1 |
| 946 | 947 |
obj = options[:object] |
| ... | ... | |
| 979 | 980 |
parse_sections(text, project, obj, attr, only_path, options) |
| 980 | 981 |
parse_headings(text, project, obj, attr, only_path, options) |
| 981 | 982 | |
| 982 |
if @parsed_headings.any? |
|
| 983 |
replace_toc(text, @parsed_headings) |
|
| 983 |
if @parsed_headings.any? || options[:toc_sidebar]
|
|
| 984 |
replace_toc(text, @parsed_headings, options)
|
|
| 984 | 985 |
end |
| 985 | 986 | |
| 986 | 987 |
text.html_safe |
| ... | ... | |
| 1497 | 1498 |
end |
| 1498 | 1499 |
end |
| 1499 | 1500 | |
| 1500 |
TOC_RE = /<p>\{\{((<|<)|(>|>))?toc\}\}<\/p>/i unless const_defined?(:TOC_RE)
|
|
| 1501 |
TOC_RE = /<p>\{\{((<|<)|(>|>))?toc(\(sidebar\))?\}\}<\/p>/i unless const_defined?(:TOC_RE)
|
|
| 1501 | 1502 | |
| 1502 | 1503 |
# Renders the TOC with given headings |
| 1503 |
def replace_toc(text, headings) |
|
| 1504 |
text.gsub!(TOC_RE) do |
|
| 1505 |
left_align, right_align = $2, $3 |
|
| 1504 |
def replace_toc(text, headings, options={})
|
|
| 1505 |
has_headings = headings.any? |
|
| 1506 |
text.gsub!(TOC_RE) do |match| |
|
| 1507 |
left_align, right_align, sidebar = $2, $3, $4 |
|
| 1508 |
next match if sidebar && (!options[:toc_sidebar] || left_align || right_align) |
|
| 1509 |
next match if !sidebar && !has_headings |
|
| 1510 | ||
| 1506 | 1511 |
# Keep only the 4 first levels |
| 1507 | 1512 |
headings = headings.select{|level, anchor, item| level <= 4}
|
| 1508 | 1513 |
if headings.empty? |
| ... | ... | |
| 1523 | 1528 |
elsif started |
| 1524 | 1529 |
out << '</li><li>' |
| 1525 | 1530 |
end |
| 1526 |
out << "<a href=\"##{anchor}\">#{item}</a>"
|
|
| 1531 |
label = sidebar ? CGI.escapeHTML(item) : item |
|
| 1532 |
out << "<a href=\"##{anchor}\">#{label}</a>"
|
|
| 1527 | 1533 |
current = level |
| 1528 | 1534 |
started = true |
| 1529 | 1535 |
end |
| 1530 | 1536 |
out << '</li></ul>' * (current - root) |
| 1531 | 1537 |
out << '</li></ul>' |
| 1538 |
if sidebar |
|
| 1539 |
# Only markup built here, with escaped labels, is trusted. |
|
| 1540 |
@wiki_toc_sidebar ||= out.html_safe |
|
| 1541 |
'' |
|
| 1542 |
else |
|
| 1543 |
out |
|
| 1544 |
end |
|
| 1532 | 1545 |
end |
| 1533 | 1546 |
end |
| 1534 | 1547 |
end |
| app/javascript/controllers/wiki_sidebar_toc_controller.js | ||
|---|---|---|
| 1 |
import { Controller } from "@hotwired/stimulus";
|
|
| 2 | ||
| 3 |
export default class extends Controller {
|
|
| 4 |
scrollToTop() {
|
|
| 5 |
window.scrollTo({ top: 0, behavior: "instant" });
|
|
| 6 |
} |
|
| 7 | ||
| 8 |
scrollToBottom() {
|
|
| 9 |
window.scrollTo({ top: document.scrollingElement.scrollHeight, behavior: "instant" });
|
|
| 10 |
} |
|
| 11 | ||
| 12 |
connect() {
|
|
| 13 |
const content = document.querySelector("#content .wiki-page");
|
|
| 14 |
if (!content) return; |
|
| 15 | ||
| 16 |
const anchors = new Map( |
|
| 17 |
Array.from(content.querySelectorAll("a[name], [id]"), anchor =>
|
|
| 18 |
[anchor.getAttribute("name") || anchor.id, anchor])
|
|
| 19 |
); |
|
| 20 |
this.items = Array.from(this.element.querySelectorAll('ul.toc a[href^="#"]'))
|
|
| 21 |
.map(link => {
|
|
| 22 |
const fragment = link.getAttribute("href").slice(1);
|
|
| 23 |
let name; |
|
| 24 |
try { name = decodeURIComponent(fragment); } catch { name = fragment; }
|
|
| 25 |
return { link, anchor: anchors.get(fragment) || anchors.get(name) };
|
|
| 26 |
}).filter(item => item.anchor); |
|
| 27 |
this.scheduleUpdate = () => {
|
|
| 28 |
if (this.frame) return; |
|
| 29 |
this.frame = requestAnimationFrame(() => {
|
|
| 30 |
this.frame = null; |
|
| 31 |
this.updateCurrentHeading(); |
|
| 32 |
}); |
|
| 33 |
}; |
|
| 34 |
window.addEventListener("scroll", this.scheduleUpdate, { passive: true });
|
|
| 35 |
window.addEventListener("resize", this.scheduleUpdate);
|
|
| 36 |
window.addEventListener("hashchange", this.scheduleUpdate);
|
|
| 37 |
this.scheduleUpdate(); |
|
| 38 |
} |
|
| 39 | ||
| 40 |
disconnect() {
|
|
| 41 |
window.removeEventListener("scroll", this.scheduleUpdate);
|
|
| 42 |
window.removeEventListener("resize", this.scheduleUpdate);
|
|
| 43 |
window.removeEventListener("hashchange", this.scheduleUpdate);
|
|
| 44 |
if (this.frame) cancelAnimationFrame(this.frame); |
|
| 45 |
this.frame = null; |
|
| 46 |
this.items?.forEach(({ link }) => {
|
|
| 47 |
link.classList.remove("is-active");
|
|
| 48 |
link.removeAttribute("aria-current");
|
|
| 49 |
}); |
|
| 50 |
} |
|
| 51 | ||
| 52 |
updateCurrentHeading() {
|
|
| 53 |
let active = null; |
|
| 54 |
for (const item of this.items) {
|
|
| 55 |
if (item.anchor.getBoundingClientRect().top <= 10) active = item.link; |
|
| 56 |
} |
|
| 57 |
for (const { link } of this.items) {
|
|
| 58 |
link.classList.toggle("is-active", link === active);
|
|
| 59 |
if (link === active) link.setAttribute("aria-current", "location");
|
|
| 60 |
else link.removeAttribute("aria-current");
|
|
| 61 |
} |
|
| 62 |
} |
|
| 63 |
} |
|
| app/views/wiki/_content.html.erb | ||
|---|---|---|
| 1 | 1 |
<div class="wiki wiki-page"> |
| 2 | 2 |
<%= textilizable content, :text, :attachments => content.page.attachments, |
| 3 |
:toc_sidebar => local_assigns.fetch(:toc_sidebar, false), |
|
| 3 | 4 |
:edit_section_links => (@sections_editable && {:controller => 'wiki', :action => 'edit', :project_id => @page.project, :id => @page.title}) %>
|
| 4 | 5 |
</div> |
| app/views/wiki/_sidebar.html.erb | ||
|---|---|---|
| 12 | 12 |
</div> |
| 13 | 13 |
<% end -%> |
| 14 | 14 | |
| 15 |
<% if @wiki_toc_sidebar.present? -%> |
|
| 16 |
<div class="wiki wiki-toc-sidebar" data-controller="wiki-sidebar-toc"> |
|
| 17 |
<div class="wiki-toc-sidebar-controls"> |
|
| 18 |
<%= button_tag :type => 'button', :class => 'wiki-toc-button', :title => l(:button_scroll_to_top), |
|
| 19 |
:aria => {:label => l(:button_scroll_to_top)},
|
|
| 20 |
:data => {:action => 'wiki-sidebar-toc#scrollToTop'} do %>
|
|
| 21 |
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" |
|
| 22 |
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"> |
|
| 23 |
<path d="m7 13 5-5 5 5m-10 5 5-5 5 5" /> |
|
| 24 |
</svg> |
|
| 25 |
<% end %> |
|
| 26 |
<%= button_tag :type => 'button', :class => 'wiki-toc-button', :title => l(:button_scroll_to_bottom), |
|
| 27 |
:aria => {:label => l(:button_scroll_to_bottom)},
|
|
| 28 |
:data => {:action => 'wiki-sidebar-toc#scrollToBottom'} do %>
|
|
| 29 |
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" |
|
| 30 |
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"> |
|
| 31 |
<path d="m7 6 5 5 5-5m-10 5 5 5 5-5" /> |
|
| 32 |
</svg> |
|
| 33 |
<% end %> |
|
| 34 |
</div> |
|
| 35 |
<div class="wiki-toc-sidebar-content"> |
|
| 36 |
<%= @wiki_toc_sidebar %> |
|
| 37 |
</div> |
|
| 38 |
</div> |
|
| 39 |
<% end -%> |
|
| 40 | ||
| 15 | 41 |
<h3><%= l(:label_wiki) %></h3> |
| 16 | 42 |
<ul> |
| 17 | 43 |
<li><%= link_to(l(:field_start_page), {:action => 'show', :id => nil}) %></li>
|
| app/views/wiki/show.html.erb | ||
|---|---|---|
| 59 | 59 |
<hr /> |
| 60 | 60 |
<% end %> |
| 61 | 61 | |
| 62 |
<%= render(:partial => "wiki/content", :locals => {:content => @content}) %>
|
|
| 62 |
<%= render(:partial => "wiki/content", :locals => {:content => @content, :toc_sidebar => true}) %>
|
|
| 63 | 63 | |
| 64 | 64 |
<fieldset class="collapsible collapsed hide-when-print"> |
| 65 | 65 |
<legend onclick="toggleFieldset(this);" class="icon icon-collapsed"> |
| config/locales/en.yml | ||
|---|---|---|
| 1 | 1 |
en: |
| 2 |
button_scroll_to_top: Scroll to top |
|
| 3 |
button_scroll_to_bottom: Scroll to bottom |
|
| 2 | 4 |
# Text direction: Left-to-Right (ltr) or Right-to-Left (rtl) |
| 3 | 5 |
direction: ltr |
| 4 | 6 |
date: |
| config/locales/ja.yml | ||
|---|---|---|
| 3 | 3 |
# AR error messages are basically taken from Ruby-GetText-Package. Thanks to Masao Mutoh. |
| 4 | 4 | |
| 5 | 5 |
ja: |
| 6 |
button_scroll_to_top: 最上部へ |
|
| 7 |
button_scroll_to_bottom: 最下部へ |
|
| 6 | 8 |
direction: ltr |
| 7 | 9 |
date: |
| 8 | 10 |
formats: |
| test/functional/issues_controller_test.rb | ||
|---|---|---|
| 26 | 26 |
User.current = nil |
| 27 | 27 |
end |
| 28 | 28 | |
| 29 |
def test_sidebar_toc_remains_literal_outside_wiki |
|
| 30 |
with_settings :text_formatting => 'textile' do |
|
| 31 |
record = Issue.find(1) |
|
| 32 |
record.update!(:description => "{{toc(sidebar)}}\n\nh1. Heading")
|
|
| 33 |
get :show, :params => {:id => record.id}
|
|
| 34 |
assert_response :success |
|
| 35 |
assert_includes response.body, '{{toc(sidebar)}}'
|
|
| 36 |
assert_select '.wiki-toc-sidebar', 0 |
|
| 37 |
end |
|
| 38 |
end |
|
| 39 | ||
| 29 | 40 |
def test_index |
| 30 | 41 |
with_settings :default_language => "en" do |
| 31 | 42 |
get :index |
| test/functional/news_controller_test.rb | ||
|---|---|---|
| 24 | 24 |
User.current = nil |
| 25 | 25 |
end |
| 26 | 26 | |
| 27 |
def test_sidebar_toc_remains_literal_outside_wiki |
|
| 28 |
with_settings :text_formatting => 'textile' do |
|
| 29 |
record = News.find(1) |
|
| 30 |
record.update!(:description => "{{toc(sidebar)}}\n\nh1. Heading")
|
|
| 31 |
get :show, :params => {:id => record.id}
|
|
| 32 |
assert_response :success |
|
| 33 |
assert_includes response.body, '{{toc(sidebar)}}'
|
|
| 34 |
assert_select '.wiki-toc-sidebar', 0 |
|
| 35 |
end |
|
| 36 |
end |
|
| 37 | ||
| 27 | 38 |
def test_index |
| 28 | 39 |
get :index |
| 29 | 40 |
assert_response :success |
| test/functional/previews_controller_test.rb | ||
|---|---|---|
| 20 | 20 |
require_relative '../test_helper' |
| 21 | 21 | |
| 22 | 22 |
class PreviewsControllerTest < Redmine::ControllerTest |
| 23 |
def test_sidebar_toc_remains_literal_in_issue_preview |
|
| 24 |
@request.session[:user_id] = 2 |
|
| 25 |
with_settings :text_formatting => 'textile' do |
|
| 26 |
post :issue, :params => {:project_id => 1, :text => "{{toc(sidebar)}}\n\nh1. Heading"}
|
|
| 27 |
assert_response :success |
|
| 28 |
assert_includes response.body, '{{toc(sidebar)}}'
|
|
| 29 |
assert_select '.wiki-toc-sidebar', 0 |
|
| 30 |
end |
|
| 31 |
end |
|
| 32 | ||
| 23 | 33 |
def test_preview_new_issue_description |
| 24 | 34 |
@request.session[:user_id] = 2 |
| 25 | 35 |
post( |
| test/functional/versions_controller_test.rb | ||
|---|---|---|
| 25 | 25 |
User.current = nil |
| 26 | 26 |
end |
| 27 | 27 | |
| 28 |
def test_sidebar_toc_remains_literal_outside_wiki |
|
| 29 |
with_settings :text_formatting => 'textile' do |
|
| 30 |
record = Version.find(3) |
|
| 31 |
record.update!(:description => "{{toc(sidebar)}}\n\nh1. Heading")
|
|
| 32 |
get :show, :params => {:id => record.id}
|
|
| 33 |
assert_response :success |
|
| 34 |
assert_includes response.body, '{{toc(sidebar)}}'
|
|
| 35 |
assert_select '.wiki-toc-sidebar', 0 |
|
| 36 |
get :index, :params => {:project_id => record.project_id}
|
|
| 37 |
assert_response :success |
|
| 38 |
assert_includes response.body, '{{toc(sidebar)}}'
|
|
| 39 |
assert_select '.wiki-toc-sidebar', 0 |
|
| 40 |
end |
|
| 41 |
end |
|
| 42 | ||
| 28 | 43 |
def test_index |
| 29 | 44 |
get :index, :params => {:project_id => 1}
|
| 30 | 45 |
assert_response :success |
| test/functional/wiki_controller_test.rb | ||
|---|---|---|
| 24 | 24 |
User.current = nil |
| 25 | 25 |
end |
| 26 | 26 | |
| 27 |
def test_show_sidebar_toc_between_sidebar_and_wiki_menu |
|
| 28 |
@request.session[:user_id] = 2 |
|
| 29 |
with_settings :text_formatting => 'textile' do |
|
| 30 |
page = WikiPage.find(1) |
|
| 31 |
page.content.update!(:text => "{{toc(sidebar)}}\n\nh1. Main\n\nh2. Child")
|
|
| 32 |
page.add_watcher User.find(2) |
|
| 33 |
sidebar = page.wiki.pages.build(:title => 'Sidebar') |
|
| 34 |
sidebar.content = WikiContent.new(:text => "h3. Sidebar\n\n{{toc(sidebar)}}")
|
|
| 35 |
sidebar.save! |
|
| 36 |
get :show, :params => {:project_id => 1, :id => page.title}
|
|
| 37 |
assert_response :success |
|
| 38 |
assert_select '#sidebar-wrapper > .wiki + .wiki-toc-sidebar + h3', 1 |
|
| 39 |
assert_select '.wiki-toc-sidebar a[href="#Main"]', 1 |
|
| 40 |
assert_select '.wiki-toc-sidebar li li a', :text => 'Child' |
|
| 41 |
assert_select '#content .wiki-page', :text => /\{\{toc\(sidebar\)\}\}/, :count => 0
|
|
| 42 |
assert_select '#watchers', 1 |
|
| 43 |
end |
|
| 44 |
end |
|
| 45 | ||
| 46 |
def test_show_sidebar_toc_for_previous_version_without_sidebar_page |
|
| 47 |
with_settings :text_formatting => 'textile' do |
|
| 48 |
page = WikiPage.find(1) |
|
| 49 |
page.content.update!(:text => "{{toc(sidebar)}}\n\nh1. Previous")
|
|
| 50 |
version = page.content.version |
|
| 51 |
page.content.update!(:text => 'New content') |
|
| 52 |
get :show, :params => {:project_id => 1, :id => page.title, :version => version}
|
|
| 53 |
assert_response :success |
|
| 54 |
assert_select '#sidebar-wrapper > .wiki-toc-sidebar + h3', 1 |
|
| 55 |
assert_select '.wiki-toc-sidebar a[href="#Previous"]', 1 |
|
| 56 |
end |
|
| 57 |
end |
|
| 58 | ||
| 59 |
def test_show_without_sidebar_toc_or_headings_has_no_container |
|
| 60 |
with_settings :text_formatting => 'textile' do |
|
| 61 |
page = WikiPage.find(1) |
|
| 62 |
['h1. Heading', '{{toc(sidebar)}}'].each do |text|
|
|
| 63 |
page.content.update!(:text => text) |
|
| 64 |
get :show, :params => {:project_id => 1, :id => page.title}
|
|
| 65 |
assert_response :success |
|
| 66 |
assert_select '.wiki-toc-sidebar', 0 |
|
| 67 |
end |
|
| 68 |
end |
|
| 69 |
end |
|
| 70 | ||
| 71 |
def test_sidebar_toc_is_not_enabled_for_html_export |
|
| 72 |
Role.anonymous.add_permission! :export_wiki_pages |
|
| 73 |
with_settings :text_formatting => 'textile' do |
|
| 74 |
page = WikiPage.find(1) |
|
| 75 |
page.content.update!(:text => "{{toc(sidebar)}}\n\nh1. Heading")
|
|
| 76 |
get :show, :params => {:project_id => 1, :id => page.title, :format => 'html'}
|
|
| 77 |
assert_response :success |
|
| 78 |
assert_includes response.body, '{{toc(sidebar)}}'
|
|
| 79 |
assert_select '.wiki-toc-sidebar', 0 |
|
| 80 |
end |
|
| 81 |
end |
|
| 82 | ||
| 27 | 83 |
def test_show_start_page |
| 28 | 84 |
with_settings :text_formatting => 'textile' do |
| 29 | 85 |
get :show, :params => {:project_id => 'ecookbook'}
|
| test/helpers/application_helper_test.rb | ||
|---|---|---|
| 1502 | 1502 |
end |
| 1503 | 1503 |
end |
| 1504 | 1504 | |
| 1505 |
def test_sidebar_toc_with_both_formatters |
|
| 1506 |
{'textile' => "h1. 日本語 & Heading\n\nh2. Child\n\nh1. 日本語 & Heading",
|
|
| 1507 |
'common_mark' => "# 日本語 & Heading\n\n## Child\n\n# 日本語 & Heading"}.each do |format, headings| |
|
| 1508 |
with_settings :text_formatting => format do |
|
| 1509 |
result = textilizable("{{toc(sidebar)}}\n\n{{toc(sidebar)}}\n\n{{toc}}\n\n#{headings}", :toc_sidebar => true)
|
|
| 1510 |
assert_not_includes result, '{{toc(sidebar)}}'
|
|
| 1511 |
assert_select_in result, 'ul.toc', 1 |
|
| 1512 |
assert @wiki_toc_sidebar.html_safe? |
|
| 1513 |
assert_select_in @wiki_toc_sidebar, 'ul.toc', 1 |
|
| 1514 |
assert_select_in @wiki_toc_sidebar, 'li li a', :text => 'Child' |
|
| 1515 |
toc = Nokogiri::HTML.fragment(@wiki_toc_sidebar) |
|
| 1516 |
anchors = Nokogiri::HTML.fragment(result).css('a[name]').pluck('name')
|
|
| 1517 |
assert_equal anchors, toc.css('a').map {|a| a['href'].delete_prefix('#')}
|
|
| 1518 |
assert_equal 3, anchors.uniq.size |
|
| 1519 |
end |
|
| 1520 |
end |
|
| 1521 |
end |
|
| 1522 | ||
| 1523 |
def test_sidebar_toc_is_opt_in_and_does_not_change_inline_alignment |
|
| 1524 |
with_settings :text_formatting => 'textile' do |
|
| 1525 |
result = textilizable("{{toc(sidebar)}}\n\nh1. Heading")
|
|
| 1526 |
assert_includes result, '{{toc(sidebar)}}'
|
|
| 1527 |
assert_nil @wiki_toc_sidebar |
|
| 1528 |
result = textilizable("{{toc(sidebar)}}\n\n{{<toc}}\n\n{{>toc}}\n\nh1. Heading", :toc_sidebar => true)
|
|
| 1529 |
assert_select_in result, 'ul.toc.left', 1 |
|
| 1530 |
assert_select_in result, 'ul.toc.right', 1 |
|
| 1531 |
end |
|
| 1532 |
end |
|
| 1533 | ||
| 1534 |
def test_sidebar_toc_resets_only_for_enabled_rendering_including_blank_content |
|
| 1535 |
with_settings :text_formatting => 'textile' do |
|
| 1536 |
textilizable("{{toc(sidebar)}}\n\nh1. Main", :toc_sidebar => true)
|
|
| 1537 |
saved = @wiki_toc_sidebar |
|
| 1538 |
textilizable("{{toc(sidebar)}}\n\nh1. Sidebar")
|
|
| 1539 |
assert_equal saved, @wiki_toc_sidebar |
|
| 1540 |
textilizable('', :toc_sidebar => true)
|
|
| 1541 |
assert_nil @wiki_toc_sidebar |
|
| 1542 |
result = textilizable("{{toc(sidebar)}}\n\n{{toc}}", :toc_sidebar => true)
|
|
| 1543 |
assert_includes result, '{{toc}}'
|
|
| 1544 |
assert_not_includes result, '{{toc(sidebar)}}'
|
|
| 1545 |
assert_nil @wiki_toc_sidebar |
|
| 1546 |
result = textilizable("{{toc(sidebar)}}\n\nh5. Too deep", :toc_sidebar => true)
|
|
| 1547 |
assert_not_includes result, '{{toc(sidebar)}}'
|
|
| 1548 |
assert_nil @wiki_toc_sidebar |
|
| 1549 |
end |
|
| 1550 |
end |
|
| 1551 | ||
| 1552 |
def test_sidebar_toc_preserves_code_and_escapes_heading_content |
|
| 1553 |
{'textile' => "<pre>{{toc(sidebar)}}</pre>\n\nh1. <script>alert(1)</script>",
|
|
| 1554 |
'common_mark' => "```\n{{toc(sidebar)}}\n```\n\n# <script>alert(1)</script>"}.each do |format, text|
|
|
| 1555 |
with_settings :text_formatting => format do |
|
| 1556 |
result = textilizable(text, :toc_sidebar => true) |
|
| 1557 |
assert_includes result, '{{toc(sidebar)}}'
|
|
| 1558 |
assert_nil @wiki_toc_sidebar |
|
| 1559 |
textilizable("{{toc(sidebar)}}\n\n#{text}", :toc_sidebar => true)
|
|
| 1560 |
assert_select_in @wiki_toc_sidebar, 'script', 0 |
|
| 1561 |
assert_select_in @wiki_toc_sidebar, 'a', :text => '<script>alert(1)</script>' |
|
| 1562 |
end |
|
| 1563 |
end |
|
| 1564 |
end |
|
| 1565 | ||
| 1566 |
def test_sidebar_toc_include_preserves_existing_heading_collection |
|
| 1567 |
with_settings :text_formatting => 'textile' do |
|
| 1568 |
@project = Project.find(1) |
|
| 1569 |
page = @project.wiki.pages.find_by!(:title => 'Another_page') |
|
| 1570 |
page.content.update!(:text => "{{toc(sidebar)}}\n\nh1. Included")
|
|
| 1571 |
result = textilizable("{{include(Another_page)}}\n\n{{toc(sidebar)}}\n\nh1. Main", :toc_sidebar => true)
|
|
| 1572 |
assert_select_in @wiki_toc_sidebar, 'a', :text => 'Main' |
|
| 1573 |
anchors = Nokogiri::HTML.fragment(result).css('a[name]').pluck('name')
|
|
| 1574 |
links = Nokogiri::HTML.fragment(@wiki_toc_sidebar).css('a').map {|a| a['href'].delete_prefix('#')}
|
|
| 1575 |
assert_equal anchors, links |
|
| 1576 |
end |
|
| 1577 |
end |
|
| 1578 | ||
| 1505 | 1579 |
def test_toc_with_textile_formatting_should_be_parsed |
| 1506 | 1580 |
with_settings :text_formatting => 'textile' do |
| 1507 | 1581 |
assert_select_in textilizable("{{toc}}\n\nh1. Heading"), 'ul.toc li', :text => 'Heading'
|
| test/system/wiki_sidebar_toc_test.rb | ||
|---|---|---|
| 1 |
# frozen_string_literal: true |
|
| 2 | ||
| 3 |
require_relative '../application_system_test_case' |
|
| 4 | ||
| 5 |
class WikiSidebarTocSystemTest < ApplicationSystemTestCase |
|
| 6 |
def setup |
|
| 7 |
super |
|
| 8 |
@original_formatting = Setting.text_formatting |
|
| 9 |
Setting.text_formatting = 'textile' |
|
| 10 |
wiki_page = WikiPage.find(1) |
|
| 11 |
text = +"{{toc(sidebar)}}\n\nIntroduction\n\n"
|
|
| 12 |
text << (1..60).map {|i| "h#{i.between?(2, 3) ? i : 1}. Heading #{i}\n\n" + ("Paragraph content.\n\n" * 20)}.join
|
|
| 13 |
wiki_page.content.update!(:text => text) |
|
| 14 |
page.current_window.resize_to(1280, 900) |
|
| 15 |
visit '/projects/ecookbook/wiki/CookBook_documentation' |
|
| 16 |
assert_selector '.wiki-toc-sidebar[data-controller="wiki-sidebar-toc"]' |
|
| 17 |
end |
|
| 18 | ||
| 19 |
def teardown |
|
| 20 |
Setting.text_formatting = @original_formatting |
|
| 21 |
super |
|
| 22 |
end |
|
| 23 | ||
| 24 |
def test_current_heading_follows_scroll_click_and_back_scroll |
|
| 25 |
assert_no_selector '.wiki-toc-sidebar a.is-active' |
|
| 26 |
[1, 2, 10, 1].each do |number| |
|
| 27 |
page.execute_script("document.getElementsByName(arguments[0])[0].scrollIntoView()", "Heading-#{number}")
|
|
| 28 |
assert_selector ".wiki-toc-sidebar a.is-active[aria-current='location'][href='#Heading-#{number}']"
|
|
| 29 |
assert_selector '.wiki-toc-sidebar a.is-active', :count => 1 |
|
| 30 |
end |
|
| 31 |
find('.wiki-toc-sidebar a[href="#Heading-3"]').click
|
|
| 32 |
assert_selector '.wiki-toc-sidebar a.is-active[href="#Heading-3"]' |
|
| 33 |
page.execute_script('window.scrollTo(0, 0)')
|
|
| 34 |
assert_no_selector '.wiki-toc-sidebar a.is-active' |
|
| 35 |
end |
|
| 36 | ||
| 37 |
def test_sticky_internal_scroll_and_mobile_breakpoint |
|
| 38 |
page.current_window.resize_to(1280, 800) |
|
| 39 |
page.execute_script('window.scrollTo(0, 1200)')
|
|
| 40 |
assert_selector '.wiki-toc-sidebar a.is-active' |
|
| 41 |
metrics = page.evaluate_script(<<~JS) |
|
| 42 |
(() => {
|
|
| 43 |
const toc = document.querySelector('.wiki-toc-sidebar');
|
|
| 44 |
const content = toc.querySelector('.wiki-toc-sidebar-content');
|
|
| 45 |
return {
|
|
| 46 |
position: getComputedStyle(toc).position, |
|
| 47 |
top: toc.getBoundingClientRect().top, |
|
| 48 |
height: toc.clientHeight, contentHeight: content.clientHeight, scrollHeight: content.scrollHeight, |
|
| 49 |
viewport: innerHeight |
|
| 50 |
}; |
|
| 51 |
})() |
|
| 52 |
JS |
|
| 53 |
assert_equal 'sticky', metrics['position'] |
|
| 54 |
assert_in_delta 10, metrics['top'], 2 |
|
| 55 |
assert_operator metrics['scrollHeight'], :>, metrics['contentHeight'] |
|
| 56 |
assert_operator metrics['height'], :<=, metrics['viewport'] - 20 |
|
| 57 |
controls_top = page.evaluate_script("document.querySelector('.wiki-toc-sidebar-controls').getBoundingClientRect().top")
|
|
| 58 |
page.execute_script("document.querySelector('.wiki-toc-sidebar-content').scrollTop = 150")
|
|
| 59 |
assert_operator page.evaluate_script("document.querySelector('.wiki-toc-sidebar-content').scrollTop"), :>, 0
|
|
| 60 |
assert_in_delta controls_top, page.evaluate_script("document.querySelector('.wiki-toc-sidebar-controls').getBoundingClientRect().top"), 1
|
|
| 61 |
page.current_window.resize_to(900, 800) |
|
| 62 |
assert_equal 'sticky', page.evaluate_script("getComputedStyle(document.querySelector('.wiki-toc-sidebar')).position")
|
|
| 63 |
page.current_window.resize_to(899, 800) |
|
| 64 |
assert_equal 'static', page.evaluate_script("getComputedStyle(document.querySelector('.wiki-toc-sidebar')).position")
|
|
| 65 |
assert_equal 'none', page.evaluate_script("getComputedStyle(document.querySelector('.wiki-toc-sidebar')).maxBlockSize")
|
|
| 66 |
assert_selector '.flyout-menu .wiki-toc-sidebar', :visible => :all |
|
| 67 |
end |
|
| 68 | ||
| 69 |
def test_scroll_controls_reach_page_ends_and_keep_the_fragment |
|
| 70 |
page.execute_script("window.location.hash = 'Heading-2'")
|
|
| 71 |
assert_selector '.wiki-toc-sidebar a.is-active[href="#Heading-2"]' |
|
| 72 |
find('.wiki-toc-sidebar-controls [data-action="wiki-sidebar-toc#scrollToBottom"]').click
|
|
| 73 |
assert_in_delta page.evaluate_script('document.scrollingElement.scrollHeight - innerHeight'),
|
|
| 74 |
page.evaluate_script('scrollY'), 1
|
|
| 75 |
find('.wiki-toc-sidebar-controls [data-action="wiki-sidebar-toc#scrollToTop"]').click
|
|
| 76 |
assert_in_delta 0, page.evaluate_script('scrollY'), 1
|
|
| 77 |
assert_equal '#Heading-2', page.evaluate_script('location.hash')
|
|
| 78 |
end |
|
| 79 | ||
| 80 |
def test_all_levels_remain_visible_without_toggle_padding |
|
| 81 |
[1, 2, 3].each do |number| |
|
| 82 |
assert_selector ".wiki-toc-sidebar a[href='#Heading-#{number}']"
|
|
| 83 |
end |
|
| 84 |
metrics = page.evaluate_script(<<~JS) |
|
| 85 |
(() => {
|
|
| 86 |
const links = [1, 2, 3].map(n => |
|
| 87 |
document.querySelector('.wiki-toc-sidebar a[href="#Heading-' + n + '"]'));
|
|
| 88 |
return links.map(link => ({
|
|
| 89 |
left: link.getBoundingClientRect().left, |
|
| 90 |
font: parseFloat(getComputedStyle(link).fontSize), |
|
| 91 |
padding: parseFloat(getComputedStyle(link.parentElement).paddingInlineStart) |
|
| 92 |
})); |
|
| 93 |
})() |
|
| 94 |
JS |
|
| 95 |
metrics.each {|metric| assert_in_delta 0, metric['padding'], 0.1}
|
|
| 96 |
[1, 2].each do |index| |
|
| 97 |
assert_in_delta metrics[index]['font'], metrics[index]['left'] - metrics[index - 1]['left'], 1 |
|
| 98 |
end |
|
| 99 |
end |
|
| 100 |
end |
|
- « Previous
- 1
- 2
- 3
- Next »