REFACTOR: General component overhaul (#19)

This commit is contained in:
Penar Musaraj 2022-01-18 09:18:21 -05:00 committed by GitHub
parent 5b2f5a455e
commit 20366c671d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 2576 additions and 804 deletions

View File

@ -1 +1,2 @@
2.7.13: 5b2f5a455e1adf8ce5e8c1cfb7fbc3c388d3d82a
2.6.0.beta3: 68d40fe9f5b625cf465adc31b502a54e16d02cc6

10
.eslintrc Normal file
View File

@ -0,0 +1,10 @@
{
"extends": "eslint-config-discourse",
"ignorePatterns": [
"javascripts/vendor/*"
],
"globals": {
"settings": "readonly",
"themePrefix": "readonly"
}
}

43
.github/workflows/theme-linting.yml vendored Normal file
View File

@ -0,0 +1,43 @@
name: Linting
on:
push:
branches:
- main
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Node.js
uses: actions/setup-node@v1
with:
node-version: 12
- name: Yarn install
run: yarn install
- name: ESLint
if: ${{ always() }}
run: yarn eslint --ext .js,.js.es6 --no-error-on-unmatched-pattern {test,javascripts}
- name: Prettier
if: ${{ always() }}
shell: bash
run: |
yarn prettier -v
shopt -s extglob
if ls @(javascripts|desktop|mobile|common|scss)/**/*.@(scss|js|es6) &> /dev/null; then
yarn prettier --list-different "@(javascripts|desktop|mobile|common|scss)/**/*.{scss,js,es6}"
fi
if ls test/**/*.@(js|es6) &> /dev/null; then
yarn prettier --list-different "test/**/*.{js,es6}"
fi
- name: Ember template lint
if: ${{ always() }}
run: yarn ember-template-lint javascripts

121
.github/workflows/theme-tests.yml vendored Normal file
View File

@ -0,0 +1,121 @@
name: Tests
on:
push:
branches:
- main
pull_request:
jobs:
build:
runs-on: ubuntu-latest
container: discourse/discourse_test:slim-browsers
timeout-minutes: 30
env:
DISCOURSE_HOSTNAME: www.example.com
RUBY_GLOBAL_METHOD_CACHE_SIZE: 131072
RAILS_ENV: development
QUNIT_RAILS_ENV: development
PGHOST: postgres
PGUSER: discourse
PGPASSWORD: discourse
services:
postgres:
image: postgres:13
ports:
- 5432:5432
env:
POSTGRES_USER: discourse
POSTGRES_PASSWORD: discourse
options: >-
--mount type=tmpfs,destination=/var/lib/postgresql/data
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v2
with:
repository: discourse/discourse
fetch-depth: 1
- name: Install component
uses: actions/checkout@v2
with:
path: tmp/component
fetch-depth: 1
- name: Setup Git
run: |
git config --global user.email "ci@ci.invalid"
git config --global user.name "Discourse CI"
- name: Start redis
run: |
redis-server /etc/redis/redis.conf &
- name: Start Postgres
run: |
chown -R postgres /var/run/postgresql
sudo -E -u postgres script/start_test_db.rb
sudo -u postgres psql -c "CREATE ROLE $PGUSER LOGIN SUPERUSER PASSWORD '$PGPASSWORD';"
- name: Bundler cache
uses: actions/cache@v2
with:
path: vendor/bundle
key: ${{ runner.os }}-2.7-gem-${{ hashFiles('**/Gemfile.lock') }}
restore-keys: |
${{ runner.os }}-2.7-gem-
- name: Setup gems
run: |
bundle config --local path vendor/bundle
bundle config --local deployment true
bundle config --local without development
bundle install --jobs 4
bundle clean
- name: Get yarn cache directory
id: yarn-cache-dir
run: echo "::set-output name=dir::$(yarn cache dir)"
- name: Yarn cache
uses: actions/cache@v2
id: yarn-cache
with:
path: ${{ steps.yarn-cache-dir.outputs.dir }}
key: ${{ runner.os }}-${{ matrix.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-${{ matrix.os }}-yarn-
- name: Yarn install
run: yarn install
- name: Migrate database
run: |
bin/rake db:create
bin/rake db:migrate
- name: Check qunit existence
id: check_qunit
shell: bash
run: |
shopt -s extglob
if ls tmp/component/test/**/*.@(js|es6) &> /dev/null; then
echo "::set-output name=files_exist::true"
fi
- name: Component QUnit
if: steps.check_qunit.outputs.files_exist == 'true'
run: |
bundle exec rake themes:install -- '--{"${{ github.event.repository.name }}": "tmp/component"}'
UNICORN_TIMEOUT=120 bundle exec rake themes:qunit[name,${{ github.event.repository.name }}]
timeout-minutes: 10
- name: Lint English locale
if: ${{ always() }}
run: bundle exec ruby script/i18n_lint.rb "tmp/component/locales/en.yml"

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
.discourse-site
.DS_Store
node_modules

4
.template-lintrc.js Normal file
View File

@ -0,0 +1,4 @@
module.exports = {
plugins: ["ember-template-lint-plugin-discourse"],
extends: "discourse:recommended",
};

View File

@ -1,339 +1,207 @@
@import "common/foundation/variables";
@import "sidebar";
@import "chat";
$padding-basis: 0.75em;
.d-toc-regular {
[data-theme-toc] {
.d-toc-main {
display: none;
}
.d-toc-ignore {
font-size: $font-up-1;
margin: 0 0 10px 0;
font-weight: bold;
}
.highlighted {
animation: fadein 1s;
}
.d-toc {
transform: translate3d(0, 0, 0);
transition: opacity 0.25s;
ul,
li {
list-style: none;
margin: 0;
padding: 0;
border: none;
}
.d-toc-item {
padding: 6px 0;
a {
color: var(--primary-high);
}
&.d-toc-active {
position: relative;
&:before {
:not(.rtl) & {
border-left: 1px solid var(--tertiary);
}
.rtl & {
border-right: 1px solid var(--tertiary);
}
height: 100%;
content: "";
width: 1px;
position: absolute;
top: 0;
}
a {
color: var(--primary);
text-shadow: 0.1px 0.1px var(--primary),
-0.1px -0.1px var(--primary);
}
}
}
.d-toc-heading {
:not(.rtl) & {
padding-left: 10px;
}
.rtl & {
padding-right: 10px;
}
.d-toc-active:before {
:not(.rtl) & {
left: -10px;
}
.rtl & {
right: -10px;
}
}
}
.d-toc-subheading {
:not(.rtl) & {
padding-left: 20px;
}
.rtl & {
padding-right: 20px;
}
.d-toc-active:before {
:not(.rtl) & {
left: -30px;
}
.rtl & {
right: -30px;
}
}
.d-toc-subheading {
:not(.rtl) & {
padding-left: 30px;
}
.rtl & {
padding-right: 30px;
}
.d-toc-active:before {
:not(.rtl) & {
left: -60px;
}
.rtl & {
right: -60px;
}
}
.d-toc-subheading {
:not(.rtl) & {
padding-left: 40px;
}
.rtl & {
padding-right: 40px;
}
.d-toc-active:before {
:not(.rtl) & {
left: -70px;
}
.rtl & {
right: -70px;
}
}
.d-toc-subheading {
:not(.rtl) & {
padding-left: 50px;
}
.rtl & {
padding-right: 50px;
}
.d-toc-active:before {
:not(.rtl) & {
left: -80px;
}
.rtl & {
right: -80px;
}
}
}
}
}
}
.d-toc-subheading li {
font-size: 0.8em;
}
}
#bottom-anchor {
opacity: 0;
height: 0;
margin: 0;
}
.post-bottom-wrapper {
a {
color: var(--primary-med-or-secondary-med);
}
}
}
// large screens
@media screen and (min-width: $large-width) {
.d-toc {
margin-top: 1em;
}
.post-bottom-wrapper {
padding: 1em 0.5em 0 0.5em;
&.mobile {
display: none;
}
}
.d-toc-toggle {
display: none !important;
}
.d-toc-close-wrapper,
.d-toc-subheading {
display: none;
}
.d-toc-post {
.topic-body,
.topic-avatar {
border-top: none;
}
.d-toc {
max-height: 85vh;
padding-left: 0;
position: -webkit-sticky;
position: sticky;
top: 75px;
margin-bottom: 135px;
max-width: 235px;
overflow-y: auto;
overflow-x: hidden;
align-self: flex-start;
flex: 1 1 auto;
:not(.rtl) & {
margin-left: -1px;
}
.rtl & {
margin-right: -1px;
}
}
.d-toc-article {
display: flex;
.post-notice {
display: none;
}
.topic-map {
margin-bottom: 0;
}
> .row {
:not(.rtl) & {
border-right: 1px solid var(--primary-low);
}
.rtl & {
width: 225px;
border-left: 1px solid var(--primary-low);
}
}
}
#topic-title {
margin-bottom: 0;
.title-wrapper {
border-bottom: 1px solid var(--primary-low);
padding-bottom: 0.5em;
width: auto;
}
}
}
}
// small screens
@media screen and (max-width: $large-width) {
.d-toc-regular {
.post-bottom-wrapper {
padding: 1em 0.75em;
&.desktop {
display: none;
box-sizing: border-box;
a {
display: block;
padding: 0.15em 0;
color: var(--primary-medium);
&.scroll-to-bottom {
padding-left: $padding-basis;
}
}
#d-toc {
z-index: z("header") + 1;
background: var(--secondary);
position: fixed;
right: 0;
top: 0;
height: 100vh;
width: 100vw;
max-width: 500px;
overflow: scroll;
transition: transform 0.5s, opacity 0.25s;
transform: translatex(100%);
opacity: 0;
:not(.rtl) & {
margin-left: -1px;
max-height: calc(100vh - 3em - var(--header-offset));
overflow: auto;
ul {
list-style-type: none;
margin: 0;
padding: 0;
}
.rtl & {
margin-right: -1px;
li.d-toc-item {
margin: 0;
padding: 0;
padding-left: $padding-basis;
line-height: 1.6em;
> ul {
max-height: 0;
overflow: hidden;
opacity: 0.5;
transition: opacity 0.3s ease-in-out, max-height 0.3s ease-in-out;
}
&.d-toc-mobile {
transform: translatex(0);
&.active,
.d-toc-wrapper.overlay & {
ul {
max-height: 50em;
overflow: visible;
opacity: 1;
}
.d-toc-active {
}
&.direct-active > a {
position: relative;
color: var(--primary);
&:before {
:not(.rtl) & {
content: "";
width: 1px;
margin-top: -1px;
background-color: var(--tertiary);
position: absolute;
height: 100%;
}
}
}
> ul > li > ul {
font-size: var(--font-down-1);
li.direct-active > a:before {
// it's odd that we need this
margin-left: -1px;
}
.rtl & {
margin-right: -1px;
ul {
margin-bottom: 0.5em;
}
}
}
}
.d-toc-close-wrapper {
height: 3em;
background: var(--secondary);
color: var(--primary-med-or-secondary-med);
margin-bottom: 1em;
position: -webkit-sticky;
position: sticky;
top: 0;
display: flex;
align-items: center;
justify-content: flex-end;
.d-toc-close {
padding: 1em 0.75em;
// active line marker
$selector: "> ul > li.direct-active > a:before";
$sub: "> ul > li";
$map: (
"left": "html:not(.rtl)",
"right": "html.rtl",
);
/*
// loop below generates styling for non-RTL and RTL
// Example:
html:not(.rtl) SELECTOR {
left: -.75em
}
html.rtl SELECTOR {
right: -.75em
}
*/
@each $prop, $parent in $map {
#{$parent} #d-toc {
#{$selector} {
#{$prop}: (-$padding-basis);
}
#{$sub} #{$selector} {
#{$prop}: (-$padding-basis) * 2;
}
#{$sub} #{$sub} #{$selector} {
#{$prop}: (-$padding-basis) * 3;
}
#{$sub} #{$sub} #{$sub} #{$selector} {
#{$prop}: (-$padding-basis) * 4;
}
#{$sub} #{$sub} #{$sub} #{$sub} #{$selector} {
#{$prop}: (-$padding-basis) * 5;
}
#{$sub} #{$sub} #{$sub} #{$sub} #{$sub} #{$selector} {
#{$prop}: (-$padding-basis) * 6;
}
}
.d-toc-toggle {
}
// END active line marker
.d-toc-mini,
a.d-toc-close {
display: none;
}
.d-toc-timeline-visible {
.d-toc-main,
.d-toc-mini {
display: block;
}
// overlayed timeline (on mobile and narrow screens)
.topic-navigation.with-topic-progress {
.d-toc-wrapper {
position: fixed;
bottom: 5px;
padding: 0.5em 1em;
background: var(--tertiary);
color: var(--secondary);
z-index: 3;
margin-bottom: env(safe-area-inset-bottom);
:not(.rtl) & {
right: 16px;
}
.rtl & {
left: 16px;
}
body.footer-nav-visible & {
bottom: 49px;
margin-top: 0.25em;
height: calc(100vh - 50px - var(--header-offset));
opacity: 0.5;
right: -100vw;
top: var(--header-offset);
width: 75vw;
max-width: 350px;
background-color: var(--secondary);
box-shadow: shadow("dropdown");
z-index: z("modal", "overlay");
transition: all 0.2s ease-in-out;
.d-toc-main {
width: 100%;
padding: 0.5em;
height: 100%;
#d-toc {
max-height: calc(100% - 3em);
}
}
#d-toc > ul {
:not(.rtl) & {
margin-left: 20px;
border-left: 1px solid var(--primary-low);
}
.rtl & {
margin-right: 20px;
border-right: 1px solid var(--primary-low);
}
&:last-child {
margin-bottom: 5em;
}
}
}
}
.d-toc-timeline {
.timeline-container,
#topic-progress-wrapper {
opacity: 0;
pointer-events: none;
transition: opacity 0.25s;
}
&.d-toc-timeline-visible {
.timeline-container,
#topic-progress-wrapper {
&.overlay {
right: 0;
width: 75vw;
opacity: 1;
pointer-events: initial;
.d-toc-main #d-toc li.d-toc-item ul {
transition: none;
}
}
a.scroll-to-bottom,
a.d-toc-close {
display: inline-block;
padding: 0.5em;
}
.d-toc-icons {
text-align: right;
}
}
}
// core overrides when timeline is active
.timeline-container,
#topic-progress {
display: none;
}
.container.posts .topic-navigation.with-topic-progress {
align-self: start;
}
}
// RTL Support
.rtl {
.d-toc-main {
border-left: none;
border-right: 1px solid var(--primary-low);
#d-toc li.d-toc-item,
a.scroll-to-bottom {
padding-left: 0;
padding-right: $padding-basis;
}
}
.d-toc-timeline-visible .topic-navigation.with-topic-progress .d-toc-wrapper {
right: unset;
left: -100vw;
&.overlay {
right: unset;
left: 0;
}
}
}
// Composer preview notice
.edit-title .d-editor-preview [data-theme-toc] {
background: var(--tertiary);
color: var(--secondary);
border-top: 2px solid var(--secondary);
position: -webkit-sticky;
position: sticky;
top: 0;
height: 30px;

View File

@ -1,395 +0,0 @@
<script type="text/discourse-plugin" version="0.1">
const minimumOffset = require("discourse/lib/offset-calculator").minimumOffset;
const { iconHTML } = require("discourse-common/lib/icon-library");
const { run } = Ember;
const mobileView = $("html").hasClass("mobile-view");
const linkIcon = iconHTML(settings.anchor_icon);
const closeIcon = iconHTML("times");
const dtocIcon = iconHTML("align-left");
const currUser = api.getCurrentUser();
const currUserTrustLevel = currUser ? currUser.trust_level : "";
const minimumTrustLevel = settings.minimum_trust_level_to_create_TOC;
const SCROLL_THROTTLE = 300;
const SMOOTH_SCROLL_SPEED = 300;
const TOC_ANIMATION_SPEED = 300;
const cleanUp = item => {
const cleanItem = item
.trim()
.toLowerCase()
.replace(
/[\{\}\[\]\\\/\<\>\(\)\|\+\?\*\^\'\`\'\"\.\_\$\s~!@#%&,;:=]/gi,
"-"
)
.replace(/\-\-+/g, "-")
.replace(/^\-/, "")
.replace(/\-$/, "");
return cleanItem;
};
const setUpTocItem = function (item) {
const unique = item.attr("id");
const text = item.text();
const tocItem = $("<li/>", {
class: "d-toc-item",
"data-d-toc": unique
});
tocItem.append(
$("<a/>", {
text: text
})
);
return tocItem;
};
(function (dToc) {
dToc($, window);
$.widget("discourse.dToc", {
_create: function () {
this.generateDtoc();
this.setEventHandlers();
},
generateDtoc: function () {
const self = this;
const primaryHeadings = $(this.options.cooked).find(
this.options.selectors.substr(0, this.options.selectors.indexOf(","))
);
self.element.addClass("d-toc");
primaryHeadings.each(function (index) {
const selectors = self.options.selectors,
ul = $("<ul/>", {
id: `d-toc-top-heading-${index}`,
class: "d-toc-heading"
});
ul.append(setUpTocItem($(this)));
self.element.append(ul);
$(this)
.nextUntil(this.nodeName.toLowerCase())
.each(function () {
const headings = $(this).find(selectors).length
? $(this).find(selectors)
: $(this).filter(selectors);
headings.each(function () {
self.nestTocItem.call(this, self, ul);
});
});
});
},
nestTocItem: function (self, ul) {
const index = $(this).index(self.options.selectors);
const previousHeader = $(self.options.selectors).eq(index - 1);
const previousTagName = previousHeader.prop("tagName").charAt(1);
const currentTagName = $(this).prop("tagName").charAt(1);
if (currentTagName < previousTagName) {
self.element
.find(`.d-toc-subheading[data-tag="${currentTagName}"]`)
.last()
.append(setUpTocItem($(this)));
} else if (currentTagName === previousTagName) {
ul.find(".d-toc-item")
.last()
.after(setUpTocItem($(this)));
} else {
ul.find(".d-toc-item")
.last()
.after(
$("<ul/>", {
class: "d-toc-subheading",
"data-tag": currentTagName
})
)
.next(".d-toc-subheading")
.append(setUpTocItem($(this)));
}
},
setEventHandlers: function () {
const self = this;
const dtocMobile = () => {
$(".d-toc").toggleClass("d-toc-mobile");
};
this.element.on("click.d-toc", "li", function () {
self.element.find(".d-toc-active").removeClass("d-toc-active");
$(this).addClass("d-toc-active");
if (mobileView) {
dtocMobile();
} else {
let elem = $(`li[data-d-toc="${$(this).attr("data-d-toc")}"]`);
self.triggerShowHide(elem);
}
self.scrollTo($(this));
});
$("#main").on(
"click.toggleDtoc",
".d-toc-toggle, .d-toc-close, .post-bottom-wrapper a",
dtocMobile
);
const onScroll = () => {
run.throttle(this, self.highlightItemsOnScroll, self, SCROLL_THROTTLE);
};
$(window).on("scroll.d-toc", onScroll);
},
highlightItemsOnScroll: self => {
$("html, body")
.promise()
.done(function () {
const winScrollTop = $(window).scrollTop();
const anchors = $(self.options.cooked).find("[data-d-toc]");
let closestAnchorDistance = null;
let closestAnchorIdx = null;
anchors.each(function (idx) {
const distance = Math.abs(
$(this).offset().top - minimumOffset() - winScrollTop
);
if (
closestAnchorDistance == null ||
distance < closestAnchorDistance
) {
closestAnchorDistance = distance;
closestAnchorIdx = idx;
} else {
return false;
}
});
const anchorText = $(anchors[closestAnchorIdx]).attr("data-d-toc");
const elem = $(`li[data-d-toc="${anchorText}"]`);
if (elem.length) {
self.element.find(".d-toc-active").removeClass("d-toc-active");
elem.addClass("d-toc-active");
}
if (!mobileView) {
self.triggerShowHide(elem);
}
});
},
triggerShowHide: function (elem) {
if (
elem.parent().is(".d-toc-heading") ||
elem.next().is(".d-toc-subheading")
) {
this.showHide(elem.next(".d-toc-subheading"));
} else if (elem.parent().is(".d-toc-subheading")) {
this.showHide(elem.parent());
}
},
showHide: function (elem) {
return elem.is(":visible") ? this.hide(elem) : this.show(elem);
},
hide: function (elem) {
const target = $(".d-toc-subheading")
.not(elem)
.not(elem.parents(".d-toc-subheading:has(.d-toc-active)"));
return target.slideUp(TOC_ANIMATION_SPEED);
},
show: function (elem) {
return elem.slideDown(TOC_ANIMATION_SPEED);
},
scrollTo: function (elem) {
const currentDiv = $(`[data-d-toc="${elem.attr("data-d-toc")}"]`);
$("html, body").animate(
{
scrollTop: `${currentDiv.offset().top - minimumOffset()}`
},
{
duration: SMOOTH_SCROLL_SPEED
}
);
},
setOptions: () => {
$.Widget.prototype._setOptions.apply(this, arguments);
}
});
})(() => {});
api.decorateCooked(
$elem => {
run.scheduleOnce("actions", () => {
if ($elem.hasClass("d-editor-preview")) return;
if (!$elem.parents("article#post_1").length) return;
const dToc = $elem.find(`[data-theme-toc="true"]`);
if (!dToc.length) return this;
const body = $elem;
body.find("div, aside, blockquote, article, details").each(function () {
$(this)
.children("h1,h2,h3,h4,h5,h6")
.each(function () {
$(this).replaceWith(
`<div class="d-toc-ignore">${$(this).html()}</div>`
);
});
});
body.append(`<span id="bottom-anchor" class="d-toc-igonore"></span>`);
let dTocHeadingSelectors = "h1,h2,h3,h4,h5,h6";
if (!body.has(">h1").length) {
dTocHeadingSelectors = "h2,h3,h4,h5,h6";
if (!body.has(">h2").length) {
dTocHeadingSelectors = "h3,h4,h5,h6";
if (!body.has(">h3").length) {
dTocHeadingSelectors = "h4,h5,h6";
if (!body.has(">h4").length) {
dTocHeadingSelectors = "h5,h6";
if (!body.has(">h5").length) {
dTocHeadingSelectors = "h6";
}
}
}
}
}
body.find(dTocHeadingSelectors).each(function () {
if ($(this).hasClass("d-toc-ignore")) return;
const heading = $(this);
let id = heading.attr("id") || "";
if (!id.length) {
id = cleanUp(heading.text());
}
heading
.attr({
id: id,
"data-d-toc": id
})
.addClass("d-toc-post-heading");
});
body
.addClass("d-toc-cooked")
.prepend(
`<span class="d-toc-toggle">
${dtocIcon} ${I18n.t(themePrefix("table_of_contents"))}
</span>`
)
.parents(".regular")
.addClass("d-toc-regular")
.parents("article")
.addClass("d-toc-article")
.append(
`<div class="d-toc-main">
<div class="post-bottom-wrapper dekstop">
<a href="#bottom-anchor" title="${I18n.t(
themePrefix("post_bottom_tooltip")
)}">${iconHTML("downward")}</a>
</div>
<ul id="d-toc">
<div class="d-toc-close-wrapper mobile">
<div class="post-bottom-wrapper">
<a href="#bottom-anchor" title="${I18n.t(
themePrefix("post_bottom_tooltip")
)}">${iconHTML("downward")}</a>
</div>
<div class="d-toc-close">
${closeIcon}
</div>
</div>
</ul>
</div>
`
)
.parents(".topic-post")
.addClass("d-toc-post")
.parents("body")
.addClass("d-toc-timeline");
$("#d-toc").dToc({
cooked: body,
selectors: dTocHeadingSelectors
});
});
},
{ id: "disco-toc" }
);
api.cleanupStream(() => {
$(window).off("scroll.d-toc");
$("#main").off("click.toggleDtoc");
$(".d-toc-timeline").removeClass("d-toc-timeline d-toc-timeline-visible");
});
api.onAppEvent("topic:current-post-changed", post => {
if (!$(".d-toc-timeline").length) return;
run.scheduleOnce("afterRender", () => {
if (post.post.post_number <= 2) {
$("body").removeClass("d-toc-timeline-visible");
$(".d-toc-toggle").fadeIn(100);
} else {
$("body").addClass("d-toc-timeline-visible");
$(".d-toc-toggle").fadeOut(100);
}
});
});
if (currUserTrustLevel >= minimumTrustLevel) {
if (!I18n.translations[I18n.currentLocale()].js.composer) {
I18n.translations[I18n.currentLocale()].js.composer = {};
}
I18n.translations[I18n.currentLocale()].js.composer.contains_dtoc = " ";
api.addToolbarPopupMenuOptionsCallback(() => {
const composerController = api.container.lookup("controller:composer");
return {
action: "insertDtoc",
icon: "align-left",
label: themePrefix("insert_table_of_contents"),
condition: composerController.get("model.canCategorize")
};
});
api.modifyClass("controller:composer", {
pluginId: "DiscoTOC",
actions: {
insertDtoc() {
this.get("toolbarEvent").applySurround(
`<div data-theme-toc="true">`,
`</div>`,
"contains_dtoc"
);
}
}
});
}
</script>

View File

@ -0,0 +1,3 @@
<a {{action "showTOCOverlay"}} href class="btn btn-primary">
{{theme-i18n "table_of_contents"}}
</a>

View File

@ -0,0 +1,7 @@
export default {
actions: {
showTOCOverlay() {
document.querySelector(".d-toc-wrapper").classList.toggle("overlay");
},
},
};

View File

@ -0,0 +1 @@
{{!-- TOC placeholder --}}

View File

@ -0,0 +1,47 @@
import I18n from "I18n";
import { withPluginApi } from "discourse/lib/plugin-api";
export default {
name: "disco-toc-composer",
initialize() {
withPluginApi("1.0.0", (api) => {
const currentUser = api.getCurrentUser();
if (!currentUser) {
return;
}
const minimumTL = settings.minimum_trust_level_to_create_TOC;
if (currentUser.trust_level >= minimumTL) {
if (!I18n.translations[I18n.currentLocale()].js.composer) {
I18n.translations[I18n.currentLocale()].js.composer = {};
}
I18n.translations[I18n.currentLocale()].js.composer.contains_dtoc = " ";
api.modifyClass("controller:composer", {
pluginId: "DiscoTOC",
actions: {
insertDtoc() {
this.get("toolbarEvent").applySurround(
`<div data-theme-toc="true">`,
`</div>`,
"contains_dtoc"
);
},
},
});
api.addToolbarPopupMenuOptionsCallback((controller) => {
return {
action: "insertDtoc",
icon: "align-left",
label: themePrefix("insert_table_of_contents"),
condition: controller.get("model.creatingTopic"),
};
});
}
});
},
};

View File

@ -0,0 +1,263 @@
import domUtils from "discourse-common/utils/dom-utils";
import { headerOffset } from "discourse/lib/offset-calculator";
import { iconHTML } from "discourse-common/lib/icon-library";
import { slugify } from "discourse/lib/utilities";
import { withPluginApi } from "discourse/lib/plugin-api";
import I18n from "I18n";
export default {
name: "disco-toc-main",
initialize() {
withPluginApi("1.0.0", (api) => {
api.decorateCookedElement(
(el, helper) => {
if (helper) {
const post = helper.getModel();
if (post.post_number !== 1) {
return;
}
if (!el.querySelector(`[data-theme-toc="true"]`)) {
return;
}
let dTocHeadingSelectors =
":scope > h1, :scope > h2, :scope > h3, :scope > h4, :scope > h5, :scope > h6";
const headings = el.querySelectorAll(dTocHeadingSelectors);
if (headings.length < 1) {
return;
}
headings.forEach((h) => {
const id =
h.getAttribute("id") ||
slugify(`toc-${h.nodeName}-${h.textContent}`);
h.setAttribute("id", id);
h.setAttribute("data-d-toc", id);
h.classList.add("d-toc-post-heading");
});
el.classList.add("d-toc-cooked");
const dToc = document.createElement("div");
dToc.classList.add("d-toc-main");
dToc.innerHTML = `<div class="d-toc-icons">
<a href="" class="scroll-to-bottom" title="${I18n.t(
themePrefix("post_bottom_tooltip")
)}">${iconHTML("downward")}</a>
<a href="" class="d-toc-close">${iconHTML("times")}</a></div>`;
const existing = document.querySelector(
".d-toc-wrapper .d-toc-main"
);
if (existing) {
document
.querySelector(".d-toc-wrapper")
.replaceChild(dToc, existing);
} else {
document.querySelector(".d-toc-wrapper").appendChild(dToc);
}
const startingLevel =
parseInt(headings[0].tagName.substring(1), 10) - 1;
let result = document.createElement("div");
result.setAttribute("id", "d-toc");
buildTOC(headings, result, startingLevel || 1);
document.querySelector(".d-toc-main").appendChild(result);
document.addEventListener("click", this.clickTOC, false);
}
},
{
id: "disco-toc",
onlyStream: true,
afterAdopt: true,
}
);
api.onAppEvent("topic:current-post-changed", (args) => {
if (!document.querySelector(".d-toc-cooked")) {
return;
}
if (args.post.post_number === 1) {
document.body.classList.add("d-toc-timeline-visible");
} else {
document.body.classList.remove("d-toc-timeline-visible");
}
});
api.onAppEvent("topic:current-post-scrolled", (args) => {
if (args.postIndex !== 1) {
return;
}
const headings = document.querySelectorAll(".d-toc-post-heading");
let closestHeadingDistance = null;
let closestHeading = null;
headings.forEach((heading) => {
const distance = Math.abs(
domUtils.offset(heading).top - headerOffset() - window.scrollY
);
if (
closestHeadingDistance == null ||
distance < closestHeadingDistance
) {
closestHeadingDistance = distance;
closestHeading = heading;
} else {
return false;
}
});
if (closestHeading) {
document.querySelectorAll("#d-toc li").forEach((listItem) => {
listItem.classList.remove("active");
listItem.classList.remove("direct-active");
});
const anchor = document.querySelector(
`#d-toc a[data-d-toc="${closestHeading.getAttribute("id")}"]`
);
anchor.parentElement.classList.add("direct-active");
parentsUntil(anchor, "#d-toc", ".d-toc-item").forEach((liParent) => {
liParent.classList.add("active");
});
}
});
api.cleanupStream(() => {
document.body.classList.remove("d-toc-timeline-visible");
document.removeEventListener("click", this.clickTOC, false);
});
});
},
clickTOC(e) {
if (!document.body.classList.contains("d-toc-timeline-visible")) {
return;
}
// link to each heading
if (e.target.hasAttribute("data-d-toc")) {
const target = `#${e.target.getAttribute("data-d-toc")}`;
const scrollTo = domUtils.offset(
document.querySelector(`.d-toc-cooked ${target}`)
).top;
window.scrollTo({
top: scrollTo - headerOffset() - 10,
behavior: "smooth",
});
document.querySelector(".d-toc-wrapper").classList.remove("overlay");
e.preventDefault();
return false;
}
if (e.target.closest("a")) {
// link to first post bottom
if (e.target.closest("a").classList.contains("scroll-to-bottom")) {
const rect = document
.querySelector(".d-toc-cooked")
.getBoundingClientRect();
if (rect) {
window.scrollTo({
top: rect.bottom + window.scrollY - headerOffset() - 10,
behavior: "smooth",
});
}
}
// close overlay
if (e.target.closest("a").classList.contains("d-toc-close")) {
document.querySelector(".d-toc-wrapper").classList.remove("overlay");
}
e.preventDefault();
return false;
}
if (!document.querySelector(".d-toc-wrapper.overlay")) {
return;
}
// clicking outside overlay
if (!e.target.closest(".d-toc-wrapper.overlay")) {
document.querySelector(".d-toc-wrapper").classList.remove("overlay");
}
},
};
function buildTOC(nodesList, elm, lv = 1) {
let nodes = Array.from(nodesList);
node = nodes.shift();
let node;
if (node) {
let li, cnt;
let curLv = parseInt(node.tagName.substring(1), 10);
if (curLv === lv) {
// same level
cnt = 0;
} else if (curLv < lv) {
// walk up then append
cnt = 0;
do {
elm = elm.parentNode.parentNode;
cnt--;
} while (cnt > curLv - lv);
} else if (curLv > lv) {
// add children
cnt = 0;
do {
li = elm.lastChild;
if (li == null) {
elm = elm.appendChild(document.createElement("ul"));
} else {
elm = li.appendChild(document.createElement("ul"));
}
cnt++;
} while (cnt < curLv - lv);
}
if (curLv === 1 && elm.lastChild === null) {
elm = elm.appendChild(document.createElement("ul"));
}
// append list item
li = elm.appendChild(document.createElement("li"));
li.classList.add("d-toc-item");
li.innerHTML = `<a data-d-toc="${node.getAttribute("id")}">${
node.textContent
}</a>`;
// recurse
buildTOC(nodes, elm, lv + cnt);
}
}
function parentsUntil(el, selector, filter) {
const result = [];
const matchesSelector =
el.matches ||
el.webkitMatchesSelector ||
el.mozMatchesSelector ||
el.msMatchesSelector;
// match start from parent
el = el.parentElement;
while (el && !matchesSelector.call(el, selector)) {
if (!filter) {
result.push(el);
} else {
if (matchesSelector.call(el, filter)) {
result.push(el);
}
}
el = el.parentElement;
}
return result;
}

10
package.json Normal file
View File

@ -0,0 +1,10 @@
{
"name": "DiscoTOC",
"version": "2.0.0",
"repository": "https://github.com/discourse/DiscoTOC",
"author": "Discourse",
"license": "MIT",
"devDependencies": {
"eslint-config-discourse": "^1.1.9"
}
}

View File

@ -1,3 +0,0 @@
.d-toc-timeline .topic-chat-float-container {
z-index: z("header");
}

View File

@ -1,105 +0,0 @@
.discourse-sidebar .d-toc-regular .d-toc-article .row {
width: 75%;
@media screen and (max-width: calc($large-width + 100px)) {
width: calc(100vw - 240px - 3em);
}
}
@media screen and (max-width: calc($large-width + 100px)) {
.discourse-sidebar {
.d-toc-regular {
.post-bottom-wrapper {
padding: 1em 0.75em;
&.desktop {
display: none;
}
}
#d-toc {
z-index: z("header") + 1;
background: var(--secondary);
position: fixed;
right: 0;
top: 0;
height: 100vh;
width: 100vw;
max-width: 500px;
overflow: scroll;
transition: transform 0.5s, opacity 0.25s;
transform: translatex(100%);
opacity: 0;
:not(.rtl) & {
margin-left: -1px;
}
.rtl & {
margin-right: -1px;
}
&.d-toc-mobile {
transform: translatex(0);
opacity: 1;
}
.d-toc-active {
&:before {
:not(.rtl) & {
margin-left: -1px;
}
.rtl & {
margin-right: -1px;
}
}
}
}
.d-toc-close-wrapper {
height: 3em;
background: var(--secondary);
color: var(--primary-med-or-secondary-med);
margin-bottom: 1em;
position: -webkit-sticky;
position: sticky;
top: 0;
display: flex;
align-items: center;
justify-content: flex-end;
.d-toc-close {
padding: 1em 0.75em;
}
}
.d-toc-toggle {
position: fixed;
bottom: 5px;
padding: 0.5em 1em;
background: var(--tertiary);
color: var(--secondary);
z-index: 3;
margin-bottom: env(safe-area-inset-bottom);
:not(.rtl) & {
right: 16px;
}
.rtl & {
left: 16px;
}
body.footer-nav-visible & {
bottom: 49px;
}
}
#d-toc > ul {
:not(.rtl) & {
margin-left: 20px;
border-left: 1px solid var(--primary-low);
}
.rtl & {
margin-right: 20px;
border-right: 1px solid var(--primary-low);
}
&:last-child {
margin-bottom: 5em;
}
}
}
}
}
.mobile-view .discourse-sidebar .d-toc-regular .d-toc-article .row {
width: 100%;
}

View File

@ -0,0 +1,27 @@
import { acceptance, query } from "discourse/tests/helpers/qunit-helpers";
import { click, visit } from "@ember/test-helpers";
import selectKit from "discourse/tests/helpers/select-kit-helper";
import { test } from "qunit";
acceptance("DiscoTOC - Composer", function (needs) {
needs.user();
test("Can use TOC when creating a topic", async function (assert) {
await visit("/");
await click("#create-topic");
const toolbarPopupMenu = selectKit(".toolbar-popup-menu-options");
await toolbarPopupMenu.expand();
await toolbarPopupMenu.selectRowByValue("insertDtoc");
assert.ok(query(".d-editor-input").value.includes('data-theme-toc="true"'));
});
test("no TOC option when replying", async function (assert) {
await visit("/t/internationalization-localization/280");
await click(".create.reply");
const toolbarPopupMenu = selectKit(".toolbar-popup-menu-options");
await toolbarPopupMenu.expand();
assert.notOk(toolbarPopupMenu.rowByValue("insertDtoc").exists());
});
});

232
test/acceptance/toc-test.js Normal file

File diff suppressed because one or more lines are too long

1635
yarn.lock Normal file

File diff suppressed because it is too large Load Diff