Skip to content

Commit 830c043

Browse files
authored
FEATURE: Allow TOC for replies (#90)
* FEATURE: Allow TOC for replies This commit adds an optional setting that allows enabling a TOC for replies. TOCs for replies are not affected by autoTOC settings like `auto_TOC_tags` and must be inserted manually.
1 parent 86b378d commit 830c043

File tree

11 files changed

+296
-95
lines changed

11 files changed

+296
-95
lines changed

common/common.scss

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,8 @@ html.rtl SELECTOR {
215215
}
216216

217217
// Composer preview notice
218-
.edit-title .d-editor-preview [data-theme-toc] {
218+
.edit-title .d-editor-preview [data-theme-toc],
219+
body.toc-for-replies-enabled .d-editor-preview [data-theme-toc] {
219220
background: var(--tertiary);
220221
color: var(--secondary);
221222
position: sticky;

javascripts/discourse/components/toc-contents.gjs

Lines changed: 48 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import { action } from "@ember/object";
44
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
55
import { service } from "@ember/service";
66
import { headerOffset } from "discourse/lib/offset-calculator";
7-
import { slugify } from "discourse/lib/utilities";
87
import { debounce } from "discourse-common/utils/decorators";
98
import TocHeading from "../components/toc-heading";
109
import TocLargeButtons from "../components/toc-large-buttons";
@@ -16,18 +15,20 @@ const RESIZE_DEBOUNCE = 200;
1615

1716
export default class TocContents extends Component {
1817
@service tocProcessor;
18+
@service appEvents;
1919

2020
@tracked activeHeadingId = null;
2121
@tracked headingPositions = [];
2222
@tracked activeAncestorIds = [];
2323

24-
get flattenedToc() {
25-
return this.flattenTocStructure(this.args.tocStructure);
24+
get mappedToc() {
25+
return this.mappedTocStructure(this.args.tocStructure);
2626
}
2727

2828
@action
2929
setup() {
3030
this.listenForScroll();
31+
this.listenForPostChange();
3132
this.listenForResize();
3233
this.updateHeadingPositions();
3334
this.updateActiveHeadingOnScroll(); // manual on setup so active class is added
@@ -37,6 +38,10 @@ export default class TocContents extends Component {
3738
super.willDestroy(...arguments);
3839
window.removeEventListener("scroll", this.updateActiveHeadingOnScroll);
3940
window.removeEventListener("resize", this.calculateHeadingPositions);
41+
this.appEvents.off(
42+
"topic:current-post-changed",
43+
this.calculateHeadingPositions
44+
);
4045
}
4146

4247
@action
@@ -50,6 +55,14 @@ export default class TocContents extends Component {
5055
window.addEventListener("resize", this.calculateHeadingPositions);
5156
}
5257

58+
@action
59+
listenForPostChange() {
60+
this.appEvents.on(
61+
"topic:current-post-changed",
62+
this.calculateHeadingPositions
63+
);
64+
}
65+
5366
@debounce(RESIZE_DEBOUNCE)
5467
calculateHeadingPositions() {
5568
this.updateHeadingPositions();
@@ -71,17 +84,27 @@ export default class TocContents extends Component {
7184
return;
7285
}
7386

74-
this.headingPositions = Array.from(headings).map((heading) => {
75-
const id = this.getIdFromHeading(heading);
76-
return {
77-
id,
78-
position:
79-
heading.getBoundingClientRect().top +
80-
window.scrollY -
81-
headerOffset() -
82-
POSITION_BUFFER,
83-
};
84-
});
87+
const sameIdCount = new Map();
88+
const mappedToc = this.mappedToc;
89+
this.headingPositions = Array.from(headings)
90+
.map((heading) => {
91+
const id = this.tocProcessor.getIdFromHeading(
92+
this.args.postID,
93+
heading,
94+
sameIdCount
95+
);
96+
return mappedToc[id]
97+
? {
98+
id,
99+
position:
100+
heading.getBoundingClientRect().top +
101+
window.scrollY -
102+
headerOffset() -
103+
POSITION_BUFFER,
104+
}
105+
: null;
106+
})
107+
.compact();
85108
}
86109

87110
@debounce(SCROLL_DEBOUNCE)
@@ -104,9 +127,8 @@ export default class TocContents extends Component {
104127
}
105128
}
106129

107-
const activeHeading = this.flattenedToc.find(
108-
(h) => h.id === this.headingPositions[activeIndex]?.id
109-
);
130+
const activeHeading =
131+
this.mappedToc[this.headingPositions[activeIndex]?.id];
110132

111133
this.activeHeadingId = activeHeading?.id;
112134
this.activeAncestorIds = [];
@@ -117,20 +139,15 @@ export default class TocContents extends Component {
117139
}
118140
}
119141

120-
getIdFromHeading(heading) {
121-
// reuse content from autolinked headings
122-
const tagName = heading.tagName.toLowerCase();
123-
const text = heading.textContent.trim();
124-
const anchor = heading.querySelector("a.anchor");
125-
return anchor ? anchor.name : `toc-${tagName}-${slugify(text)}`;
126-
}
127-
128-
flattenTocStructure(tocStructure) {
129-
// the post content is flat, but we want to keep the relationships added in tocStructure
130-
return tocStructure.flatMap((item) => [
131-
item,
132-
...(item.subItems ? this.flattenTocStructure(item.subItems) : []),
133-
]);
142+
mappedTocStructure(tocStructure, map = null) {
143+
map ??= {};
144+
for (const item of tocStructure) {
145+
map[item.id] = item;
146+
if (item.subItems) {
147+
this.mappedTocStructure(item.subItems, map);
148+
}
149+
}
150+
return map;
134151
}
135152

136153
<template>

javascripts/discourse/components/toc-heading.gjs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,9 @@ export default class TocHeading extends Component {
4242
return;
4343
}
4444

45-
const targetElement = document.querySelector(`a[name="${targetId}"]`);
45+
const targetElement =
46+
document.querySelector(`a[name="${targetId}"]`) ||
47+
document.getElementById(targetId);
4648
if (targetElement) {
4749
const headerOffsetValue = headerOffset();
4850
const elementPosition =

javascripts/discourse/components/toc-mini.gjs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,13 +39,13 @@ export default class TocMini extends Component {
3939

4040
<template>
4141
{{#if this.tocProcessor.hasTOC}}
42-
<div class="d-toc-mini">
42+
<span class="d-toc-mini">
4343
<DButton
4444
class="btn-primary"
4545
@icon="stream"
4646
@action={{this.toggleTOCOverlay}}
4747
/>
48-
</div>
48+
</span>
4949
{{/if}}
5050
</template>
5151
}

javascripts/discourse/initializers/disco-toc-composer.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,15 @@ export default {
3030
icon: "align-left",
3131
label: themePrefix("insert_table_of_contents"),
3232
condition: (composer) => {
33-
return composer.model.topicFirstPost;
33+
return (
34+
settings.enable_TOC_for_replies || composer.model.topicFirstPost
35+
);
3436
},
3537
});
38+
39+
if (settings.enable_TOC_for_replies) {
40+
document.body.classList.add("toc-for-replies-enabled");
41+
}
3642
}
3743
});
3844
},

javascripts/discourse/services/toc-processor.js

Lines changed: 39 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ export default class TocProcessor extends Service {
6464
}
6565

6666
shouldDisplayToc(post) {
67-
return post.post_number === 1;
67+
return settings.enable_TOC_for_replies || post.post_number === 1;
6868
}
6969

7070
containsTocMarkup(content) {
@@ -133,22 +133,42 @@ export default class TocProcessor extends Service {
133133
);
134134
}
135135

136+
/**
137+
* @param {number} postId
138+
* @param {HTMLHeadingElement} heading
139+
* @param {Map<string, number>} sameIdCount
140+
*/
141+
getIdFromHeading(postId, heading, sameIdCount) {
142+
const anchor = heading.querySelector("a.anchor");
143+
if (anchor) {
144+
return anchor.name;
145+
}
146+
const lowerTagName = heading.tagName.toLowerCase();
147+
const text = heading.textContent.trim();
148+
let slug = `${slugify(text)}`;
149+
if (sameIdCount.has(slug)) {
150+
sameIdCount.set(slug, sameIdCount.get(slug) + 1);
151+
slug = `${slug}-${sameIdCount.get(slug)}`;
152+
} else {
153+
sameIdCount.set(slug, 1);
154+
}
155+
const res = `p-${postId}-toc-${lowerTagName}-${slug}`;
156+
heading.id = res;
157+
return res;
158+
}
159+
136160
generateTocStructure(headings) {
137161
let root = { subItems: [], level: 0 };
138162
let ancestors = [root];
139163

140-
headings.forEach((heading, index) => {
164+
const sameIdCount = new Map();
165+
166+
headings.forEach((heading) => {
141167
const level = parseInt(heading.tagName[1], 10);
142168
const text = heading.textContent.trim();
143169
const lowerTagName = heading.tagName.toLowerCase();
144-
const anchor = heading.querySelector("a.anchor");
145170

146-
let id;
147-
if (anchor) {
148-
id = anchor.name;
149-
} else {
150-
id = `toc-${lowerTagName}-${slugify(text) || index}`;
151-
}
171+
const id = this.getIdFromHeading(this.postID, heading, sameIdCount);
152172

153173
// Remove irrelevant ancestors
154174
while (ancestors[ancestors.length - 1].level >= level) {
@@ -172,7 +192,7 @@ export default class TocProcessor extends Service {
172192
}
173193

174194
jumpToEnd(renderTimeline, postID) {
175-
const buffer = 150;
195+
let buffer = 150;
176196
const postContainer = document.querySelector(`[data-post-id="${postID}"]`);
177197

178198
if (!renderTimeline) {
@@ -185,6 +205,15 @@ export default class TocProcessor extends Service {
185205
const topicMapHeight =
186206
postContainer.querySelector(`.topic-map`)?.offsetHeight || 0;
187207

208+
if (
209+
postContainer.parentElement?.nextElementSibling?.querySelector(
210+
"div[data-theme-toc]"
211+
)
212+
) {
213+
// but if the next post also has a toc, just jump to it
214+
buffer = 30 - topicMapHeight;
215+
}
216+
188217
const offsetPosition =
189218
postContainer.getBoundingClientRect().bottom +
190219
window.scrollY -

locales/en.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,4 @@ en:
1212
auto_TOC_categories: Automatically enable TOC on topics in these categories
1313
auto_TOC_tags: Automatically enable TOC on topics with these tags
1414
TOC_min_heading: Minimum number of headings in a topic for the table of contents to be shown
15+
enable_TOC_for_replies: Allows TOC for replies. TOCs for replies are not affected by the <b>auto TOC tags</b> and <b>auto TOC categories</b> settings and must be inserted manually.

settings.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ auto_TOC_tags:
1616
type: list
1717
list_type: tag
1818
default: ""
19+
enable_TOC_for_replies:
20+
default: false
1921
TOC_min_heading:
2022
default: 3
2123
min: 1

spec/system/discotoc_author_spec.rb

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,17 @@
77
fab!(:user) { Fabricate(:user, trust_level: TrustLevel[1], refresh_auto_groups: true) }
88

99
fab!(:topic_1) { Fabricate(:topic) }
10-
fab!(:post_1) {
11-
Fabricate(:post, raw: "<div data-theme-toc='true'></div>\n\n# Heading 1\nContent for the first heading\n## Heading 2\nContent for the second heading\n### Heading 3\nContent for the third heading\n# Heading 4\nContent for the fourth heading", topic: topic_1)
12-
}
13-
14-
before do
15-
sign_in(user)
10+
fab!(:post_1) do
11+
Fabricate(
12+
:post,
13+
raw:
14+
"<div data-theme-toc='true'></div>\n\n# Heading 1\nContent for the first heading\n## Heading 2\nContent for the second heading\n### Heading 3\nContent for the third heading\n# Heading 4\nContent for the fourth heading",
15+
topic: topic_1,
16+
)
1617
end
1718

19+
before { sign_in(user) }
20+
1821
it "composer has table of contents button" do
1922
visit("/c/#{category.id}")
2023

@@ -35,7 +38,7 @@
3538
end
3639

3740
it "table of contents button is hidden by trust level setting" do
38-
theme.update_setting(:minimum_trust_level_to_create_TOC, "2" )
41+
theme.update_setting(:minimum_trust_level_to_create_TOC, "2")
3942
theme.save!
4043

4144
visit("/c/#{category.id}")
@@ -54,5 +57,20 @@
5457

5558
expect(page).to have_no_css("[data-name='Insert table of contents']")
5659
end
57-
60+
61+
context "when enable TOC for replies" do
62+
before do
63+
theme.update_setting(:enable_TOC_for_replies, true)
64+
theme.save!
65+
end
66+
67+
it "table of contents button does appear on replies" do
68+
visit("/t/#{topic_1.id}")
69+
70+
find(".reply").click
71+
find(".toolbar-popup-menu-options").click
72+
73+
expect(page).to have_css("[data-name='Insert table of contents']")
74+
end
75+
end
5876
end

0 commit comments

Comments
 (0)