Commit e6aa2fbd authored by Phil Hughes's avatar Phil Hughes

Added ToC dropdown to markdown blob viewers

Adds a table of contents dropdown to the header for markdown
files. It parses all the headers on the page and then adds these
into the dropdown. Any item that isn't indented will become bolded
to treat it as a major header.

Changelog: added

Closes https://gitlab.com/gitlab-org/gitlab/-/issues/326252
parent c83695a3
<script>
import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
function getHeaderNumber(el) {
return parseInt(el.tagName.match(/\d+/)[0], 10);
}
export default {
components: {
GlDropdown,
GlDropdownItem,
},
data() {
return {
isHidden: false,
items: [],
};
},
mounted() {
this.blobViewer = document.querySelector('.blob-viewer[data-type="rich"]');
this.observer = new MutationObserver(() => {
if (this.blobViewer.classList.contains('hidden')) {
this.isHidden = true;
} else if (this.blobViewer.getAttribute('data-loaded') === 'true') {
this.isHidden = false;
this.generateHeaders();
}
});
if (this.blobViewer) {
this.observer.observe(this.blobViewer, {
attributes: true,
});
}
},
beforeDestroy() {
if (this.observer) {
this.observer.disconnect();
}
},
methods: {
generateHeaders() {
const headers = [...this.blobViewer.querySelectorAll('h1,h2,h3,h4,h5,h6')];
if (headers.length) {
const firstHeader = getHeaderNumber(headers[0]);
headers.forEach((el) => {
this.items.push({
text: el.textContent.trim(),
anchor: el.querySelector('a').getAttribute('id'),
spacing: Math.max((getHeaderNumber(el) - firstHeader) * 8, 0),
});
});
}
},
},
};
</script>
<template>
<gl-dropdown v-if="!isHidden && items.length" icon="list-bulleted" class="gl-mr-2">
<gl-dropdown-item v-for="(item, index) in items" :key="index" :href="`#${item.anchor}`">
<span
:style="{ 'padding-left': `${item.spacing}px` }"
class="gl-display-block"
data-testid="tableContentsLink"
>
{{ item.text }}
</span>
</gl-dropdown-item>
</gl-dropdown>
</template>
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import TableOfContents from '~/blob/components/table_contents.vue';
import PipelineTourSuccessModal from '~/blob/pipeline_tour_success_modal.vue';
import BlobViewer from '~/blob/viewer/index';
import GpgBadges from '~/gpg_badges';
......@@ -92,3 +93,15 @@ if (successPipelineEl) {
},
});
}
const tableContentsEl = document.querySelector('.js-table-contents');
if (tableContentsEl) {
// eslint-disable-next-line no-new
new Vue({
el: tableContentsEl,
render(h) {
return h(TableOfContents);
},
});
}
.file-header-content
- if Gitlab::MarkupHelper.gitlab_markdown?(blob.path)
.js-table-contents
= blob_icon blob.mode, blob.name
%strong.file-title-name.gl-word-break-all{ data: { qa_selector: 'file_name_content' } }
......
import { GlDropdownItem } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import TableContents from '~/blob/components/table_contents.vue';
let wrapper;
function createComponent() {
wrapper = shallowMount(TableContents);
}
async function setLoaded(loaded) {
document.querySelector('.blob-viewer').setAttribute('data-loaded', loaded);
await nextTick();
}
describe('Markdown table of contents component', () => {
beforeEach(() => {
setFixtures(`
<div class="blob-viewer" data-type="rich" data-loaded="false">
<h1><a href="#1"></a>Hello</h1>
<h2><a href="#2"></a>World</h2>
<h3><a href="#3"></a>Testing</h3>
<h2><a href="#4"></a>GitLab</h2>
</div>
`);
});
afterEach(() => {
wrapper.destroy();
});
describe('not loaded', () => {
it('does not populate dropdown', () => {
createComponent();
expect(wrapper.findComponent(GlDropdownItem).exists()).toBe(false);
});
});
describe('loaded', () => {
it('populates dropdown', async () => {
createComponent();
await setLoaded(true);
const dropdownItems = wrapper.findAllComponents(GlDropdownItem);
expect(dropdownItems.exists()).toBe(true);
expect(dropdownItems.length).toBe(4);
});
it('sets padding for dropdown items', async () => {
createComponent();
await setLoaded(true);
const dropdownLinks = wrapper.findAll('[data-testid="tableContentsLink"]');
expect(dropdownLinks.at(0).element.style.paddingLeft).toBe('0px');
expect(dropdownLinks.at(1).element.style.paddingLeft).toBe('8px');
expect(dropdownLinks.at(2).element.style.paddingLeft).toBe('16px');
expect(dropdownLinks.at(3).element.style.paddingLeft).toBe('8px');
});
});
});
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment